diff --git a/.changeset/menu-role-in-popover.md b/.changeset/menu-role-in-popover.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/menu-role-in-popover.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx index f8ac956d40c..36daf0cf5d8 100644 --- a/packages/headless/src/primitives/menu/menu-root.tsx +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -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); @@ -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, diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx index dbc19745c69..324d9145d4f 100644 --- a/packages/headless/src/primitives/menu/menu.test.tsx +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -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()); @@ -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( + + Account + + + + Actions + + + Sign out + + + + + + , + ); + + 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(