diff --git a/packages/react/src/hooks/useInteractions.ts b/packages/react/src/hooks/useInteractions.ts index dafc64417d..6d3c2e5732 100644 --- a/packages/react/src/hooks/useInteractions.ts +++ b/packages/react/src/hooks/useInteractions.ts @@ -36,7 +36,7 @@ function mergeProps( .map((value) => { const propsOrGetProps = value ? value[elementKey] : null; if (typeof propsOrGetProps === 'function') { - return userProps ? propsOrGetProps(userProps) : null; + return propsOrGetProps(userProps || {}); } return propsOrGetProps; }) diff --git a/packages/react/src/hooks/useListNavigation.ts b/packages/react/src/hooks/useListNavigation.ts index e006515b68..ccdc00a97a 100644 --- a/packages/react/src/hooks/useListNavigation.ts +++ b/packages/react/src/hooks/useListNavigation.ts @@ -37,6 +37,7 @@ import { ARROW_RIGHT, ARROW_LEFT, } from '../utils/constants'; +import type {ExtendedUserProps} from './useInteractions'; export const ESCAPE = 'Escape'; @@ -338,9 +339,6 @@ export function useListNavigation( const focusItem = useEffectEvent(() => { function runFocus(item: HTMLElement) { if (virtual) { - if (item.id?.endsWith('-fui-option')) { - item.id = `${floatingId}-${Math.random().toString(16).slice(2, 10)}`; - } setActiveId(item.id); tree?.events.emit('virtualfocus', item); if (virtualItemRef) { @@ -377,7 +375,6 @@ export function useListNavigation( const scrollIntoViewOptions = scrollItemIntoViewRef.current; const shouldScrollIntoView = scrollIntoViewOptions && - item && (forceScrollIntoView || !isPointerModalityRef.current); if (shouldScrollIntoView) { @@ -548,53 +545,65 @@ export function useListNavigation( const hasActiveIndex = activeIndex != null; - const item = React.useMemo(() => { - function syncCurrentTarget(currentTarget: HTMLElement | null) { - if (!latestOpenRef.current) return; - const index = listRef.current.indexOf(currentTarget); - if (index !== -1 && indexRef.current !== index) { - indexRef.current = index; - onNavigate(); + const item = React.useCallback( + ({active}: ExtendedUserProps) => { + function syncCurrentTarget(currentTarget: HTMLElement | null) { + if (!latestOpenRef.current) return; + const index = listRef.current.indexOf(currentTarget); + if (index !== -1 && indexRef.current !== index) { + indexRef.current = index; + onNavigate(); + } } - } - const props: ElementProps['item'] = { - onFocus({currentTarget}) { - forceSyncFocusRef.current = true; - syncCurrentTarget(currentTarget); - }, - onClick: ({currentTarget}) => currentTarget.focus({preventScroll: true}), // Safari - ...(focusItemOnHover && { - onMouseMove({currentTarget}) { + const obj: ElementProps['item'] = { + onFocus({currentTarget}) { forceSyncFocusRef.current = true; - forceScrollIntoViewRef.current = false; syncCurrentTarget(currentTarget); }, - onPointerLeave({pointerType}) { - if (!isPointerModalityRef.current || pointerType === 'touch') { - return; - } + // Safari + onClick({currentTarget}) { + currentTarget.focus({preventScroll: true}); + }, + ...(focusItemOnHover && { + onMouseMove({currentTarget}) { + forceSyncFocusRef.current = true; + forceScrollIntoViewRef.current = false; + syncCurrentTarget(currentTarget); + }, + onPointerLeave({pointerType}) { + if (!isPointerModalityRef.current || pointerType === 'touch') { + return; + } - forceSyncFocusRef.current = true; - indexRef.current = -1; - onNavigate(); + forceSyncFocusRef.current = true; + indexRef.current = -1; + onNavigate(); - if (!virtual) { - floatingFocusElementRef.current?.focus({preventScroll: true}); - } - }, - }), - }; + if (!virtual) { + floatingFocusElementRef.current?.focus({preventScroll: true}); + } + }, + }), + }; - return props; - }, [ - latestOpenRef, - floatingFocusElementRef, - focusItemOnHover, - listRef, - onNavigate, - virtual, - ]); + if (active && obj.id?.endsWith('-fui-option')) { + obj.id = `${floatingId}-option-${activeIndex}`; + } + + return obj; + }, + [ + floatingId, + activeIndex, + focusItemOnHover, + latestOpenRef, + listRef, + onNavigate, + virtual, + floatingFocusElementRef, + ], + ); const getParentOrientation = React.useCallback(() => { return ( diff --git a/packages/react/src/hooks/useRole.ts b/packages/react/src/hooks/useRole.ts index 7b9d3a1c85..fb99a86128 100644 --- a/packages/react/src/hooks/useRole.ts +++ b/packages/react/src/hooks/useRole.ts @@ -105,29 +105,26 @@ export function useRole( const item: ElementProps['item'] = React.useCallback( ({active, selected}: ExtendedUserProps) => { - const commonProps = { + const commonProps: React.HTMLProps = { role: 'option', - ...(active && {id: `${floatingId}-fui-option`}), }; + if (active) { + commonProps.id = `${floatingId}-fui-option`; + } + // For `menu`, we are unable to tell if the item is a `menuitemradio` // or `menuitemcheckbox`. For backwards-compatibility reasons, also // avoid defaulting to `menuitem` as it may overwrite custom role props. - switch (role) { - case 'select': - return { - ...commonProps, - 'aria-selected': active && selected, - }; - case 'combobox': { - return { - ...commonProps, - 'aria-selected': selected, - }; - } + if (role === 'select') { + commonProps['aria-selected'] = active && selected; + } + + if (role === 'combobox') { + commonProps['aria-selected'] = selected; } - return {}; + return commonProps; }, [floatingId, role], ); diff --git a/packages/react/test/unit/useListNavigation.test.tsx b/packages/react/test/unit/useListNavigation.test.tsx index 94be0a026c..e5abbb3d6a 100644 --- a/packages/react/test/unit/useListNavigation.test.tsx +++ b/packages/react/test/unit/useListNavigation.test.tsx @@ -19,6 +19,7 @@ import { useInteractions, useListItem, useListNavigation, + useRole, } from '../../src'; import type {UseListNavigationProps} from '../../src/hooks/useListNavigation'; import {Main as ComplexGrid} from '../visual/components/ComplexGrid'; @@ -1475,3 +1476,87 @@ test('Home or End key press is ignored for typeable combobox reference', async ( expect(screen.getByTestId('item-1')).toHaveFocus(); }); }); + +test('`aria-activedescendant` refers to the correct item when list changes', async () => { + function VirtualListApp() { + const [open, setOpen] = useState(false); + const [items, setItems] = useState(['apple', 'banana', 'cherry']); + const [activeIndex, setActiveIndex] = useState(null); + const listRef = useRef>([]); + + const {refs, context} = useFloating({ + open, + onOpenChange: setOpen, + }); + + const role = useRole(context, {role: 'combobox'}); + const listNavigation = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + }); + + const {getReferenceProps, getFloatingProps, getItemProps} = useInteractions( + [role, listNavigation], + ); + + return ( + <> + + {open && ( +
+ {items.map((item, index) => { + return ( +
+ {item} +
+ ); + })} +
+ )} + + + ); + } + + render(); + + await userEvent.click(screen.getByTestId('reference')); + await userEvent.keyboard('{ArrowDown}'); + await act(async () => {}); + expect(screen.getByTestId('reference')).toHaveAttribute( + 'aria-activedescendant', + screen.getByTestId('item-0').id, + ); + await userEvent.click(screen.getByTestId('change-items')); + expect(screen.getByTestId('reference')).toHaveAttribute( + 'aria-activedescendant', + screen.getByTestId('item-0').id, + ); +});