# Menu

**Maturity:** stable

A visible, sizable trigger that, on a plain click/tap, presents a Liquid Glass popover of command rows — with labelled and unlabeled sections, leading icons, a destructive role, and drill-in submenus.

## Import

```tsx
import { Menu } from "@liquidify/react/menu"
```

## Props

- `aria-label`: `string | undefined`. Accessible name of the panel (`role="menu"`). Defaults to the trigger's accessible name.
- `className`: `string | undefined`. Optional additional CSS class merged into the glass surface class. The supported per-instance customisation seam: override any `--lq-*` custom property from this class's CSS — the token cascade the engine reads (ADR-0004). No raw inline style is involved, so this stays ADR-0003 clean.
- `defaultOpen`: `boolean | undefined`; default ``false``. Uncontrolled seed for the open state.
- `disabled`: `boolean | undefined`. Whole-control disable: desaturates the trigger, `aria-disabled` + `data-disabled`, removed from tab order, cannot open.
- `icon`: `ReactNode`. Optional leading icon in the trigger's reserved gutter, `aria-hidden`.
- `items`: `readonly MenuNode[] | undefined`. The declarative command tree; each node carries a required stable `id` (§16.6).
- `label`: `ReactNode` (required). Fixed trigger text/content (the SwiftUI `Menu(_ title:, content:)` analogue) — NOT a current-value display.
- `labelProps`: `LabelProps | undefined`. Per-instance typography override spread last into every row's label (ADR-0028).
- `onOpenChange`: `((open: boolean) => void) | undefined`. Fires with the next open state on every open/close path.
- `open`: `boolean | undefined`. Controlled open state. Passing it switches Menu to controlled mode.
- `placement`: `LogicalSide | undefined`. Which side of the trigger the panel sits on. Forwarded to Popup.
- `ref`: `Ref<HTMLButtonElement> | undefined`. Forwarded ref to the rendered element — the component's native root (or the merged child element on a component that opts into {@link SlottableProps}). Generic over `E` (charter `docs/api-conventions.md` §4 rule 1, default `HTMLElement`) so a component with a polymorphic root (Link's navigation / action mode union, Text's `as`) can instantiate the concrete element per variant instead of leaving every consumer with a widened `Ref<HTMLElement>`. A component that does not narrow simply inherits the `HTMLElement` default — source-compatible with every pre-existing non-generic `extends ControlProps`.
- `size`: `MenuSize | undefined`; default ``"regular"``. Size of the `style="button"` trigger capsule (the SwiftUI `.controlSize(_:)` analogue). `mini` / `large` / `xlarge` are deferred — no authentic reference frame (spec `## Out of scope`).
- `style`: `"menu" | "button" | undefined`; default ``"menu"``. Visual style of the trigger. `"menu"` (default) is a plain accent-text trigger with no chrome at rest (`.menuStyle(.automatic)` analogue); `"button"` is a `size`-scaled glass-capsule trigger (`.menuStyle(.button)` analogue).

## SwiftUI mapping

- ``Menu(_ title:, content:)` / `Menu(_ titleKey:, content:)`` → ``label: ReactNode`` (direct): Fixed trigger text (e.g. `"Actions"`); NOT a current-value display (contrast Picker).
- ``Menu(content:, label:)`` → ``label: ReactNode` + `icon?: ReactNode`` (direct): Custom trigger (e.g. `Label("Edit", systemImage:)`); leading icon slot (`…/overview.png`), `aria-hidden`.
- ``Button("Copy") { }` (item)` → ``MenuItem` = `{ id; label; onSelect?; disabled?; icon?; role? }`` (direct): `id` required + stable (§16.6 — rows key on `id`; duplicate `id` is a dev-mode error; duplicate labels allowed). Action → `onSelect`; activation dismisses.
- ``Label("Copy", systemImage:)` (item)` → ``MenuItem.icon?: ReactNode`` (direct): Leading SF Symbol → MenuRow `leading` slot.
- ``Button(role: .destructive) { }`` → ``MenuItem.role?: "destructive"`` (direct): MenuRow `destructive` → `data-role="destructive"`, `--lq-color-action-destructive`, scheme-stable. The label + position also carry the meaning (ADR-0019 — not colour-only).
- ``Section("Edit") { … }`` → ``MenuSection` = `{ id; label; items }`` (direct): Labelled group: `role="group"` + caption header.
- `unlabeled `Section { }`` → ``MenuSection` with `label` omitted` (direct): Divider-only group (leading `role="separator"`).
- `nested `Menu("Open Recent") { … }`` → ``MenuSubmenu` = `{ id; label; icon?; items }`` (direct): Submenu: trailing chevron, `aria-haspopup="menu"` + `aria-expanded` on the parent row, drill-in.
- ``.menuStyle(.automatic)` (default)` → ``style?: "menu"` (default)` (direct): Plain accent-text trigger, no chrome.
- ``.menuStyle(.button)`` → ``style?: "button"`` (direct): Button-shaped, `size`-scaled trigger + compact popover.
- ``.controlSize(.small / .regular)`` → ``size?: MenuSize` (extends `SizedControlProps`)` (direct): `MenuSize = Extract<Size, "small" | "regular">`, default `regular` — the v1 subset with reference frames.
- ``.disabled(true)`` → ``disabled?: boolean`` (direct): Whole-control disable: `aria-disabled` + `data-disabled` on the trigger, removed from tab order, no popover (`…/disabled--rest.png`).
- ``isPresented`-equivalent (implicit)` → ``open?` / `defaultOpen?` / `onOpenChange?`` (direct): Presentation triad (ADR-0015); controlled iff `open !== undefined`; `defaultOpen` default `false`; forwarded to `Popup`.
- `(menu anchor side)` → ``placement?: PopupPlacement`` (direct): Forwarded to `Popup`; default the sim's below-anchor placement.
- `(menu accessible name)` → ``aria-label?: string`` (direct): `role="menu"` accessible name; defaults to the trigger's accessible name, forwarded to `Popup.Content`.
- `(per-instance typography)` → ``labelProps?: LabelProps`` (direct): ADR-0028 seam — spread last into every row's `TextLabel` (as ContextMenu).
- `(no SwiftUI analogue)` → ``ref?`` (web-only): Forwarded to the **trigger `<button>`** (the documented semantic root, §16.4), typed `Ref<HTMLButtonElement>`, always attached (open or closed).
- ``.tint(_:)` — **not exercised**` → `— (declined, no `tint` prop)` (direct): Accent-only, mirroring the sim + ContextMenu (P0.B6). See Out of scope.
- ``Menu(content:label:primaryAction:)` — **not exercised**` → `— (deferred)` (direct): Split-button form; `MenuDemo.swift` ships none. See Out of scope.
- ``.menuOrder` / `.menuActionDismissBehavior` / `.menuIndicator` / `.borderlessButton`` → `— (deferred)` (direct): Not exercised; out of scope v1.
