Skip to content

Commit dc5c84f

Browse files
Menu [3] — Add typeahead functionality (radix-ui#248)
* Add typeahead support * Default to textContent, override with textValue prop * Fix warning in stories * Pass focusImpl to typeadhead and roving focus * Create 688c0cc1.yml * Revert focusImpl abstraction in favor of comments
1 parent be3c6dd commit dc5c84f

File tree

7 files changed

+262
-20
lines changed

7 files changed

+262
-20
lines changed

.yarn/versions/688c0cc1.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
releases:
2+
"@interop-ui/react-menu": prerelease
3+
"@interop-ui/utils": prerelease
4+
5+
declined:
6+
- interop-ui
7+
- "@interop-ui/popper"
8+
- "@interop-ui/react-accessible-icon"
9+
- "@interop-ui/react-accordion"
10+
- "@interop-ui/react-alert-dialog"
11+
- "@interop-ui/react-announce"
12+
- "@interop-ui/react-arrow"
13+
- "@interop-ui/react-aspect-ratio"
14+
- "@interop-ui/react-avatar"
15+
- "@interop-ui/react-checkbox"
16+
- "@interop-ui/react-collapsible"
17+
- "@interop-ui/react-dialog"
18+
- "@interop-ui/react-focus-scope"
19+
- "@interop-ui/react-label"
20+
- "@interop-ui/react-popover"
21+
- "@interop-ui/react-popper"
22+
- "@interop-ui/react-progress-bar"
23+
- "@interop-ui/react-radio-group"
24+
- "@interop-ui/react-separator"
25+
- "@interop-ui/react-slider"
26+
- "@interop-ui/react-switch"
27+
- "@interop-ui/react-tabs"
28+
- "@interop-ui/react-toggle-button"
29+
- "@interop-ui/react-tooltip"
30+
- "@interop-ui/react-use-size"
31+
- "@interop-ui/react-utils"
32+
- "@interop-ui/react-visually-hidden"

packages/core/utils/src/arrayUtils.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export function arrayRemove<T>(array: T[], item: T) {
1+
function arrayRemove<T>(array: T[], item: T) {
22
const updatedArray = [...array];
33
const index = updatedArray.indexOf(item);
44
if (index !== -1) {
@@ -7,6 +7,16 @@ export function arrayRemove<T>(array: T[], item: T) {
77
return updatedArray;
88
}
99

10-
export function arrayInsert<T>(array: T[], item: T, index: number) {
10+
function arrayInsert<T>(array: T[], item: T, index: number) {
1111
return [...array.slice(0, index), item, ...array.slice(index)];
1212
}
13+
14+
/**
15+
* Wraps an array around itself at a given start index
16+
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
17+
*/
18+
function wrapArray<T>(array: T[], startIndex: number) {
19+
return array.map((_, index) => array[(startIndex + index) % array.length]);
20+
}
21+
22+
export { arrayRemove, arrayInsert, wrapArray };

packages/react/menu/src/Menu.stories.tsx

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ export const WithLabels = () => (
3636
</Menu.Label>
3737
)}
3838
{foodGroup.foods.map((food) => (
39-
<Menu.Item as={StyledItem} key={food.value}>
39+
<Menu.Item
40+
key={food.value}
41+
as={StyledItem}
42+
disabled={food.disabled}
43+
onSelect={() => window.alert(food.label)}
44+
>
4045
{food.label}
4146
</Menu.Item>
4247
))}
@@ -46,6 +51,79 @@ export const WithLabels = () => (
4651
</Menu>
4752
);
4853

54+
const suits = [
55+
{ emoji: '♥️', label: 'Hearts' },
56+
{ emoji: '♠️', label: 'Spades' },
57+
{ emoji: '♦️', label: 'Diamonds' },
58+
{ emoji: '♣️', label: 'Clubs' },
59+
];
60+
61+
export const Typeahead = () => (
62+
<>
63+
<h1>Testing ground for typeahead behaviour</h1>
64+
<p style={{ maxWidth: 400, marginBottom: 30 }}>
65+
I recommend opening this story frame in it's own window (outside of the storybook frame)
66+
because Storybook has a bunch of shortcuts on certain keys (A, D, F, S, T) which get triggered
67+
all the time whilst testing the typeahead.
68+
</p>
69+
70+
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 100 }}>
71+
<div>
72+
<h2>Text labels</h2>
73+
<WithLabels />
74+
<div style={{ marginTop: 20 }}>
75+
<p>
76+
For comparison
77+
<br />
78+
try the closed select below
79+
</p>
80+
<select>
81+
{foodGroups.map((foodGroup, index) => (
82+
<React.Fragment key={index}>
83+
{foodGroup.foods.map((food) => (
84+
<option key={food.value} value={food.value} disabled={food.disabled}>
85+
{food.label}
86+
</option>
87+
))}
88+
</React.Fragment>
89+
))}
90+
</select>
91+
</div>
92+
</div>
93+
94+
<div>
95+
<h2>Complex children</h2>
96+
<p>(relying on `.textContent` — default)</p>
97+
<Menu as={StyledRoot}>
98+
{suits.map((suit) => (
99+
<Menu.Item key={suit.emoji} as={StyledItem}>
100+
{suit.label}
101+
<span role="img" aria-label={suit.label}>
102+
{suit.emoji}
103+
</span>
104+
</Menu.Item>
105+
))}
106+
</Menu>
107+
</div>
108+
109+
<div>
110+
<h2>Complex children</h2>
111+
<p>(with explicit `textValue` prop)</p>
112+
<Menu as={StyledRoot}>
113+
{suits.map((suit) => (
114+
<Menu.Item key={suit.emoji} as={StyledItem} textValue={suit.label}>
115+
<span role="img" aria-label={suit.label}>
116+
{suit.emoji}
117+
</span>
118+
{suit.label}
119+
</Menu.Item>
120+
))}
121+
</Menu>
122+
</div>
123+
</div>
124+
</>
125+
);
126+
49127
const StyledRoot = styled('div', {
50128
display: 'inline-block',
51129
boxSizing: 'border-box',
@@ -57,11 +135,15 @@ const StyledRoot = styled('div', {
57135
boxShadow: '0 5px 10px 0 rgba(0, 0, 0, 0.1)',
58136
fontFamily: 'apple-system, BlinkMacSystemFont, helvetica, arial, sans-serif',
59137
fontSize: 13,
138+
'&:focus-within': {
139+
borderColor: 'black',
140+
},
60141
});
61142

62143
const itemCss: any = {
63144
display: 'flex',
64145
alignItems: 'center',
146+
justifyContent: 'space-between',
65147
lineHeight: '1',
66148
cursor: 'default',
67149
userSelect: 'none',

packages/react/menu/src/Menu.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { composeEventHandlers, forwardRef, useComposedRefs } from '@interop-ui/react-utils';
33
import { getPartDataAttr, getPartDataAttrObj } from '@interop-ui/utils';
44
import { RovingFocusGroup, useRovingFocus } from './useRovingFocus';
5+
import { useMenuTypeahead, useMenuTypeaheadItem } from './useMenuTypeahead';
56

67
/* -------------------------------------------------------------------------------------------------
78
* Menu
@@ -26,6 +27,7 @@ const Menu = forwardRef<typeof MENU_DEFAULT_TAG, MenuProps, MenuStaticProps>(fun
2627
const composedRef = useComposedRefs(forwardedRef, menuRef);
2728
const [menuTabIndex, setMenuTabIndex] = React.useState(0);
2829
const [itemsReachable, setItemsReachable] = React.useState(false);
30+
const menuTypeaheadProps = useMenuTypeahead();
2931

3032
React.useEffect(() => {
3133
setMenuTabIndex(itemsReachable ? -1 : 0);
@@ -39,12 +41,17 @@ const Menu = forwardRef<typeof MENU_DEFAULT_TAG, MenuProps, MenuStaticProps>(fun
3941
ref={composedRef}
4042
tabIndex={menuTabIndex}
4143
style={{ ...menuProps.style, outline: 'none' }}
44+
onKeyDownCapture={composeEventHandlers(
45+
menuProps.onKeyDownCapture,
46+
menuTypeaheadProps.onKeyDownCapture
47+
)}
4248
// focus first/last item based on key pressed
4349
onKeyDown={composeEventHandlers(menuProps.onKeyDown, (event) => {
44-
if (event.target === menuRef.current) {
50+
const menu = menuRef.current;
51+
if (event.target === menu) {
4552
if (ALL_KEYS.includes(event.key)) {
4653
event.preventDefault();
47-
const items = Array.from(document.querySelectorAll(ENABLED_ITEM_SELECTOR));
54+
const items = Array.from(menu.querySelectorAll(ENABLED_ITEM_SELECTOR));
4855
const item = FIRST_KEYS.includes(event.key) ? items[0] : items.reverse()[0];
4956
(item as HTMLElement | undefined)?.focus();
5057
}
@@ -87,19 +94,37 @@ const ITEM_DEFAULT_TAG = 'div';
8794
const ENABLED_ITEM_SELECTOR = `[${getPartDataAttr(ITEM_NAME)}]:not([data-disabled])`;
8895

8996
type MenuItemDOMProps = React.ComponentPropsWithoutRef<typeof ITEM_DEFAULT_TAG>;
90-
type MenuItemOwnProps = { disabled?: boolean; onSelect?: () => void };
97+
type MenuItemOwnProps = {
98+
disabled?: boolean;
99+
textValue?: string;
100+
onSelect?: () => void;
101+
};
91102
type MenuItemProps = MenuItemDOMProps & MenuItemOwnProps;
92103

93104
const MenuItem = forwardRef<typeof ITEM_DEFAULT_TAG, MenuItemProps>(function MenuItem(
94105
props,
95106
forwardedRef
96107
) {
97-
const { as: Comp = ITEM_DEFAULT_TAG, disabled, tabIndex, ...itemProps } = props;
98-
const itemRef = React.useRef<HTMLDivElement>(null);
99-
const composedRef = useComposedRefs(forwardedRef, itemRef);
108+
const { as: Comp = ITEM_DEFAULT_TAG, disabled, textValue, onSelect, ...itemProps } = props;
109+
const menuItemRef = React.useRef<HTMLDivElement>(null);
110+
const composedRef = useComposedRefs(forwardedRef, menuItemRef);
111+
112+
// get the item's `.textContent` as default strategy for typeahead `textValue`
113+
const [textContent, setTextContent] = React.useState('');
114+
React.useEffect(() => {
115+
const menuItem = menuItemRef.current;
116+
if (menuItem) {
117+
setTextContent((menuItem.textContent ?? '').trim());
118+
}
119+
}, [itemProps.children]);
100120

101121
const rovingFocusProps = useRovingFocus({ disabled });
102-
const handleSelect = () => !disabled && itemProps.onSelect?.();
122+
const menuTypeaheadItemProps = useMenuTypeaheadItem({
123+
textValue: textValue ?? textContent,
124+
disabled,
125+
});
126+
127+
const handleSelect = () => !disabled && onSelect?.();
103128
const handleKeyDown = composeEventHandlers(rovingFocusProps.onKeyDown, (event) => {
104129
if (!disabled) {
105130
if (event.key === 'Enter' || event.key === ' ') {
@@ -115,6 +140,7 @@ const MenuItem = forwardRef<typeof ITEM_DEFAULT_TAG, MenuItemProps>(function Men
115140
{...itemProps}
116141
{...getPartDataAttrObj(ITEM_NAME)}
117142
{...rovingFocusProps}
143+
{...menuTypeaheadItemProps}
118144
ref={composedRef}
119145
data-disabled={disabled ? '' : undefined}
120146
onFocus={composeEventHandlers(itemProps.onFocus, rovingFocusProps.onFocus)}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as React from 'react';
2+
import { getPartDataAttr, wrapArray } from '@interop-ui/utils';
3+
4+
function useMenuTypeahead() {
5+
const timerRef = React.useRef(0);
6+
const searchRef = React.useRef('');
7+
8+
// Reset `searchRef` 1 second after it was last updated
9+
const setSearch = React.useCallback((search: string) => {
10+
searchRef.current = search;
11+
window.clearTimeout(timerRef.current);
12+
timerRef.current = window.setTimeout(() => setSearch(''), 1000);
13+
}, []);
14+
15+
return {
16+
onKeyDownCapture: (event: React.KeyboardEvent) => {
17+
if (event.key.length === 1 && !(event.ctrlKey || event.altKey || event.metaKey)) {
18+
const container = event.currentTarget as HTMLElement;
19+
setSearch(searchRef.current + event.key);
20+
21+
// Stop activating the item if we're still "searching"
22+
// This is also why we use `onKeyDownCapture` rather than `onKeyDown`
23+
if (event.key === ' ' && !searchRef.current.startsWith(' ')) {
24+
event.stopPropagation();
25+
}
26+
27+
const currentItem = document.activeElement;
28+
const currentMatch = currentItem ? getValue(currentItem) : undefined;
29+
const values = Array.from(container.querySelectorAll(`[${ITEM_ATTR}]`)).map(getValue);
30+
const nextMatch = getNextMatch(values, searchRef.current, currentMatch);
31+
const newItem = container.querySelector(`[${ITEM_ATTR}="${nextMatch}"]`);
32+
33+
if (newItem) {
34+
/**
35+
* Imperative focus during keydown is risky so we prevent React's batching updates
36+
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
37+
*/
38+
setTimeout(() => (newItem as HTMLElement).focus());
39+
}
40+
}
41+
},
42+
};
43+
}
44+
45+
/**
46+
* This is the "meat" of the matching logic. It takes in all the values,
47+
* the search and the current match, and returns the next match (or `undefined`).
48+
*
49+
* We normalize the search because if a user has repeatedly pressed a character,
50+
* we want the exact same behavior as if we only had that one character
51+
* (ie. cycle through options starting with that character)
52+
*
53+
* We also reorder the values by wrapping the array around the current match.
54+
* This is so we always look forward from the current match, and picking the first
55+
* match will always be the correct one.
56+
*
57+
* Finally, if the normalized search is exactly one character, we exclude the
58+
* current match from the values because otherwise it would be the first to match always
59+
* and focus would never move. This is as opposed to the regular case, where we
60+
* don't want focus to move if the current match still matches.
61+
*/
62+
function getNextMatch(values: string[], search: string, currentMatch?: string) {
63+
const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
64+
const normalizedSearch = isRepeated ? search[0] : search;
65+
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
66+
let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
67+
const excludeCurrentMatch = normalizedSearch.length === 1;
68+
if (excludeCurrentMatch) wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
69+
const nextMatch = wrappedValues.find((value) =>
70+
value.toLowerCase().startsWith(normalizedSearch.toLowerCase())
71+
);
72+
return nextMatch !== currentMatch ? nextMatch : undefined;
73+
}
74+
75+
const getValue = (element: Element) => element.getAttribute(ITEM_ATTR) ?? '';
76+
77+
const ITEM_NAME = 'MenuTypeaheadItem';
78+
const ITEM_ATTR = getPartDataAttr(ITEM_NAME);
79+
80+
type UseMenuTypeaheadItemOptions = { textValue: string; disabled?: boolean };
81+
82+
function useMenuTypeaheadItem({ textValue, disabled }: UseMenuTypeaheadItemOptions) {
83+
return { [ITEM_ATTR]: disabled ? undefined : textValue };
84+
}
85+
86+
export { useMenuTypeahead, useMenuTypeaheadItem };

packages/react/menu/src/useRovingFocus.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ type RovingFocusGroupOptions = {
1616

1717
type RovingContextValue = {
1818
groupId: string;
19-
reachable: boolean;
20-
setReachable: React.Dispatch<React.SetStateAction<boolean | undefined>>;
2119
tabStopId: string | null;
2220
setTabStopId: React.Dispatch<React.SetStateAction<string | null>>;
21+
reachable: boolean;
22+
setReachable: React.Dispatch<React.SetStateAction<boolean | undefined>>;
2323
} & RovingFocusGroupOptions;
2424

2525
const GROUP_NAME = 'RovingFocusGroup';
@@ -38,19 +38,18 @@ type RovingFocusGroupProps = RovingFocusGroupOptions & {
3838

3939
function RovingFocusGroup(props: RovingFocusGroupProps) {
4040
const { children, orientation, loop, dir } = props;
41-
const { reachable: reachableProp, defaultReachable, onReachableChange } = props;
4241
const [reachable = true, setReachable] = useControlledState({
43-
prop: reachableProp,
44-
defaultProp: defaultReachable,
45-
onChange: onReachableChange,
42+
prop: props.reachable,
43+
defaultProp: props.defaultReachable,
44+
onChange: props.onReachableChange,
4645
});
4746
const [tabStopId, setTabStopId] = React.useState<string | null>(null);
4847
const groupId = String(useId());
4948

5049
// prettier-ignore
5150
const context = React.useMemo(() => ({
52-
groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop, }),
53-
[ groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop, ]
51+
groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop }),
52+
[groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop ]
5453
);
5554

5655
return <RovingFocusContext.Provider value={context}>{children}</RovingFocusContext.Provider>;
@@ -114,8 +113,14 @@ function useRovingFocus({ disabled, active }: UseRovingFocusItemOptions) {
114113
const map = { first: 0, last: count - 1, prev: currentIndex - 1, next: currentIndex + 1 };
115114
let nextIndex = map[focusIntent];
116115
nextIndex = context.loop ? wrap(nextIndex, count) : clamp(nextIndex, [0, count - 1]);
117-
// See: https://github.com/facebook/react/issues/20332
118-
setTimeout(() => items[nextIndex]?.focus());
116+
const nextItem = items[nextIndex];
117+
if (nextItem) {
118+
/**
119+
* Imperative focus during keydown is risky so we prevent React's batching updates
120+
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
121+
*/
122+
setTimeout(() => nextItem.focus());
123+
}
119124
}
120125
},
121126
};

0 commit comments

Comments
 (0)