Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/menu-role-in-popover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
17 changes: 15 additions & 2 deletions packages/headless/src/primitives/menu/menu-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ function MenuInner(props: MenuProps) {
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const parentId = useFloatingParentNodeId();
const isNested = parentId != null;
// A submenu, not merely a menu inside some other floating element. A menu rendered in a popover
// has a parent node id too, and treating that as nesting makes it hover-open, side-placed, and
// unclickable by mouse.
const isNested = parentId != null && parentContext != null;

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

Expand Down Expand Up @@ -97,7 +100,17 @@ function MenuInner(props: MenuProps) {
toggle: !isNested,
ignoreMouse: isNested,
});
const role = useRole(floatingContext, { role: 'menu' });
const baseRole = useRole(floatingContext, { role: 'menu' });
// `useRole` decides submenu-ness from the floating tree alone, so a menu inside a popover gets
// `role="menuitem"` on its trigger with no parent menu to be an item of. `isNested` is the real answer.
const role = useMemo(() => {
if (isNested) {
return baseRole;
}
const reference = { ...baseRole.reference };
delete reference.role;
return { ...baseRole, reference };
}, [baseRole, isNested]);
const dismiss = useDismiss(floatingContext, { bubbles: true });
const listNavigation = useListNavigation(floatingContext, {
listRef: elementsRef,
Expand Down
29 changes: 29 additions & 0 deletions packages/headless/src/primitives/menu/menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { axe } from '../../test-utils/axe';
import { Popover } from '../popover';
import { Menu } from './index';

afterEach(() => cleanup());
Expand Down Expand Up @@ -559,6 +560,34 @@ describe('Menu', () => {
expect(shareTrigger).toHaveAttribute('role', 'menuitem');
});

it('leaves a trigger inside a popover as a plain button', async () => {
const user = userEvent.setup();
render(
<Popover.Root>
<Popover.Trigger>Account</Popover.Trigger>
<Popover.Positioner>
<Popover.Popup>
<Menu.Root>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item label='Sign out'>Sign out</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Root>
</Popover.Popup>
</Popover.Positioner>
</Popover.Root>,
);

await user.click(screen.getByText('Account'));

// A popover is not a menu, so its children are not menu items. Only the floating tree is
// shared, and that is dismissal plumbing rather than menu hierarchy.
expect(screen.getByRole('button', { name: 'Actions' })).toBeInTheDocument();
expect(screen.getByText('Actions')).not.toHaveAttribute('role');
});

it('opens submenu via controlled open prop', () => {
render(
<Menu.Root defaultOpen>
Expand Down
Loading