Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/react-aria-components/test/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Menu aria-label="Actions">
Expand Down Expand Up @@ -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(
<MenuTrigger>
<Button aria-label="Menu">☰</Button>
<Popover>
<Menu>
<MenuItem id="open">Open</MenuItem>
<SubmenuTrigger>
<MenuItem id="share">Share…</MenuItem>
<Popover>
<Menu>
<MenuItem id="email">Email</MenuItem>
</Menu>
</Popover>
</SubmenuTrigger>
</Menu>
</Popover>
</MenuTrigger>
);

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(
Expand Down
22 changes: 22 additions & 0 deletions packages/react-aria/src/menu/useMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> extends CollectionBase<T>, MultipleSelection {
Expand Down Expand Up @@ -100,6 +101,26 @@ export function useMenu<T>(
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,
Expand All @@ -110,6 +131,7 @@ export function useMenu<T>(
menuProps: mergeProps(
domProps,
{onKeyDown, onKeyUp},
hoverProps,
{
role: 'menu',
...listProps,
Expand Down