Skip to content

Commit 77f0d01

Browse files
committed
feat(utils): Implemented new keyboard focus behavior
1 parent ac60bdb commit 77f0d01

17 files changed

+2426
-0
lines changed

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from "./Dir";
1010
export * from "./events";
1111
export * from "./getPercentage";
1212
export * from "./hover";
13+
export * from "./keyboardMovement";
1314
export * from "./layout";
1415
export * from "./loop";
1516
export * from "./mode";
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ReactElement, ReactNode, useMemo } from "react";
2+
import {
3+
ActiveDescendantContext,
4+
ActiveDescendantContextProvider,
5+
} from "./activeDescendantContext";
6+
7+
/**
8+
* @internal
9+
* @remarks \@since 5.0.0
10+
*/
11+
export interface ActiveDescendantMovementProviderProps
12+
extends ActiveDescendantContext {
13+
children: ReactNode;
14+
}
15+
16+
/**
17+
* This component should be used with the {@link KeyboardMovementProvider}
18+
* component to implement custom keyboard focusable behavior using
19+
* `aria-activedescendant`.
20+
*
21+
* @example
22+
* Base Example
23+
* ```tsx
24+
* function Descendant({ id, children, ...props }: HTMLAttributes<HTMLDivElement>): ReactElement {
25+
* const { ref, active } = useActiveDescendant({ id });
26+
* return (
27+
* <div
28+
* {...props}
29+
* id={id}
30+
* ref={ref}
31+
* role="option"
32+
* tabIndex={-1}
33+
* className={active ? "active" : undefined}
34+
* >
35+
* {children}
36+
* </div>
37+
* );
38+
* }
39+
*
40+
* function CustomFocus(): ReactElement {
41+
* const { providerProps, focusIndex, ...containerProps } =
42+
* useActiveDescendantFocus()
43+
*
44+
* return (
45+
* <ActiveDescendantMovementProvider>
46+
* <div
47+
* {...containerProps}
48+
* id="some-unique-id"
49+
* role="listbox"
50+
* tabIndex={0}
51+
* >
52+
* <Descendant id="some-descendant-id">
53+
* Some Option
54+
* </Descendant>
55+
* </div>
56+
* </ActiveDescendantMovementProvider>
57+
* );
58+
* }
59+
*
60+
* function Example() {
61+
* return (
62+
* <KeyboardMovementProvider loopable searchable>
63+
* <CustomFocus />
64+
* </KeyboardMovementProvider>
65+
* );
66+
* }
67+
* ```
68+
*
69+
* @see https://www.w3.org/TR/wai-aria-practices/#kbd_focus_activedescendant
70+
* @internal
71+
* @remarks \@since 5.0.0
72+
*/
73+
export function ActiveDescendantMovementProvider({
74+
children,
75+
activeId,
76+
setActiveId,
77+
}: ActiveDescendantMovementProviderProps): ReactElement {
78+
return (
79+
<ActiveDescendantContextProvider
80+
value={useMemo(
81+
() => ({
82+
activeId,
83+
setActiveId,
84+
}),
85+
[activeId, setActiveId]
86+
)}
87+
>
88+
{children}
89+
</ActiveDescendantContextProvider>
90+
);
91+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { ReactElement, ReactNode, useMemo, useRef } from "react";
2+
3+
import {
4+
DEFAULT_KEYBOARD_MOVEMENT,
5+
KeyboardMovementContextProvider,
6+
} from "./movementContext";
7+
import type {
8+
KeyboardFocusContext,
9+
KeyboardFocusElementData,
10+
KeyboardMovementBehavior,
11+
KeyboardMovementConfig,
12+
KeyboardMovementConfiguration,
13+
} from "./types";
14+
import { getSearchText } from "./utils";
15+
16+
/**
17+
* @remarks \@since 5.0.0
18+
*/
19+
export interface KeyboardMovementProviderProps
20+
extends KeyboardMovementBehavior,
21+
KeyboardMovementConfiguration {
22+
children: ReactNode;
23+
}
24+
25+
/**
26+
* @example
27+
* Main Usage
28+
* ```tsx
29+
* function Example() {
30+
* return (
31+
* <KeyboardMovementProvider>
32+
* <CustomKeyboardFocusWidget />
33+
* </KeyboardMovementProvider>
34+
* );
35+
* }
36+
*
37+
* function CustomKeyboardFocusWidget() {
38+
* const { onKeyDown } = useKeyboardFocus();
39+
* return (
40+
* <div onKeyDown={onKeyDown}>
41+
* <FocusableChild />
42+
* <FocusableChild />
43+
* <FocusableChild />
44+
* <FocusableChild />
45+
* </div>
46+
* );
47+
* }
48+
*
49+
* function FocusableChild() {
50+
* const refCallback = useKeyboardFocusableElement()
51+
*
52+
* return <div role="menuitem" tabIndex={-1} ref={refCallback}>Content</div>;
53+
* }
54+
* ```
55+
*
56+
* @remarks \@since 5.0.0
57+
*/
58+
export function KeyboardMovementProvider({
59+
children,
60+
loopable = false,
61+
searchable = false,
62+
includeDisabled = false,
63+
incrementKeys = DEFAULT_KEYBOARD_MOVEMENT.incrementKeys,
64+
decrementKeys = DEFAULT_KEYBOARD_MOVEMENT.decrementKeys,
65+
jumpToFirstKeys = DEFAULT_KEYBOARD_MOVEMENT.jumpToFirstKeys,
66+
jumpToLastKeys = DEFAULT_KEYBOARD_MOVEMENT.jumpToLastKeys,
67+
}: KeyboardMovementProviderProps): ReactElement {
68+
const watching = useRef<KeyboardFocusElementData[]>([]);
69+
const configuration: KeyboardMovementConfig = {
70+
incrementKeys,
71+
decrementKeys,
72+
jumpToFirstKeys,
73+
jumpToLastKeys,
74+
};
75+
const config = useRef(configuration);
76+
config.current = configuration;
77+
78+
const value = useMemo<KeyboardFocusContext>(
79+
() => ({
80+
attach(element) {
81+
watching.current.push({
82+
element,
83+
content: getSearchText(element, searchable),
84+
});
85+
},
86+
detach(element) {
87+
watching.current = watching.current.filter(
88+
(cache) => cache.element !== element
89+
);
90+
},
91+
watching,
92+
config,
93+
loopable,
94+
searchable,
95+
includeDisabled: includeDisabled,
96+
}),
97+
[includeDisabled, loopable, searchable]
98+
);
99+
100+
return (
101+
<KeyboardMovementContextProvider value={value}>
102+
{children}
103+
</KeyboardMovementContextProvider>
104+
);
105+
}

0 commit comments

Comments
 (0)