From 0e09806883be6873ba6dbd9dbc3f4ccd300cedd4 Mon Sep 17 00:00:00 2001 From: Miller Date: Tue, 11 Aug 2026 00:09:29 -0500 Subject: [PATCH] Menu: move focus to the menu when the pointer leaves it entirely useMenuItem's onHoverStart moves the selection manager's focused key to the hovered item, but nothing ever clears it on hover-out. Once you've hovered an item, its key stays the focused key even after the pointer leaves the menu, so Enter can still activate an item you're no longer pointing at. Add an onHoverEnd on the menu container in useMenu that clears focusedKey when the pointer leaves the whole menu. useSelectableCollection already moves DOM focus to the collection container whenever focusedKey becomes null while the collection is still focused (existing path, used today when the focused item is removed from the collection), so this reuses that instead of adding new focus-restoration logic. Guarded against submenus: a submenu's popover isn't a DOM descendant of the parent menu, so moving the pointer from a trigger item toward its open submenu also leaves the parent menu's bounding box. Skip clearing focus while an item within the menu has aria-haspopup + aria-expanded=true, so the trigger stays focused while its submenu is open, matching current behavior. No new public props. Discussed in #10143. --- .../react-aria-components/test/Menu.test.tsx | 58 +++++++++++++++++++ packages/react-aria/src/menu/useMenu.ts | 22 +++++++ 2 files changed, 80 insertions(+) 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,