Skip to content

Commit

Permalink
Menu [3] — Add typeahead functionality (radix-ui#248)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
benoitgrelard authored Nov 30, 2020
1 parent be3c6dd commit dc5c84f
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 20 deletions.
32 changes: 32 additions & 0 deletions .yarn/versions/688c0cc1.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
releases:
"@interop-ui/react-menu": prerelease
"@interop-ui/utils": prerelease

declined:
- interop-ui
- "@interop-ui/popper"
- "@interop-ui/react-accessible-icon"
- "@interop-ui/react-accordion"
- "@interop-ui/react-alert-dialog"
- "@interop-ui/react-announce"
- "@interop-ui/react-arrow"
- "@interop-ui/react-aspect-ratio"
- "@interop-ui/react-avatar"
- "@interop-ui/react-checkbox"
- "@interop-ui/react-collapsible"
- "@interop-ui/react-dialog"
- "@interop-ui/react-focus-scope"
- "@interop-ui/react-label"
- "@interop-ui/react-popover"
- "@interop-ui/react-popper"
- "@interop-ui/react-progress-bar"
- "@interop-ui/react-radio-group"
- "@interop-ui/react-separator"
- "@interop-ui/react-slider"
- "@interop-ui/react-switch"
- "@interop-ui/react-tabs"
- "@interop-ui/react-toggle-button"
- "@interop-ui/react-tooltip"
- "@interop-ui/react-use-size"
- "@interop-ui/react-utils"
- "@interop-ui/react-visually-hidden"
14 changes: 12 additions & 2 deletions packages/core/utils/src/arrayUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export function arrayRemove<T>(array: T[], item: T) {
function arrayRemove<T>(array: T[], item: T) {
const updatedArray = [...array];
const index = updatedArray.indexOf(item);
if (index !== -1) {
Expand All @@ -7,6 +7,16 @@ export function arrayRemove<T>(array: T[], item: T) {
return updatedArray;
}

export function arrayInsert<T>(array: T[], item: T, index: number) {
function arrayInsert<T>(array: T[], item: T, index: number) {
return [...array.slice(0, index), item, ...array.slice(index)];
}

/**
* Wraps an array around itself at a given start index
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
*/
function wrapArray<T>(array: T[], startIndex: number) {
return array.map((_, index) => array[(startIndex + index) % array.length]);
}

export { arrayRemove, arrayInsert, wrapArray };
84 changes: 83 additions & 1 deletion packages/react/menu/src/Menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export const WithLabels = () => (
</Menu.Label>
)}
{foodGroup.foods.map((food) => (
<Menu.Item as={StyledItem} key={food.value}>
<Menu.Item
key={food.value}
as={StyledItem}
disabled={food.disabled}
onSelect={() => window.alert(food.label)}
>
{food.label}
</Menu.Item>
))}
Expand All @@ -46,6 +51,79 @@ export const WithLabels = () => (
</Menu>
);

const suits = [
{ emoji: '♥️', label: 'Hearts' },
{ emoji: '♠️', label: 'Spades' },
{ emoji: '♦️', label: 'Diamonds' },
{ emoji: '♣️', label: 'Clubs' },
];

export const Typeahead = () => (
<>
<h1>Testing ground for typeahead behaviour</h1>
<p style={{ maxWidth: 400, marginBottom: 30 }}>
I recommend opening this story frame in it's own window (outside of the storybook frame)
because Storybook has a bunch of shortcuts on certain keys (A, D, F, S, T) which get triggered
all the time whilst testing the typeahead.
</p>

<div style={{ display: 'flex', alignItems: 'flex-start', gap: 100 }}>
<div>
<h2>Text labels</h2>
<WithLabels />
<div style={{ marginTop: 20 }}>
<p>
For comparison
<br />
try the closed select below
</p>
<select>
{foodGroups.map((foodGroup, index) => (
<React.Fragment key={index}>
{foodGroup.foods.map((food) => (
<option key={food.value} value={food.value} disabled={food.disabled}>
{food.label}
</option>
))}
</React.Fragment>
))}
</select>
</div>
</div>

<div>
<h2>Complex children</h2>
<p>(relying on `.textContent` — default)</p>
<Menu as={StyledRoot}>
{suits.map((suit) => (
<Menu.Item key={suit.emoji} as={StyledItem}>
{suit.label}
<span role="img" aria-label={suit.label}>
{suit.emoji}
</span>
</Menu.Item>
))}
</Menu>
</div>

<div>
<h2>Complex children</h2>
<p>(with explicit `textValue` prop)</p>
<Menu as={StyledRoot}>
{suits.map((suit) => (
<Menu.Item key={suit.emoji} as={StyledItem} textValue={suit.label}>
<span role="img" aria-label={suit.label}>
{suit.emoji}
</span>
{suit.label}
</Menu.Item>
))}
</Menu>
</div>
</div>
</>
);

const StyledRoot = styled('div', {
display: 'inline-block',
boxSizing: 'border-box',
Expand All @@ -57,11 +135,15 @@ const StyledRoot = styled('div', {
boxShadow: '0 5px 10px 0 rgba(0, 0, 0, 0.1)',
fontFamily: 'apple-system, BlinkMacSystemFont, helvetica, arial, sans-serif',
fontSize: 13,
'&:focus-within': {
borderColor: 'black',
},
});

const itemCss: any = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
lineHeight: '1',
cursor: 'default',
userSelect: 'none',
Expand Down
40 changes: 33 additions & 7 deletions packages/react/menu/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import { composeEventHandlers, forwardRef, useComposedRefs } from '@interop-ui/react-utils';
import { getPartDataAttr, getPartDataAttrObj } from '@interop-ui/utils';
import { RovingFocusGroup, useRovingFocus } from './useRovingFocus';
import { useMenuTypeahead, useMenuTypeaheadItem } from './useMenuTypeahead';

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

React.useEffect(() => {
setMenuTabIndex(itemsReachable ? -1 : 0);
Expand All @@ -39,12 +41,17 @@ const Menu = forwardRef<typeof MENU_DEFAULT_TAG, MenuProps, MenuStaticProps>(fun
ref={composedRef}
tabIndex={menuTabIndex}
style={{ ...menuProps.style, outline: 'none' }}
onKeyDownCapture={composeEventHandlers(
menuProps.onKeyDownCapture,
menuTypeaheadProps.onKeyDownCapture
)}
// focus first/last item based on key pressed
onKeyDown={composeEventHandlers(menuProps.onKeyDown, (event) => {
if (event.target === menuRef.current) {
const menu = menuRef.current;
if (event.target === menu) {
if (ALL_KEYS.includes(event.key)) {
event.preventDefault();
const items = Array.from(document.querySelectorAll(ENABLED_ITEM_SELECTOR));
const items = Array.from(menu.querySelectorAll(ENABLED_ITEM_SELECTOR));
const item = FIRST_KEYS.includes(event.key) ? items[0] : items.reverse()[0];
(item as HTMLElement | undefined)?.focus();
}
Expand Down Expand Up @@ -87,19 +94,37 @@ const ITEM_DEFAULT_TAG = 'div';
const ENABLED_ITEM_SELECTOR = `[${getPartDataAttr(ITEM_NAME)}]:not([data-disabled])`;

type MenuItemDOMProps = React.ComponentPropsWithoutRef<typeof ITEM_DEFAULT_TAG>;
type MenuItemOwnProps = { disabled?: boolean; onSelect?: () => void };
type MenuItemOwnProps = {
disabled?: boolean;
textValue?: string;
onSelect?: () => void;
};
type MenuItemProps = MenuItemDOMProps & MenuItemOwnProps;

const MenuItem = forwardRef<typeof ITEM_DEFAULT_TAG, MenuItemProps>(function MenuItem(
props,
forwardedRef
) {
const { as: Comp = ITEM_DEFAULT_TAG, disabled, tabIndex, ...itemProps } = props;
const itemRef = React.useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(forwardedRef, itemRef);
const { as: Comp = ITEM_DEFAULT_TAG, disabled, textValue, onSelect, ...itemProps } = props;
const menuItemRef = React.useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(forwardedRef, menuItemRef);

// get the item's `.textContent` as default strategy for typeahead `textValue`
const [textContent, setTextContent] = React.useState('');
React.useEffect(() => {
const menuItem = menuItemRef.current;
if (menuItem) {
setTextContent((menuItem.textContent ?? '').trim());
}
}, [itemProps.children]);

const rovingFocusProps = useRovingFocus({ disabled });
const handleSelect = () => !disabled && itemProps.onSelect?.();
const menuTypeaheadItemProps = useMenuTypeaheadItem({
textValue: textValue ?? textContent,
disabled,
});

const handleSelect = () => !disabled && onSelect?.();
const handleKeyDown = composeEventHandlers(rovingFocusProps.onKeyDown, (event) => {
if (!disabled) {
if (event.key === 'Enter' || event.key === ' ') {
Expand All @@ -115,6 +140,7 @@ const MenuItem = forwardRef<typeof ITEM_DEFAULT_TAG, MenuItemProps>(function Men
{...itemProps}
{...getPartDataAttrObj(ITEM_NAME)}
{...rovingFocusProps}
{...menuTypeaheadItemProps}
ref={composedRef}
data-disabled={disabled ? '' : undefined}
onFocus={composeEventHandlers(itemProps.onFocus, rovingFocusProps.onFocus)}
Expand Down
86 changes: 86 additions & 0 deletions packages/react/menu/src/useMenuTypeahead.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as React from 'react';
import { getPartDataAttr, wrapArray } from '@interop-ui/utils';

function useMenuTypeahead() {
const timerRef = React.useRef(0);
const searchRef = React.useRef('');

// Reset `searchRef` 1 second after it was last updated
const setSearch = React.useCallback((search: string) => {
searchRef.current = search;
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setSearch(''), 1000);
}, []);

return {
onKeyDownCapture: (event: React.KeyboardEvent) => {
if (event.key.length === 1 && !(event.ctrlKey || event.altKey || event.metaKey)) {
const container = event.currentTarget as HTMLElement;
setSearch(searchRef.current + event.key);

// Stop activating the item if we're still "searching"
// This is also why we use `onKeyDownCapture` rather than `onKeyDown`
if (event.key === ' ' && !searchRef.current.startsWith(' ')) {
event.stopPropagation();
}

const currentItem = document.activeElement;
const currentMatch = currentItem ? getValue(currentItem) : undefined;
const values = Array.from(container.querySelectorAll(`[${ITEM_ATTR}]`)).map(getValue);
const nextMatch = getNextMatch(values, searchRef.current, currentMatch);
const newItem = container.querySelector(`[${ITEM_ATTR}="${nextMatch}"]`);

if (newItem) {
/**
* Imperative focus during keydown is risky so we prevent React's batching updates
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
*/
setTimeout(() => (newItem as HTMLElement).focus());
}
}
},
};
}

/**
* This is the "meat" of the matching logic. It takes in all the values,
* the search and the current match, and returns the next match (or `undefined`).
*
* We normalize the search because if a user has repeatedly pressed a character,
* we want the exact same behavior as if we only had that one character
* (ie. cycle through options starting with that character)
*
* We also reorder the values by wrapping the array around the current match.
* This is so we always look forward from the current match, and picking the first
* match will always be the correct one.
*
* Finally, if the normalized search is exactly one character, we exclude the
* current match from the values because otherwise it would be the first to match always
* and focus would never move. This is as opposed to the regular case, where we
* don't want focus to move if the current match still matches.
*/
function getNextMatch(values: string[], search: string, currentMatch?: string) {
const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
const normalizedSearch = isRepeated ? search[0] : search;
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
const excludeCurrentMatch = normalizedSearch.length === 1;
if (excludeCurrentMatch) wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
const nextMatch = wrappedValues.find((value) =>
value.toLowerCase().startsWith(normalizedSearch.toLowerCase())
);
return nextMatch !== currentMatch ? nextMatch : undefined;
}

const getValue = (element: Element) => element.getAttribute(ITEM_ATTR) ?? '';

const ITEM_NAME = 'MenuTypeaheadItem';
const ITEM_ATTR = getPartDataAttr(ITEM_NAME);

type UseMenuTypeaheadItemOptions = { textValue: string; disabled?: boolean };

function useMenuTypeaheadItem({ textValue, disabled }: UseMenuTypeaheadItemOptions) {
return { [ITEM_ATTR]: disabled ? undefined : textValue };
}

export { useMenuTypeahead, useMenuTypeaheadItem };
25 changes: 15 additions & 10 deletions packages/react/menu/src/useRovingFocus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ type RovingFocusGroupOptions = {

type RovingContextValue = {
groupId: string;
reachable: boolean;
setReachable: React.Dispatch<React.SetStateAction<boolean | undefined>>;
tabStopId: string | null;
setTabStopId: React.Dispatch<React.SetStateAction<string | null>>;
reachable: boolean;
setReachable: React.Dispatch<React.SetStateAction<boolean | undefined>>;
} & RovingFocusGroupOptions;

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

function RovingFocusGroup(props: RovingFocusGroupProps) {
const { children, orientation, loop, dir } = props;
const { reachable: reachableProp, defaultReachable, onReachableChange } = props;
const [reachable = true, setReachable] = useControlledState({
prop: reachableProp,
defaultProp: defaultReachable,
onChange: onReachableChange,
prop: props.reachable,
defaultProp: props.defaultReachable,
onChange: props.onReachableChange,
});
const [tabStopId, setTabStopId] = React.useState<string | null>(null);
const groupId = String(useId());

// prettier-ignore
const context = React.useMemo(() => ({
groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop, }),
[ groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop, ]
groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop }),
[groupId, tabStopId, setTabStopId, reachable, setReachable, orientation, dir, loop ]
);

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

0 comments on commit dc5c84f

Please sign in to comment.