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(
+