|
| 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) |
0 commit comments