diff --git a/packages/headless/package.json b/packages/headless/package.json
index 97d663f1487..a11fefd3a35 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -24,6 +24,10 @@
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
+ "./menu": {
+ "import": "./dist/primitives/menu/index.js",
+ "types": "./dist/primitives/menu/index.d.ts"
+ },
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
diff --git a/packages/headless/src/primitives/menu/README.md b/packages/headless/src/primitives/menu/README.md
new file mode 100644
index 00000000000..c15ce942ab6
--- /dev/null
+++ b/packages/headless/src/primitives/menu/README.md
@@ -0,0 +1,157 @@
+# Menu
+
+A dropdown menu with keyboard navigation, typeahead, and nested submenu support. Handles ARIA roles, safe hover zones for submenus, and tree-level close-on-click.
+
+## When to Use
+
+- Action menus, context menus, dropdown menus attached to a button trigger.
+- When you need nested submenus with safe pointer zones between trigger and submenu.
+- Prefer Menu over Popover when the content is a list of actions/commands rather than arbitrary content.
+
+## Usage
+
+```tsx
+import { Menu } from '@/primitives/menu';
+
+
+ Actions
+
+
+ handleEdit()}
+ />
+ handleDuplicate()}
+ />
+
+ handleDelete()}
+ />
+
+
+ ;
+```
+
+### Nested Submenus
+
+Nest a `` inside a parent `` — the nested trigger automatically renders as a `menuitem` and opens on hover with a safe polygon zone.
+
+```tsx
+
+ Actions
+
+
+
+
+ Share
+
+
+
+
+
+
+
+
+
+
+```
+
+### Controlled
+
+```tsx
+const [open, setOpen] = useState(false);
+
+
+ {/* ... */}
+ ;
+```
+
+## Parts
+
+| Part | Default Element | Description |
+| ----------------- | --------------- | -------------------------------------- |
+| `Menu` | — | Root context provider |
+| `Menu.Trigger` | `` | Opens/closes the menu |
+| `Menu.Portal` | — | Portals children (accepts `root` prop) |
+| `Menu.Positioner` | `` | Floating positioned container |
+| `Menu.Popup` | `
` | Visual wrapper for menu items |
+| `Menu.Item` | `
` | A menu action item |
+| `Menu.Separator` | `` | Visual divider between items |
+| `Menu.Arrow` | `
` | Optional floating arrow |
+
+## Props
+
+### `Menu` (root)
+
+| Prop | Type | Default | Description |
+| -------------- | ------------------------- | ------------------------------------------------- | ---------------------------------- |
+| `open` | `boolean` | — | Controlled open state |
+| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
+| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
+| `placement` | `Placement` | `"bottom-start"` (root), `"right-start"` (nested) | Floating UI placement |
+| `sideOffset` | `number` | `4` (root), `0` (nested) | Gap between trigger and popup (px) |
+
+### `Menu.Item`
+
+| Prop | Type | Default | Description |
+| -------------- | --------- | ------------ | ------------------------------------------------------ |
+| `label` | `string` | **required** | Item text, also used for typeahead matching |
+| `disabled` | `boolean` | — | Prevents click handler, keeps item focusable |
+| `closeOnClick` | `boolean` | `true` | Whether clicking this item closes the entire menu tree |
+
+### `Menu.Trigger`, `Menu.Positioner`, `Menu.Popup`, `Menu.Separator`
+
+No additional props beyond standard HTML attributes and the `render` prop.
+
+### `Menu.Arrow`
+
+Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.
+
+## Keyboard Navigation
+
+| Key | Action |
+| ----------------- | -------------------------------------- |
+| `ArrowDown` | Move to next item |
+| `ArrowUp` | Move to previous item |
+| `ArrowRight` | Open nested submenu |
+| `ArrowLeft` | Close nested submenu, return to parent |
+| `Enter` / `Space` | Activate the focused item |
+| `Escape` | Close the current menu level |
+| Type a character | Jump to matching item (typeahead) |
+
+## Data Attributes
+
+| Attribute | Applies To | Description |
+| --------------------------------- | ----------------- | ------------------------------------ |
+| `data-cl-slot` | All parts | Part identifier (e.g. `"menu-item"`) |
+| `data-cl-open` / `data-cl-closed` | Trigger | Menu open state |
+| `data-cl-active` | Item | Keyboard-focused item |
+| `data-cl-disabled` | Item | Disabled item |
+| `data-cl-side` | Positioner, Arrow | Resolved placement side |
+
+## Nested Menu Behavior
+
+- Nested menus open on hover (75ms delay) with a `safePolygon` safe zone.
+- Only one sibling submenu can be open at a time.
+- Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event.
+- `Escape` closes the innermost menu first, bubbling up through the tree.
+
+## Important Notes
+
+- **No built-in animations.** The positioner simply mounts/unmounts. Use `data-cl-open`/`data-cl-closed` for CSS-driven transitions.
+- **Disabled items use `aria-disabled`, not `disabled`.** They remain focusable for keyboard users.
+- **`label` is required on `Menu.Item`** — it drives typeahead matching. Disabled items are excluded from typeahead.
+
+## ARIA
+
+- Popup: `role="menu"`
+- Item: `role="menuitem"`, `aria-disabled`
+- Separator: `role="separator"`
+- Trigger: `aria-expanded`, `aria-haspopup="menu"`, `aria-controls`
+- Nested trigger: `role="menuitem"` (instead of button)
diff --git a/packages/headless/src/primitives/menu/index.ts b/packages/headless/src/primitives/menu/index.ts
new file mode 100644
index 00000000000..8f7250d8749
--- /dev/null
+++ b/packages/headless/src/primitives/menu/index.ts
@@ -0,0 +1,10 @@
+export type {
+ MenuArrowProps,
+ MenuItemProps,
+ MenuPopupProps,
+ MenuPositionerProps,
+ MenuProps,
+ MenuSeparatorProps,
+ MenuTriggerProps,
+} from './menu';
+export { Menu } from './menu';
diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx
new file mode 100644
index 00000000000..a70f1a536c1
--- /dev/null
+++ b/packages/headless/src/primitives/menu/menu.test.tsx
@@ -0,0 +1,673 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { axe } from '../../test-utils/axe';
+import { Menu } from './menu';
+
+afterEach(() => cleanup());
+
+describe('Menu', () => {
+ describe('slot attributes', () => {
+ it('renders trigger with data-cl-slot', () => {
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-slot', 'menu-trigger');
+ });
+
+ it('renders all parts with correct slot attributes when open', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+ Paste
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(document.querySelector('[data-cl-slot="menu-positioner"]')).toBeInTheDocument();
+ expect(document.querySelector('[data-cl-slot="menu-popup"]')).toBeInTheDocument();
+ expect(document.querySelectorAll('[data-cl-slot="menu-item"]')).toHaveLength(2);
+ expect(document.querySelector('[data-cl-slot="menu-separator"]')).toBeInTheDocument();
+ });
+ });
+
+ describe('open/close', () => {
+ it('opens on trigger click', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(screen.getByText('Cut')).toBeInTheDocument();
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', '');
+ });
+
+ it('closes on trigger click when open', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.click(screen.getByText('Actions'));
+
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('closes on Escape', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.keyboard('{Escape}');
+
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('closes when item is clicked', async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Cut
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.click(screen.getByText('Cut'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('does not close on item click when closeOnClick=false', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Toggle
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.click(screen.getByText('Toggle'));
+
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', '');
+ });
+
+ it('calls onOpenChange', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+ });
+ });
+
+ describe('item interaction', () => {
+ it('fires onClick on item click', async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Cut
+
+ Copy
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.click(screen.getByText('Cut'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('items are buttons with role=menuitem', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ const item = screen.getByText('Cut');
+ expect(item.tagName).toBe('BUTTON');
+ expect(item).toHaveAttribute('role', 'menuitem');
+ });
+ });
+
+ describe('disabled items', () => {
+ it('marks disabled item with data-cl-disabled', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Cut
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(document.querySelector('[data-cl-slot="menu-item"]')).toHaveAttribute('data-cl-disabled', '');
+ });
+
+ it('disabled item has aria-disabled', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Cut
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(document.querySelector('[data-cl-slot="menu-item"]')).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ it('does not close menu when clicking disabled item', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Cut
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.click(screen.getByText('Cut'));
+
+ expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', '');
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it('navigates items with ArrowDown', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+ Copy
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await new Promise(r => requestAnimationFrame(r));
+ await user.keyboard('{ArrowDown}');
+
+ expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', '');
+ });
+
+ it('navigates items with ArrowUp from last to first', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+ Copy
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await new Promise(r => requestAnimationFrame(r));
+ await user.keyboard('{ArrowDown}{ArrowUp}');
+
+ expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', '');
+ });
+
+ it('Home moves to first item', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+ Copy
+ Paste
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await new Promise(r => requestAnimationFrame(r));
+ await user.keyboard('{ArrowDown}{ArrowDown}{Home}');
+
+ expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', '');
+ });
+
+ it('End moves to last item', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+ Copy
+ Paste
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await new Promise(r => requestAnimationFrame(r));
+ await user.keyboard('{End}');
+
+ expect(screen.getByText('Paste')).toHaveAttribute('data-cl-active', '');
+ });
+ });
+
+ describe('focus management', () => {
+ it('returns focus to trigger on close via Escape', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.keyboard('{Escape}');
+
+ expect(document.activeElement).toBe(screen.getByText('Actions'));
+ });
+
+ it('returns focus to trigger on item click', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+ await user.click(screen.getByText('Cut'));
+
+ expect(document.activeElement).toBe(screen.getByText('Actions'));
+ });
+ });
+
+ describe('ARIA attributes', () => {
+ it('trigger has aria-expanded', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ expect(screen.getByText('Actions')).toHaveAttribute('aria-expanded', 'false');
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(screen.getByText('Actions')).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('popup has role=menu', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(screen.getByRole('menu')).toBeInTheDocument();
+ });
+
+ it('trigger has aria-haspopup', () => {
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+ expect(screen.getByText('Actions')).toHaveAttribute('aria-haspopup');
+ });
+
+ it('items have role=menuitem', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+ Copy
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(screen.getAllByRole('menuitem')).toHaveLength(2);
+ });
+
+ it('separator has role=separator', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+ Paste
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(screen.getByRole('separator')).toBeInTheDocument();
+ });
+ });
+
+ describe('nested menus', () => {
+ it('renders submenu trigger as menuitem', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+ Share
+
+
+ Email
+
+
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ const shareTrigger = screen.getByText('Share');
+ expect(shareTrigger).toHaveAttribute('role', 'menuitem');
+ expect(shareTrigger).toHaveAttribute('data-cl-slot', 'menu-trigger');
+ });
+
+ it('opens submenu via controlled open prop', () => {
+ render(
+
+ Actions
+
+
+ Cut
+
+ Share
+
+
+ Email
+ Slack
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText('Email')).toBeInTheDocument();
+ expect(screen.getByText('Slack')).toBeInTheDocument();
+ });
+
+ it('submenu items close all menus on click', async () => {
+ const onClick = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+
+ Share
+
+
+
+ Email
+
+
+
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Email'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('positioner', () => {
+ it('not rendered when closed', () => {
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ expect(document.querySelector('[data-cl-slot="menu-positioner"]')).not.toBeInTheDocument();
+ });
+
+ it('has data-cl-side when open', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(document.querySelector('[data-cl-slot="menu-positioner"]')).toHaveAttribute('data-cl-side');
+ });
+ });
+
+ describe('accessibility (axe)', () => {
+ it('has no violations when closed', async () => {
+ const { container } = render(
+
+ Actions
+
+
+ Cut
+ Copy
+
+
+ ,
+ );
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('has no violations when open', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Actions
+
+
+ Cut
+ Copy
+
+
+ ,
+ );
+
+ await user.click(screen.getByText('Actions'));
+
+ expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations();
+ });
+ });
+});
diff --git a/packages/headless/src/primitives/menu/menu.tsx b/packages/headless/src/primitives/menu/menu.tsx
new file mode 100644
index 00000000000..9aebefe2e06
--- /dev/null
+++ b/packages/headless/src/primitives/menu/menu.tsx
@@ -0,0 +1,530 @@
+'use client';
+
+import {
+ arrow,
+ autoUpdate,
+ type ExtendedRefs,
+ FloatingArrow,
+ type FloatingContext,
+ FloatingFocusManager,
+ FloatingList,
+ FloatingNode,
+ FloatingPortal,
+ FloatingTree,
+ flip,
+ offset,
+ type Placement,
+ type ReferenceType,
+ safePolygon,
+ shift,
+ type UseInteractionsReturn,
+ useClick,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useFloatingTree,
+ useHover,
+ useInteractions,
+ useListItem,
+ useListNavigation,
+ useMergeRefs,
+ useRole,
+ useTypeahead,
+} from '@floating-ui/react';
+import {
+ type CSSProperties,
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useControllableState } from '../../hooks/use-controllable-state';
+import { type TransitionProps, useTransition } from '../../hooks/use-transition';
+import { cssVars } from '../../utils/css-vars';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+interface MenuContextValue {
+ open: boolean;
+ floatingContext: FloatingContext;
+ refs: ExtendedRefs;
+ floatingStyles: CSSProperties;
+ placement: Placement;
+ getReferenceProps: UseInteractionsReturn['getReferenceProps'];
+ getFloatingProps: UseInteractionsReturn['getFloatingProps'];
+ getItemProps: UseInteractionsReturn['getItemProps'];
+ activeIndex: number | null;
+ setActiveIndex: React.Dispatch>;
+ elementsRef: React.MutableRefObject>;
+ labelsRef: React.MutableRefObject>;
+ arrowRef: React.MutableRefObject;
+ popupRef: React.RefObject;
+ isNested: boolean;
+ mounted: boolean;
+ transitionProps: TransitionProps;
+ parentContext: MenuContextValue | null;
+}
+
+const MenuContext = createContext(null);
+
+function useMenuContext() {
+ const ctx = useContext(MenuContext);
+ if (!ctx) {
+ throw new Error('Menu compound components must be used within ');
+ }
+ return ctx;
+}
+
+// ---------------------------------------------------------------------------
+// Menu (root)
+// ---------------------------------------------------------------------------
+
+export interface MenuProps {
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ placement?: Placement;
+ sideOffset?: number;
+ children: ReactNode;
+}
+
+function MenuInner(props: MenuProps) {
+ const { placement: placementProp, sideOffset, children } = props;
+
+ const parentContext = useContext(MenuContext);
+ const tree = useFloatingTree();
+ const nodeId = useFloatingNodeId();
+ const parentId = useFloatingParentNodeId();
+ const isNested = parentId != null;
+
+ const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);
+
+ const [activeIndex, setActiveIndex] = useState(null);
+
+ const elementsRef = useRef>([]);
+ const labelsRef = useRef>([]);
+ const arrowRef = useRef(null);
+ const popupRef = useRef(null);
+
+ const resolvedPlacement = placementProp ?? (isNested ? 'right-start' : 'bottom-start');
+ const resolvedOffset = sideOffset ?? (isNested ? 0 : 4);
+
+ const {
+ refs,
+ floatingStyles,
+ context: floatingContext,
+ placement,
+ } = useFloating({
+ nodeId,
+ open,
+ onOpenChange: setOpen,
+ placement: resolvedPlacement,
+ middleware: [
+ offset({
+ mainAxis: resolvedOffset,
+ alignmentAxis: isNested ? -4 : 0,
+ }),
+ flip(),
+ shift({ padding: 5 }),
+ arrow({ element: arrowRef }),
+ cssVars({ sideOffset: resolvedOffset }),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
+
+ const { mounted, transitionProps } = useTransition({
+ open,
+ ref: popupRef,
+ });
+
+ const hover = useHover(floatingContext, {
+ enabled: isNested,
+ delay: { open: 75 },
+ handleClose: safePolygon({ blockPointerEvents: true }),
+ });
+ const click = useClick(floatingContext, {
+ event: 'mousedown',
+ toggle: !isNested,
+ ignoreMouse: isNested,
+ });
+ const role = useRole(floatingContext, { role: 'menu' });
+ const dismiss = useDismiss(floatingContext, { bubbles: true });
+ const listNavigation = useListNavigation(floatingContext, {
+ listRef: elementsRef,
+ activeIndex,
+ nested: isNested,
+ onNavigate: setActiveIndex,
+ });
+ const typeahead = useTypeahead(floatingContext, {
+ listRef: labelsRef,
+ onMatch: open ? setActiveIndex : undefined,
+ activeIndex,
+ });
+
+ const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([
+ hover,
+ click,
+ role,
+ dismiss,
+ listNavigation,
+ typeahead,
+ ]);
+
+ // Close all menus when an item is clicked anywhere in the tree
+ useEffect(() => {
+ if (!tree) return;
+
+ function handleTreeClick() {
+ setOpen(false);
+ }
+
+ function onSubMenuOpen(event: { nodeId: string; parentId: string }) {
+ if (event.nodeId !== nodeId && event.parentId === parentId) {
+ setOpen(false);
+ }
+ }
+
+ tree.events.on('click', handleTreeClick);
+ tree.events.on('menuopen', onSubMenuOpen);
+
+ return () => {
+ tree.events.off('click', handleTreeClick);
+ tree.events.off('menuopen', onSubMenuOpen);
+ };
+ }, [tree, nodeId, parentId, setOpen]);
+
+ useEffect(() => {
+ if (open && tree) {
+ tree.events.emit('menuopen', { parentId, nodeId });
+ }
+ }, [tree, open, nodeId, parentId]);
+
+ const contextValue = useMemo(
+ () => ({
+ open,
+ floatingContext,
+ refs,
+ floatingStyles,
+ placement,
+ getReferenceProps,
+ getFloatingProps,
+ getItemProps,
+ activeIndex,
+ setActiveIndex,
+ elementsRef,
+ labelsRef,
+ arrowRef,
+ popupRef,
+ isNested,
+ mounted,
+ transitionProps,
+ parentContext,
+ }),
+ [
+ open,
+ floatingContext,
+ refs,
+ floatingStyles,
+ placement,
+ getReferenceProps,
+ getFloatingProps,
+ getItemProps,
+ activeIndex,
+ isNested,
+ mounted,
+ transitionProps,
+ parentContext,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+function MenuRoot(props: MenuProps) {
+ const parentId = useFloatingParentNodeId();
+
+ if (parentId === null) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Trigger
+// ---------------------------------------------------------------------------
+
+export interface MenuTriggerProps extends ComponentProps<'button'> {}
+
+function MenuTrigger(props: MenuTriggerProps) {
+ const { render, ref: consumerRef, ...otherProps } = props;
+ const { open, isNested, refs, getReferenceProps, parentContext } = useMenuContext();
+
+ const item = useListItem();
+
+ const mergedRef = useMergeRefs([refs.setReference, isNested ? item.ref : null, consumerRef ?? null]);
+
+ const state = { open };
+
+ let referenceProps: Record;
+
+ if (isNested && parentContext) {
+ referenceProps = getReferenceProps(parentContext.getItemProps() as React.HTMLProps);
+ } else {
+ referenceProps = getReferenceProps();
+ }
+
+ const defaultProps = {
+ type: 'button' as const,
+ 'data-cl-slot': 'menu-trigger',
+ ref: mergedRef,
+ ...(isNested && {
+ role: 'menuitem' as const,
+ tabIndex: parentContext?.activeIndex === item.index ? 0 : -1,
+ }),
+ ...(referenceProps as React.ComponentPropsWithRef<'button'>),
+ };
+
+ return renderElement({
+ defaultTagName: 'button',
+ render,
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
+ },
+ props: mergeProps<'button'>(defaultProps, otherProps),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Portal
+// ---------------------------------------------------------------------------
+
+export interface MenuPortalProps {
+ children: ReactNode;
+ root?: HTMLElement | null | React.RefObject;
+}
+
+function MenuPortal(props: MenuPortalProps) {
+ const { mounted } = useMenuContext();
+ if (!mounted) return null;
+ return {props.children} ;
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Positioner
+// ---------------------------------------------------------------------------
+
+export interface MenuPositionerProps extends ComponentProps<'div'> {}
+
+function MenuPositioner(props: MenuPositionerProps) {
+ const { render, ...otherProps } = props;
+ const {
+ mounted,
+ floatingContext,
+ refs,
+ floatingStyles,
+ placement,
+ getFloatingProps,
+ elementsRef,
+ labelsRef,
+ isNested,
+ setActiveIndex,
+ } = useMenuContext();
+
+ const side = placement.split('-')[0];
+
+ const defaultProps = {
+ 'data-cl-slot': 'menu-positioner',
+ 'data-cl-side': side,
+ ref: refs.setFloating,
+ style: floatingStyles,
+ ...(getFloatingProps({
+ onKeyDown(event: React.KeyboardEvent) {
+ if (event.key === 'Home' || event.key === 'End') {
+ event.preventDefault();
+ const items = elementsRef.current;
+ if (event.key === 'Home') {
+ const firstEnabled = items.findIndex(el => el != null && !el.hasAttribute('aria-disabled'));
+ if (firstEnabled !== -1) setActiveIndex(firstEnabled);
+ } else {
+ for (let i = items.length - 1; i >= 0; i--) {
+ if (items[i] != null && !items[i]!.hasAttribute('aria-disabled')) {
+ setActiveIndex(i);
+ break;
+ }
+ }
+ }
+ }
+ },
+ }) as React.ComponentPropsWithRef<'div'>),
+ };
+
+ const element = renderElement({
+ defaultTagName: 'div',
+ render,
+ enabled: mounted,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+
+ return (
+
+
+ {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- element is guaranteed non-null when mounted */}
+ {element!}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Popup
+// ---------------------------------------------------------------------------
+
+export interface MenuPopupProps extends ComponentProps<'div'> {}
+
+function MenuPopup(props: MenuPopupProps) {
+ const { render, ...otherProps } = props;
+ const { popupRef, transitionProps } = useMenuContext();
+
+ const defaultProps = {
+ 'data-cl-slot': 'menu-popup',
+ ref: popupRef,
+ ...transitionProps,
+ };
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Item
+// ---------------------------------------------------------------------------
+
+export interface MenuItemProps extends ComponentProps<'button'> {
+ label: string;
+ disabled?: boolean;
+ closeOnClick?: boolean;
+}
+
+function MenuItem(props: MenuItemProps) {
+ const { render, label, disabled, closeOnClick = true, ...otherProps } = props;
+ const { activeIndex, getItemProps } = useMenuContext();
+ const tree = useFloatingTree();
+ const item = useListItem({ label: disabled ? null : label });
+ const isActive = item.index === activeIndex;
+
+ const state = {
+ active: isActive,
+ disabled: !!disabled,
+ };
+
+ const defaultProps = {
+ 'data-cl-slot': 'menu-item',
+ type: 'button' as const,
+ ref: item.ref,
+ role: 'menuitem' as const,
+ tabIndex: isActive ? 0 : -1,
+ ...(disabled && { 'aria-disabled': true as const }),
+ ...(getItemProps({
+ onClick() {
+ if (!disabled && closeOnClick) {
+ tree?.events.emit('click');
+ }
+ },
+ }) as React.ComponentPropsWithRef<'button'>),
+ };
+
+ return renderElement({
+ defaultTagName: 'button',
+ render,
+ state,
+ stateAttributesMapping: {
+ active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
+ disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
+ },
+ props: mergeProps<'button'>(defaultProps, otherProps),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Separator
+// ---------------------------------------------------------------------------
+
+export interface MenuSeparatorProps extends ComponentProps<'div'> {}
+
+function MenuSeparator(props: MenuSeparatorProps) {
+ const { render, ...otherProps } = props;
+
+ const defaultProps = {
+ 'data-cl-slot': 'menu-separator',
+ role: 'separator' as const,
+ };
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Menu.Arrow
+// ---------------------------------------------------------------------------
+
+export interface MenuArrowProps extends React.ComponentPropsWithRef {}
+
+function MenuArrowComponent(props: MenuArrowProps) {
+ const { floatingContext, arrowRef, placement } = useMenuContext();
+ const side = placement.split('-')[0];
+
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Compound export
+// ---------------------------------------------------------------------------
+
+export const Menu = Object.assign(MenuRoot, {
+ Trigger: MenuTrigger,
+ Portal: MenuPortal,
+ Positioner: MenuPositioner,
+ Popup: MenuPopup,
+ Item: MenuItem,
+ Separator: MenuSeparator,
+ Arrow: MenuArrowComponent,
+});
diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts
index 613ef999005..c76bf1067c3 100644
--- a/packages/headless/vite.config.ts
+++ b/packages/headless/vite.config.ts
@@ -16,6 +16,7 @@ export default defineConfig({
'primitives/tooltip/index': 'src/primitives/tooltip/index.ts',
'primitives/popover/index': 'src/primitives/popover/index.ts',
'primitives/select/index': 'src/primitives/select/index.ts',
+ 'primitives/menu/index': 'src/primitives/menu/index.ts',
'primitives/dialog/index': 'src/primitives/dialog/index.ts',
'utils/index': 'src/utils/index.ts',
'hooks/use-controllable-state': 'src/hooks/use-controllable-state.ts',