Skip to content

Commit a8c8ca8

Browse files
committed
feat: add primitives module roving-focus subpkg
1 parent b5030ec commit a8c8ca8

File tree

8 files changed

+457
-0
lines changed

8 files changed

+457
-0
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"name": "soybean-react-ui/roving-focus",
3+
"version": "1.1.10",
4+
"license": "MIT",
5+
"homepage": "https://radix-ui.com/primitives",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/radix-ui/primitives.git"
9+
},
10+
"bugs": {
11+
"url": "https://github.com/radix-ui/primitives/issues"
12+
},
13+
"publishConfig": {
14+
"main": "./dist/index.js",
15+
"module": "./dist/index.mjs",
16+
"types": "./dist/index.d.ts",
17+
"exports": {
18+
".": {
19+
"import": {
20+
"types": "./dist/index.d.mts",
21+
"default": "./dist/index.mjs"
22+
},
23+
"require": {
24+
"types": "./dist/index.d.ts",
25+
"default": "./dist/index.js"
26+
}
27+
}
28+
}
29+
},
30+
"sideEffects": false,
31+
"main": "./src/index.ts",
32+
"module": "./src/index.ts",
33+
"files": ["dist", "README.md"],
34+
"scripts": {
35+
"build": "radix-build",
36+
"clean": "rm -rf dist",
37+
"lint": "eslint --max-warnings 0 src",
38+
"typecheck": "tsc --noEmit"
39+
},
40+
"peerDependencies": {
41+
"@types/react": "*",
42+
"@types/react-dom": "*",
43+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
44+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
45+
},
46+
"peerDependenciesMeta": {
47+
"@types/react": {
48+
"optional": true
49+
},
50+
"@types/react-dom": {
51+
"optional": true
52+
}
53+
},
54+
"dependencies": {
55+
"soybean-react-ui/collection": "workspace:*",
56+
"soybean-react-ui/compose-refs": "workspace:*",
57+
"soybean-react-ui/context": "workspace:*",
58+
"soybean-react-ui/direction": "workspace:*",
59+
"soybean-react-ui/id": "workspace:*",
60+
"soybean-react-ui/primitive": "workspace:*",
61+
"soybean-react-ui/use-callback-ref": "workspace:*",
62+
"soybean-react-ui/use-controllable-state": "workspace:*"
63+
},
64+
"devDependencies": {
65+
"@types/react": "19.0.7",
66+
"@types/react-dom": "19.0.3",
67+
"eslint": "9.18.0",
68+
"react": "19.1.0",
69+
"react-dom": "19.1.0",
70+
"typescript": "5.7.3"
71+
},
72+
"source": "./src/index.ts"
73+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { createContextScope } from 'soybean-react-ui/context';
2+
import { createCollection } from 'soybean-react-ui/collection';
3+
import { ItemData, RovingContextValue } from './types';
4+
5+
export const GROUP_NAME = 'RovingFocusGroup';
6+
7+
export const [Collection, useCollection, createCollectionScope] = createCollection<HTMLSpanElement, ItemData>(GROUP_NAME);
8+
9+
10+
export const [createRovingFocusGroupContext, createRovingFocusGroupScope] = createContextScope(GROUP_NAME, [
11+
createCollectionScope
12+
]);
13+
14+
export const [RovingFocusProvider, useRovingFocusContext] = createRovingFocusGroupContext<RovingContextValue>(GROUP_NAME);
15+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
'use client';
2+
3+
export {
4+
//
5+
RovingFocusGroup,
6+
} from './roving-focus-group';
7+
8+
export * from './roving-focus-item';
9+
10+
11+
export * from './context';
12+
13+
14+
export * from './types';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import * as React from 'react';
2+
3+
import { useComposedRefs } from 'soybean-react-ui/compose-refs';
4+
5+
6+
import { useDirection } from 'soybean-react-ui/direction';
7+
8+
import { Primitive, composeEventHandlers } from 'soybean-react-ui/primitive';
9+
import { useCallbackRef } from 'soybean-react-ui/use-callback-ref';
10+
import { useControllableState } from 'soybean-react-ui/use-controllable-state';
11+
import { Collection, GROUP_NAME, RovingFocusProvider, useCollection } from './context';
12+
import { RovingFocusGroupElement, RovingFocusGroupImplElement, RovingFocusGroupImplProps, RovingFocusGroupProps, ScopedProps } from './types';
13+
import { focusFirst } from './shared';
14+
15+
const ENTRY_FOCUS = 'rovingFocusGroup.onEntryFocus';
16+
17+
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
18+
19+
20+
const RovingFocusGroupImpl = React.forwardRef<RovingFocusGroupImplElement, RovingFocusGroupImplProps>(
21+
(props: ScopedProps<RovingFocusGroupImplProps>, forwardedRef) => {
22+
const {
23+
__scopeRovingFocusGroup,
24+
currentTabStopId: currentTabStopIdProp,
25+
defaultCurrentTabStopId,
26+
dir,
27+
loop = false,
28+
onCurrentTabStopIdChange,
29+
onEntryFocus,
30+
orientation,
31+
preventScrollOnEntryFocus = false,
32+
...groupProps
33+
} = props;
34+
const ref = React.useRef<RovingFocusGroupImplElement>(null);
35+
const composedRefs = useComposedRefs(forwardedRef, ref);
36+
const direction = useDirection(dir);
37+
const [currentTabStopId, setCurrentTabStopId] = useControllableState({
38+
caller: GROUP_NAME,
39+
defaultProp: defaultCurrentTabStopId ?? null,
40+
onChange: onCurrentTabStopIdChange,
41+
prop: currentTabStopIdProp
42+
});
43+
const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false);
44+
const handleEntryFocus = useCallbackRef(onEntryFocus);
45+
const getItems = useCollection(__scopeRovingFocusGroup);
46+
const isClickFocusRef = React.useRef(false);
47+
const [focusableItemsCount, setFocusableItemsCount] = React.useState(0);
48+
49+
React.useEffect(() => {
50+
const node = ref.current;
51+
if (node) {
52+
node.addEventListener(ENTRY_FOCUS, handleEntryFocus);
53+
54+
return () => node.removeEventListener(ENTRY_FOCUS, handleEntryFocus);
55+
}
56+
57+
return () => {};
58+
}, [handleEntryFocus]);
59+
60+
return (
61+
<RovingFocusProvider
62+
currentTabStopId={currentTabStopId}
63+
dir={direction}
64+
loop={loop}
65+
orientation={orientation}
66+
scope={__scopeRovingFocusGroup}
67+
onFocusableItemAdd={React.useCallback(() => setFocusableItemsCount(prevCount => prevCount + 1), [])}
68+
onFocusableItemRemove={React.useCallback(() => setFocusableItemsCount(prevCount => prevCount - 1), [])}
69+
onItemFocus={React.useCallback(tabStopId => setCurrentTabStopId(tabStopId), [setCurrentTabStopId])}
70+
onItemShiftTab={React.useCallback(() => setIsTabbingBackOut(true), [])}
71+
>
72+
<Primitive.div
73+
data-orientation={orientation}
74+
tabIndex={isTabbingBackOut || focusableItemsCount === 0 ? -1 : 0}
75+
{...groupProps}
76+
ref={composedRefs as React.Ref<HTMLDivElement>}
77+
style={{ outline: 'none', ...props.style }}
78+
onBlur={composeEventHandlers(props.onBlur, () => setIsTabbingBackOut(false))}
79+
onFocus={composeEventHandlers(props.onFocus, event => {
80+
// We normally wouldn't need this check, because we already check
81+
// that the focus is on the current target and not bubbling to it.
82+
// We do this because Safari doesn't focus buttons when clicked, and
83+
// instead, the wrapper will get focused and not through a bubbling event.
84+
const isKeyboardFocus = !isClickFocusRef.current;
85+
86+
if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) {
87+
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
88+
event.currentTarget.dispatchEvent(entryFocusEvent);
89+
90+
if (!entryFocusEvent.defaultPrevented) {
91+
const items = getItems().filter(item => item.focusable);
92+
const activeItem = items.find(item => item.active);
93+
const currentItem = items.find(item => item.id === currentTabStopId);
94+
const candidateItems = [activeItem, currentItem, ...items].filter(Boolean) as typeof items;
95+
const candidateNodes = candidateItems.map(item => item.ref.current!);
96+
focusFirst(candidateNodes, preventScrollOnEntryFocus);
97+
}
98+
}
99+
100+
isClickFocusRef.current = false;
101+
})}
102+
onMouseDown={composeEventHandlers(props.onMouseDown, () => {
103+
isClickFocusRef.current = true;
104+
})}
105+
/>
106+
</RovingFocusProvider>
107+
);
108+
}
109+
);
110+
111+
RovingFocusGroupImpl.displayName = `${GROUP_NAME}Impl`;
112+
113+
const RovingFocusGroup = React.forwardRef<RovingFocusGroupElement, RovingFocusGroupProps>(
114+
(props: ScopedProps<RovingFocusGroupProps>, forwardedRef) => {
115+
return (
116+
<Collection.Provider scope={props.__scopeRovingFocusGroup}>
117+
<Collection.Slot scope={props.__scopeRovingFocusGroup}>
118+
<RovingFocusGroupImpl
119+
{...props}
120+
ref={forwardedRef}
121+
/>
122+
</Collection.Slot>
123+
</Collection.Provider>
124+
);
125+
}
126+
);
127+
128+
RovingFocusGroup.displayName = GROUP_NAME;
129+
130+
export {
131+
RovingFocusGroup,
132+
};
133+
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { forwardRef, useEffect } from "react";
2+
import { RovingFocusItemElement, RovingFocusItemProps, ScopedProps } from "./types";
3+
import { Collection, useCollection, useRovingFocusContext } from "./context";
4+
import { useId } from 'soybean-react-ui/id';
5+
import { Primitive, composeEventHandlers } from 'soybean-react-ui/primitive';
6+
import { focusFirst, getFocusIntent, wrapArray } from "./shared";
7+
8+
const ITEM_NAME = 'RovingFocusGroupItem';
9+
10+
const RovingFocusGroupItem = forwardRef<RovingFocusItemElement, RovingFocusItemProps>(
11+
(props: ScopedProps<RovingFocusItemProps>, forwardedRef) => {
12+
13+
const { __scopeRovingFocusGroup, active = false, children, focusable = true, tabStopId, ...itemProps } = props;
14+
15+
const autoId = useId();
16+
17+
const id = tabStopId || autoId;
18+
19+
const context = useRovingFocusContext(ITEM_NAME, __scopeRovingFocusGroup);
20+
21+
const isCurrentTabStop = context.currentTabStopId === id;
22+
23+
const getItems = useCollection(__scopeRovingFocusGroup);
24+
25+
const { currentTabStopId, onFocusableItemAdd, onFocusableItemRemove } = context;
26+
27+
useEffect(() => {
28+
if (focusable) {
29+
30+
onFocusableItemAdd();
31+
32+
return () => onFocusableItemRemove();
33+
}
34+
35+
return () => {};
36+
}, [focusable, onFocusableItemAdd, onFocusableItemRemove]);
37+
38+
return (
39+
<Collection.ItemSlot
40+
active={active}
41+
focusable={focusable}
42+
id={id}
43+
scope={__scopeRovingFocusGroup}
44+
>
45+
<Primitive.span
46+
data-orientation={context.orientation}
47+
tabIndex={isCurrentTabStop ? 0 : -1}
48+
{...itemProps}
49+
ref={forwardedRef}
50+
onFocus={composeEventHandlers(props.onFocus, () => context.onItemFocus(id))}
51+
onKeyDown={composeEventHandlers(props.onKeyDown, event => {
52+
if (event.key === 'Tab' && event.shiftKey) {
53+
context.onItemShiftTab();
54+
return;
55+
}
56+
57+
if (event.target !== event.currentTarget) return;
58+
59+
const focusIntent = getFocusIntent(event, context.orientation, context.dir);
60+
61+
if (focusIntent !== undefined) {
62+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
63+
event.preventDefault();
64+
const items = getItems().filter(item => item.focusable);
65+
let candidateNodes = items.map(item => item.ref.current!);
66+
67+
if (focusIntent === 'last') candidateNodes.reverse();
68+
else if (focusIntent === 'prev' || focusIntent === 'next') {
69+
if (focusIntent === 'prev') candidateNodes.reverse();
70+
const currentIndex = candidateNodes.indexOf(event.currentTarget);
71+
candidateNodes = context.loop
72+
? wrapArray(candidateNodes, currentIndex + 1)
73+
: candidateNodes.slice(currentIndex + 1);
74+
}
75+
76+
/**
77+
* Imperative focus during keydown is risky so we prevent React's batching updates
78+
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
79+
*/
80+
setTimeout(() => focusFirst(candidateNodes));
81+
}
82+
})}
83+
onMouseDown={composeEventHandlers(props.onMouseDown, event => {
84+
// We prevent focusing non-focusable items on `mousedown`.
85+
// Even though the item has tabIndex={-1}, that only means take it out of the tab order.
86+
if (!focusable) event.preventDefault();
87+
// Safari doesn't focus a button when clicked so we run our logic on mousedown also
88+
else context.onItemFocus(id);
89+
})}
90+
>
91+
{typeof children === 'function'
92+
? children({ hasTabStop: currentTabStopId !== null, isCurrentTabStop })
93+
: children}
94+
</Primitive.span>
95+
</Collection.ItemSlot>
96+
);
97+
}
98+
);
99+
100+
RovingFocusGroupItem.displayName = ITEM_NAME;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Direction, FocusIntent, Orientation } from "./types";
2+
3+
4+
const MAP_KEY_TO_FOCUS_INTENT: Record<string, FocusIntent> = {
5+
ArrowDown: 'next', ArrowLeft: 'prev',
6+
ArrowRight: 'next', ArrowUp: 'prev',
7+
End: 'last', Home: 'first',
8+
PageDown: 'last', PageUp: 'first',
9+
};
10+
11+
12+
export function focusFirst(candidates: HTMLElement[], preventScroll = false) {
13+
const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
14+
for (const candidate of candidates) {
15+
// if focus is already where we want to go, we don't want to keep going through the candidates
16+
if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
17+
candidate.focus({ preventScroll });
18+
if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
19+
}
20+
}
21+
22+
/**
23+
* Wraps an array around itself at a given start index
24+
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
25+
*/
26+
export function wrapArray<T>(array: T[], startIndex: number) {
27+
return array.map<T>((_, index) => array[(startIndex + index) % array.length]!);
28+
}
29+
30+
31+
function getDirectionAwareKey(key: string, dir?: Direction) {
32+
if (dir !== 'rtl') return key;
33+
// eslint-disable-next-line no-nested-ternary
34+
return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key;
35+
}
36+
37+
38+
export function getFocusIntent(event: React.KeyboardEvent, orientation?: Orientation, dir?: Direction) {
39+
const key = getDirectionAwareKey(event.key, dir);
40+
if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) return undefined;
41+
if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) return undefined;
42+
return MAP_KEY_TO_FOCUS_INTENT[key];
43+
}

0 commit comments

Comments
 (0)