From 10a20fc022bc066dc7d8f7442cc775bc329d05f4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 31 Jul 2026 10:20:36 -0400 Subject: [PATCH 1/2] fix(headless): only treat a menu as nested when it has a parent menu A menu rendered inside a popover has a floating parent node id, so keying nesting off that alone made it hover-open, place itself side-start, and swallow mouse clicks. Require a parent menu context too. --- packages/headless/src/primitives/menu/menu-root.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/headless/src/primitives/menu/menu-root.tsx b/packages/headless/src/primitives/menu/menu-root.tsx index f8ac956d40c..0b69b002504 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); From 9beaf0263a324b966f8d11f100072a3ba6c2263e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 3 Aug 2026 12:55:11 -0400 Subject: [PATCH 2/2] fix(headless): stop a menu inside a popover claiming role=menuitem A popover is not a menu, so a menu trigger rendered inside one is a plain button. floating-ui derives submenu-ness from the shared floating tree, which every floating element joins for dismissal, and labels the trigger a menu item of a menu that does not exist. Take the role from whether there is a parent menu. --- .changeset/menu-role-in-popover.md | 2 ++ .../src/primitives/menu/menu-root.tsx | 12 +++++++- .../src/primitives/menu/menu.test.tsx | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .changeset/menu-role-in-popover.md 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 0b69b002504..36daf0cf5d8 100644 --- a/packages/headless/src/primitives/menu/menu-root.tsx +++ b/packages/headless/src/primitives/menu/menu-root.tsx @@ -100,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(