<!-- Generated by emit-docs.ts — do not edit. -->
# Menu

Action menu on the native Popover API top layer: the top layer and light dismiss come from the platform; Esc, roving keyboard, placement and dismissal reasons are Psi's (D53). Controlled-only, like Dialog (D50). Controlled-only means Menu does not change its own visibility. Esc and item-select *report* a dismissal via onClose(reason) and leave the popover open; only the consumer flipping `open` to false actually closes it. Esc calls preventDefault() to suppress the platform's own popover dismissal, which is what makes that possible. Light dismiss (outside click) is the one asymmetry, and it is forced by the platform: the browser hides the popover itself and the hide-side `beforetoggle` is not cancelable, so the popover is already closed by the time the resulting `toggle` lets us report onClose("outside"). The consumer must still flip `open` to false so React's state matches the DOM. `toggle` therefore reports only that one reason. A close driven by the consumer (`open` true -> false) runs through the sync effect's own hidePopover(), which raises a `toggle` too — that one is suppressed, because a programmatic close is not a dismissal and must not call onClose.

## Props

| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
| `open` | `boolean` | — | yes | Controlled open state; syncs to showPopover()/hidePopover(). |
| `onClose` | `(reason: "item-select" \| "esc" \| "outside") => void` | — | yes | Called when a dismissal is *requested*; the consumer flips `open`. Menu never closes itself on `"esc"` or `"item-select"` — the popover stays open until `open` becomes false (D50). `"outside"` is the one exception: light dismiss is performed by the browser before it tells us, so by the time that reason is reported the popover is already closed. Flipping `open` to false is still required, to keep React's state in step. Invariant (D58): a dismissal is only ever reported for a menu that is currently open according to its own `open` prop. The platform can close an auto popover before the consumer's state catches up — clicking another menu's trigger light-dismisses this one before the consumer's click handler runs — and a report for an already-closed menu would clear a selection that has since moved on. |
| `trigger` | `ReactElement<Record<string, unknown>, string \| JSXElementConstructor<any>>` | — | yes | The trigger element. Must be a single React element that spreads unknown props onto a focusable node (a Psi `Button`, say): Menu clones it to add `aria-haspopup="menu"` and `aria-expanded`, so assistive tech associates the menu with the control users actually focus. |
| `placement` | `"bottom-start" \| "bottom-end" \| "top-start" \| "top-end"` | bottom-start | no | Placement relative to the trigger. |
| `aria-label` | `string` | — | no | Accessible name for the menu when there is no visible label. |
| `children` | `ReactNode` | — | yes | MenuItem and MenuSeparator children. |
| `className` | `string` | — | no | Additional CSS class name(s) merged onto the component's root element. |
| `ref` | `Ref<HTMLDivElement>` | — | no | Forwarded ref to the popover element. |

## Keyboard & assistive tech

| Keys | Behavior |
|---|---|
| Arrow Down / Arrow Up | Moves between enabled items, wrapping at both ends. Disabled items are skipped. |
| Home / End | Jumps to the first or last enabled item. |
| Any single printable key | Typeahead — focuses the first enabled item whose label starts with the typed prefix; the prefix resets after 500ms. Keystrokes with Meta/Ctrl/Alt held are ignored. |
| Esc | Suppresses the platform's own dismissal and reports onClose("esc"); the menu stays open until the consumer flips `open` (D50). |
| Enter / Space | Activates the focused item (native button behavior). |

Opens on the native top layer via popover="auto", which supplies light dismiss. Controlled-only: every dismissal path (esc, item-select, outside) only reports onClose(reason) — the consumer must flip `open` to actually close it; outside is the one path the platform has already acted on by the time it is reported. Opening moves focus to the first enabled item — Menu takes focus off the trigger as soon as `open` becomes true. Focus returns to the trigger when the menu actually closes, and only if focus is still inside the menu, so a light dismiss onto another control does not steal focus back. Requires an accessible name — pass aria-label. Placement uses CSS anchor positioning where supported and a JS fallback below that floor; below it there is no collision flip.

## Theming

Override `--psi-menu-*` custom properties at any scope; interactive states derive automatically (L - 0.04 hover, L - 0.08 active).



## Rules

- One accent per visual group; everything else neutral or ghost.
- danger only for actions with real consequences.
- Sizes are px numbers (24|32|40|48), never S/M/L.
- Typography tokens are --psi-text-{size}-{lineHeight}-{weight}.
- Override component tokens (--psi-{component}-*), not semantic tokens, for one-off theming.
- --psi-button-font overrides button typography across all sizes (documented D34 override; ember → mono).
- Wrap labeled form controls in Field — label association, description/error line, aria-describedby and aria-invalid come wired; don't hand-roll label+message rows.
- Field is for a control with a *visible* label. A toolbar filter control named by aria-label or placeholder takes neither a Field nor a hand-rolled label row — compare filter-toolbar (no labels) with table-pagination ("Rows per page" visible, so Field).
- Use Dialog for blocking modal flows — title/footer slots, dismissible gate; danger stays on the footer Buttons, one accent per group.
