diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index ef2300d095d..9df37506d4f 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -304,6 +304,26 @@ describe('Menu', () => { expect(onHoverEnd).not.toHaveBeenCalled(); }); + it('should move focus to the menu when the pointer leaves it entirely', async () => { + let onAction = jest.fn(); + let {getByRole, getAllByRole} = renderMenu({onAction}); + let menu = getByRole('menu'); + let item = getAllByRole('menuitem')[0]; + + await user.hover(item); + expect(item).toHaveAttribute('data-focused', 'true'); + expect(document.activeElement).toBe(item); + + await user.unhover(item); + expect(item).not.toHaveAttribute('data-focused'); + expect(document.activeElement).toBe(menu); + + // Enter shouldn't re-trigger the item that was hovered before the pointer left the menu. + fireEvent.keyDown(menu, {key: 'Enter'}); + fireEvent.keyUp(menu, {key: 'Enter'}); + expect(onAction).not.toHaveBeenCalled(); + }); + it('should support slots', () => { let {getByRole} = render( @@ -986,6 +1006,44 @@ describe('Menu', () => { expect(menu).not.toBeInTheDocument(); expect(submenu).not.toBeInTheDocument(); }); + it('should keep the submenu trigger focused when the pointer leaves the parent menu towards its open submenu', async () => { + let {getByRole, getAllByRole} = render( + + + + + Open + + Share… + + + Email + + + + + + + ); + + await user.click(getByRole('button')); + let menu = getAllByRole('menu')[0]; + let triggerItem = getAllByRole('menuitem')[1]; + + await user.pointer({target: triggerItem}); + act(() => { + jest.runAllTimers(); + }); + expect(triggerItem).toHaveAttribute('aria-expanded', 'true'); + expect(triggerItem).toHaveAttribute('data-focused', 'true'); + + // The submenu popover isn't a DOM descendant of the parent menu, so moving the pointer from + // the trigger item towards it leaves the parent menu's bounding box. The trigger item should + // stay focused while its submenu remains open, matching the existing hover-follows-focus behavior. + fireEvent.pointerLeave(menu, {pointerType: 'mouse', pointerId: 1}); + expect(triggerItem).toHaveAttribute('data-focused', 'true'); + expect(document.activeElement).not.toBe(menu); + }); it('should support nested submenu triggers', async () => { let onAction = jest.fn(); let {getByRole, getAllByRole} = render( diff --git a/packages/react-aria/src/menu/useMenu.ts b/packages/react-aria/src/menu/useMenu.ts index f95b9dd9dac..42d20a659a5 100644 --- a/packages/react-aria/src/menu/useMenu.ts +++ b/packages/react-aria/src/menu/useMenu.ts @@ -26,6 +26,7 @@ import {filterDOMProps} from '../utils/filterDOMProps'; import {menuData} from './utils'; import {mergeProps} from '../utils/mergeProps'; import {TreeState} from 'react-stately/useTreeState'; +import {useHover} from '../interactions/useHover'; import {useSelectableList} from '../selection/useSelectableList'; export interface MenuProps extends CollectionBase, MultipleSelection { @@ -100,6 +101,26 @@ export function useMenu( linkBehavior: 'override' }); + // When the pointer leaves the menu entirely, clear the focused key so the last hovered item + // doesn't keep intercepting Enter/Space. useSelectableCollection already moves DOM focus to the + // menu itself once focusedKey becomes null (the same path used when a focused item is removed), + // so this reuses that existing behavior rather than introducing a new one. + // Skip this while a submenu opened from this menu is still expanded: the pointer moving from the + // trigger item towards its submenu leaves this menu's bounding box (the submenu renders in a + // separate popover), and the trigger item should stay visually focused while its submenu is open. + let {hoverProps} = useHover({ + onHoverEnd() { + let {selectionManager: manager} = state; + if ( + manager.isFocused && + manager.focusedKey != null && + !ref.current?.querySelector('[aria-haspopup][aria-expanded="true"]') + ) { + manager.setFocusedKey(null); + } + } + }); + menuData.set(state, { onClose: props.onClose, onAction: props.onAction, @@ -110,6 +131,7 @@ export function useMenu( menuProps: mergeProps( domProps, {onKeyDown, onKeyUp}, + hoverProps, { role: 'menu', ...listProps,