Skip to content

Commit 1dabedd

Browse files
alexcarpenterclaude
andcommitted
feat(headless): add Menu primitive
Dropdown menu with nested submenus via FloatingTree, safePolygon hover zones, combined hover/click triggers, keyboard navigation, and Separator support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf64381 commit 1dabedd

6 files changed

Lines changed: 1375 additions & 0 deletions

File tree

packages/headless/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"private": true,
55
"type": "module",
66
"exports": {
7+
"./menu": {
8+
"import": "./dist/primitives/menu/index.js",
9+
"types": "./dist/primitives/menu/index.d.ts"
10+
},
711
"./select": {
812
"import": "./dist/primitives/select/index.js",
913
"types": "./dist/primitives/select/index.d.ts"
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Menu
2+
3+
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.
4+
5+
## When to Use
6+
7+
- Action menus, context menus, dropdown menus attached to a button trigger.
8+
- When you need nested submenus with safe pointer zones between trigger and submenu.
9+
- Prefer Menu over Popover when the content is a list of actions/commands rather than arbitrary content.
10+
11+
## Usage
12+
13+
```tsx
14+
import { Menu } from '@/primitives/menu';
15+
16+
<Menu>
17+
<Menu.Trigger>Actions</Menu.Trigger>
18+
<Menu.Positioner>
19+
<Menu.Popup>
20+
<Menu.Item
21+
label='Edit'
22+
onClick={() => handleEdit()}
23+
/>
24+
<Menu.Item
25+
label='Duplicate'
26+
onClick={() => handleDuplicate()}
27+
/>
28+
<Menu.Separator />
29+
<Menu.Item
30+
label='Delete'
31+
onClick={() => handleDelete()}
32+
/>
33+
</Menu.Popup>
34+
</Menu.Positioner>
35+
</Menu>;
36+
```
37+
38+
### Nested Submenus
39+
40+
Nest a `<Menu>` inside a parent `<Menu>` — the nested trigger automatically renders as a `menuitem` and opens on hover with a safe polygon zone.
41+
42+
```tsx
43+
<Menu>
44+
<Menu.Trigger>Actions</Menu.Trigger>
45+
<Menu.Positioner>
46+
<Menu.Popup>
47+
<Menu.Item label='Edit' />
48+
<Menu>
49+
<Menu.Trigger>Share</Menu.Trigger>
50+
<Menu.Positioner>
51+
<Menu.Popup>
52+
<Menu.Item label='Copy Link' />
53+
<Menu.Item label='Email' />
54+
</Menu.Popup>
55+
</Menu.Positioner>
56+
</Menu>
57+
</Menu.Popup>
58+
</Menu.Positioner>
59+
</Menu>
60+
```
61+
62+
### Controlled
63+
64+
```tsx
65+
const [open, setOpen] = useState(false);
66+
67+
<Menu
68+
open={open}
69+
onOpenChange={setOpen}
70+
>
71+
{/* ... */}
72+
</Menu>;
73+
```
74+
75+
## Parts
76+
77+
| Part | Default Element | Description |
78+
| ----------------- | --------------- | -------------------------------------- |
79+
| `Menu` || Root context provider |
80+
| `Menu.Trigger` | `<button>` | Opens/closes the menu |
81+
| `Menu.Portal` || Portals children (accepts `root` prop) |
82+
| `Menu.Positioner` | `<div>` | Floating positioned container |
83+
| `Menu.Popup` | `<div>` | Visual wrapper for menu items |
84+
| `Menu.Item` | `<button>` | A menu action item |
85+
| `Menu.Separator` | `<div>` | Visual divider between items |
86+
| `Menu.Arrow` | `<svg>` | Optional floating arrow |
87+
88+
## Props
89+
90+
### `Menu` (root)
91+
92+
| Prop | Type | Default | Description |
93+
| -------------- | ------------------------- | ------------------------------------------------- | ---------------------------------- |
94+
| `open` | `boolean` || Controlled open state |
95+
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
96+
| `onOpenChange` | `(open: boolean) => void` || Called when open state changes |
97+
| `placement` | `Placement` | `"bottom-start"` (root), `"right-start"` (nested) | Floating UI placement |
98+
| `sideOffset` | `number` | `4` (root), `0` (nested) | Gap between trigger and popup (px) |
99+
100+
### `Menu.Item`
101+
102+
| Prop | Type | Default | Description |
103+
| -------------- | --------- | ------------ | ------------------------------------------------------ |
104+
| `label` | `string` | **required** | Item text, also used for typeahead matching |
105+
| `disabled` | `boolean` || Prevents click handler, keeps item focusable |
106+
| `closeOnClick` | `boolean` | `true` | Whether clicking this item closes the entire menu tree |
107+
108+
### `Menu.Trigger`, `Menu.Positioner`, `Menu.Popup`, `Menu.Separator`
109+
110+
No additional props beyond standard HTML attributes and the `render` prop.
111+
112+
### `Menu.Arrow`
113+
114+
Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.
115+
116+
## Keyboard Navigation
117+
118+
| Key | Action |
119+
| ----------------- | -------------------------------------- |
120+
| `ArrowDown` | Move to next item |
121+
| `ArrowUp` | Move to previous item |
122+
| `ArrowRight` | Open nested submenu |
123+
| `ArrowLeft` | Close nested submenu, return to parent |
124+
| `Enter` / `Space` | Activate the focused item |
125+
| `Escape` | Close the current menu level |
126+
| Type a character | Jump to matching item (typeahead) |
127+
128+
## Data Attributes
129+
130+
| Attribute | Applies To | Description |
131+
| --------------------------------- | ----------------- | ------------------------------------ |
132+
| `data-cl-slot` | All parts | Part identifier (e.g. `"menu-item"`) |
133+
| `data-cl-open` / `data-cl-closed` | Trigger | Menu open state |
134+
| `data-cl-active` | Item | Keyboard-focused item |
135+
| `data-cl-disabled` | Item | Disabled item |
136+
| `data-cl-side` | Positioner, Arrow | Resolved placement side |
137+
138+
## Nested Menu Behavior
139+
140+
- Nested menus open on hover (75ms delay) with a `safePolygon` safe zone.
141+
- Only one sibling submenu can be open at a time.
142+
- Clicking any item with `closeOnClick={true}` (default) closes the entire menu tree via a tree event.
143+
- `Escape` closes the innermost menu first, bubbling up through the tree.
144+
145+
## Important Notes
146+
147+
- **No built-in animations.** The positioner simply mounts/unmounts. Use `data-cl-open`/`data-cl-closed` for CSS-driven transitions.
148+
- **Disabled items use `aria-disabled`, not `disabled`.** They remain focusable for keyboard users.
149+
- **`label` is required on `Menu.Item`** — it drives typeahead matching. Disabled items are excluded from typeahead.
150+
151+
## ARIA
152+
153+
- Popup: `role="menu"`
154+
- Item: `role="menuitem"`, `aria-disabled`
155+
- Separator: `role="separator"`
156+
- Trigger: `aria-expanded`, `aria-haspopup="menu"`, `aria-controls`
157+
- Nested trigger: `role="menuitem"` (instead of button)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type {
2+
MenuArrowProps,
3+
MenuItemProps,
4+
MenuPopupProps,
5+
MenuPositionerProps,
6+
MenuProps,
7+
MenuSeparatorProps,
8+
MenuTriggerProps,
9+
} from './menu';
10+
export { Menu } from './menu';

0 commit comments

Comments
 (0)