# Menu

**When to use:** A list of actions hanging off a trigger — a row's "more" button, a toolbar overflow, a context menu on a card. Use [Popover](Popover.md) directly when the panel holds something other than a list of actions, and [Dropdown](Dropdown.md) when the user is picking a value rather than doing something.

**Keywords:** context menu, action menu, overflow menu, kebab menu, three dots, popup menu, right click, anchored list

**Import:** `import { Menu, menuHotkeys } from '@ahrowe/ui'`  
**Types:** `import type { MenuProps, MenuEntry, MenuAction } from '@ahrowe/ui'`

```tsx
import { Menu } from '@ahrowe/ui';
import type { MenuEntry } from '@ahrowe/ui';
import { faPen, faCopy, faTrash } from '@fortawesome/free-solid-svg-icons';

const items: MenuEntry[] = [
  { id: 'edit', label: 'Edit', icon: faPen, shortcut: 'mod+e' },
  { id: 'duplicate', label: 'Duplicate', icon: faCopy },
  { separator: true },
  { id: 'delete', label: 'Delete', icon: faTrash, danger: true },
];

<Menu items={items} onAction={(id) => run(id)}>
  <Button>Actions</Button>
</Menu>;
```

**Entries** are one of three shapes, and the list is read in the order you give it:

```ts
{ id: 'edit', label: 'Edit', icon, shortcut, keywords, disabled, danger, href, onClick }  // an action, or a link with `href`
{ separator: true }                                                                       // a line
{ heading: 'This document' }                                                              // a label over what follows
```

`keywords` are extra search terms for [CommandPalette](CommandPalette.md), which takes the same entries. The menu ignores them.

**Shortcuts are drawn, not bound.** A `shortcut` is a combo in [useHotkeys](Hooks.md) spelling, and the menu writes it on the trailing edge for the platform the reader is on: `mod+s` shows as `⌘S` on an Apple one and `Ctrl+S` everywhere else. So there is one spelling per shortcut and a hand-written `'⌘S'` can no longer be wrong on Windows. Screen readers get it through `aria-keyshortcuts`, not as part of the item's name.

Listening for it is yours, because only your app knows what else is listening: if it already binds `⌘S` and the menu bound it too, both would fire and the action would run twice. `menuHotkeys` turns the same entries into a map for `useHotkeys`, so the one spelling covers the hint and the binding:

```tsx
import { Menu, menuHotkeys, useHotkeys } from '@ahrowe/ui';

const items: MenuEntry[] = [
  { id: 'save', label: 'Save', shortcut: 'mod+s', onClick: save },
  { id: 'rename', label: 'Rename', shortcut: 'f2', onClick: rename },
];

// Whenever the page is up, which is what a shortcut on a menu item usually means:
// ⌘S in a File menu works without opening the File menu.
useHotkeys(menuHotkeys(items, run));

// Or only while the menu is open, if that is what you want:
useHotkeys(menuHotkeys(items, run), { enabled: isOpen });
```

It runs each entry the way clicking it does, its own `onClick` then `onAction` then its `href`, and leaves out a `disabled` entry and any entry with no `shortcut`. It runs outside React, so it cannot reach `ConfigProvider`'s `navigate`: an `href` run from a shortcut loads the page. Bind them yourself instead if the actions already live in a keymap of your own; the menu does not mind either way.

An action runs its own `onClick` first, then `onAction` on the menu, then the menu closes. Use whichever of the two fits: a handler per entry when they are unrelated, `onAction` when they funnel into one place.

```tsx
// Grouped, with a heading over each group
<Menu
  items={[
    { heading: 'This document' },
    { id: 'rename', label: 'Rename' },
    { separator: true },
    { heading: 'Elsewhere' },
    { id: 'open', label: 'Open in a new tab' },
  ]}
>
  <ActionIcon icon={faEllipsisVertical} aria-label='More actions' />
</Menu>

// A sheet on a small touch screen, anchored everywhere else
<Menu items={items} presentation={Presentation.Auto}>
  <Button>Open</Button>
</Menu>
```

**Keyboard:** the menu takes one tab stop, and follows the menu-button pattern.

| Key | Does |
|-----|------|
| `↓` on the closed trigger | Open, focus the first entry |
| `↑` on the closed trigger | Open, focus the last entry |
| `↓` `↑` on the trigger of a menu opened by mouse | Move into the list, at the first or last entry |
| `↑` `↓` | Move between entries, wrapping, skipping disabled ones |
| `Home` `End` | First or last entry |
| a letter | Jump to the next entry that starts with it; keep typing to narrow, or repeat one letter to walk the matches |
| `Enter` `Space` | Run the focused entry |
| `Escape` | Close and hand focus back to the trigger |
| `Tab` | Close, and carry on to whatever follows the trigger, from any entry |
| `Shift+Tab` | Close, and hand focus back to the trigger |

A click opens the menu without moving the focus, so no entry is marked until you point at one or press an arrow key: a highlight that does not follow the mouse reads as a selection. Opening with the keyboard, including `Enter` and `Space`, puts the focus on an entry straight away.

Separators and headings are never focused. The trigger is given `aria-haspopup`, `aria-expanded` and `aria-controls` unless it sets them itself, so a screen reader announces it as a menu button.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `items` | `MenuEntry[]` | What the menu offers (required) |
| `children` | `ReactNode` | The trigger |
| `onAction` | `(id: string) => void` | Called with the id of the entry that was picked |
| `isOpen` | `boolean` | Controlled open state; leave it out to let the menu manage its own |
| `onOpenChange` | `(isOpen: boolean) => void` | Called when the menu wants to open or close |
| `placement` | `PopoverPlacement` | Which side of the trigger it opens on (default `Bottom`) |
| `align` | `PopoverAlign` | Which edge it lines up with (default `Start`) |
| `presentation` | `Presentation` | Anchored, a bottom sheet, or a sheet only on touch |

**Entry fields (`MenuAction`):**

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Stable id, also what `onAction` reports |
| `label` | `ReactNode` | What the entry says |
| `icon` | `IconContent` | Leading icon. Entries without one keep its space while any other has one, so the labels line up. Same in `CommandPalette` |
| `shortcut` | `string` | A combo in `useHotkeys` spelling, e.g. `'mod+k'`. Shown on the trailing edge, written for the platform. Binding it is yours, see `menuHotkeys` |
| `disabled` | `boolean` | Not clickable, and skipped by the arrow keys |
| `danger` | `boolean` | Renders in the destructive colour |
| `href` | `string` | Makes the entry a real `<a>`, so Ctrl/⌘-click and a middle click open it in a new tab. A click runs `onClick` and `onAction` first, a middle click neither, then follows the link through `ConfigProvider`'s `navigate` when it is set, see [ConfigProvider.md](ConfigProvider.md). A `disabled` entry stays a button |
| `onClick` | `() => void` | Runs before `onAction` |

**Slots:** `root` `trigger` `menu` `item` `itemIcon` `itemLabel` `itemShortcut` `separator` `heading`

`root` and `trigger` generate no box, so your trigger keeps the place it would have had without a Menu around it. A `className` or `style` on those two cannot paint; style the trigger you pass in instead.

**Sheet dimensions:** as a sheet it spans the full width up to `--sheet-max-width` (560px) and centres beyond it, caps its height at `--sheet-max-height` (70dvh) of the space actually visible, and clears a phone's home indicator with `--sheet-safe-padding` (8px, or the device's own safe-area inset where that is larger). Set any of them per instance via `style` or theme-wide via `ThemeProvider`.

**No submenus yet.** An entry is a single action. Nesting brings its own interaction model — hover intent, a second layer of focus management, and a different shape again as a sheet on touch — so it is a separate piece of work rather than a field on `MenuAction`.
