Skip to content

Commit 8b420c2

Browse files
fix(headless): stop a menu inside a popover acting as a submenu (#9315)
1 parent 67c81e2 commit 8b420c2

3 files changed

Lines changed: 46 additions & 2 deletions

File tree

‎.changeset/menu-role-in-popover.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/headless/src/primitives/menu/menu-root.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ function MenuInner(props: MenuProps) {
4545
const tree = useFloatingTree();
4646
const nodeId = useFloatingNodeId();
4747
const parentId = useFloatingParentNodeId();
48-
const isNested = parentId != null;
48+
// A submenu, not merely a menu inside some other floating element. A menu rendered in a popover
49+
// has a parent node id too, and treating that as nesting makes it hover-open, side-placed, and
50+
// unclickable by mouse.
51+
const isNested = parentId != null && parentContext != null;
4952

5053
const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
5154

@@ -97,7 +100,17 @@ function MenuInner(props: MenuProps) {
97100
toggle: !isNested,
98101
ignoreMouse: isNested,
99102
});
100-
const role = useRole(floatingContext, { role: 'menu' });
103+
const baseRole = useRole(floatingContext, { role: 'menu' });
104+
// `useRole` decides submenu-ness from the floating tree alone, so a menu inside a popover gets
105+
// `role="menuitem"` on its trigger with no parent menu to be an item of. `isNested` is the real answer.
106+
const role = useMemo(() => {
107+
if (isNested) {
108+
return baseRole;
109+
}
110+
const reference = { ...baseRole.reference };
111+
delete reference.role;
112+
return { ...baseRole, reference };
113+
}, [baseRole, isNested]);
101114
const dismiss = useDismiss(floatingContext, { bubbles: true });
102115
const listNavigation = useListNavigation(floatingContext, {
103116
listRef: elementsRef,

‎packages/headless/src/primitives/menu/menu.test.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
33
import { afterEach, describe, expect, it, vi } from 'vitest';
44

55
import { axe } from '../../test-utils/axe';
6+
import { Popover } from '../popover';
67
import { Menu } from './index';
78

89
afterEach(() => cleanup());
@@ -559,6 +560,34 @@ describe('Menu', () => {
559560
expect(shareTrigger).toHaveAttribute('role', 'menuitem');
560561
});
561562

563+
it('leaves a trigger inside a popover as a plain button', async () => {
564+
const user = userEvent.setup();
565+
render(
566+
<Popover.Root>
567+
<Popover.Trigger>Account</Popover.Trigger>
568+
<Popover.Positioner>
569+
<Popover.Popup>
570+
<Menu.Root>
571+
<Menu.Trigger>Actions</Menu.Trigger>
572+
<Menu.Positioner>
573+
<Menu.Popup>
574+
<Menu.Item label='Sign out'>Sign out</Menu.Item>
575+
</Menu.Popup>
576+
</Menu.Positioner>
577+
</Menu.Root>
578+
</Popover.Popup>
579+
</Popover.Positioner>
580+
</Popover.Root>,
581+
);
582+
583+
await user.click(screen.getByText('Account'));
584+
585+
// A popover is not a menu, so its children are not menu items. Only the floating tree is
586+
// shared, and that is dismissal plumbing rather than menu hierarchy.
587+
expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
588+
expect(screen.getByText('Actions')).not.toHaveAttribute('role');
589+
});
590+
562591
it('opens submenu via controlled open prop', () => {
563592
render(
564593
<Menu.Root defaultOpen>

0 commit comments

Comments
 (0)