import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as MenuStories from "./Menu.stories";

<Meta of={MenuStories} />

# Menu

A menu is **not** a listbox, and mux-magic's `TypePicker` is the fleet's one attempt at
telling them apart — it renders `role="menu"` over items that set a value, which is a
listbox wearing the wrong role.

<Canvas of={MenuStories.Playground} />

## Props

<Controls />

## `menuitem` versus `option`

| | `menuitem` | `option` |
| --- | --- | --- |
| What it is | an **action** — Retry, Skip, Eject | a **value** you are choosing |
| Announced as | "menu, 3 items" | "listbox, selected, 2 of 4" |
| Leaves state behind | no | yes |
| Lives in | this component | `Select` |

The last row is why it matters to an agent driving the app: after choosing an `option` it
needs to know what the control now holds. After invoking a `menuitem` there is nothing to
read back. Getting the role wrong is invisible in a browser and invisible to axe.

## Portalled, and no `[popover]` machinery

The panel renders in a `FloatingPortal` at `document.body`, through the same
`useAnchoredOverlay` `Popover` uses
([decision](../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md)).
A portalled node is visible when mounted, so the whole `popover="manual"` /
`showPopover()` / `isConnected`-guard class of "a `[popover]` that was never shown is
`display: none`" bug is simply gone. The menu keeps its roving focus, its arrow/Home/End
keymap, its seed-first-item-on-open effect, and its `MenuAction` items. A scoped query for
the panel now reaches for `body`; `useRole` names it by pointing `aria-labelledby` at the
trigger, so the link survives the boundary.

## There is no `label` prop, and that corrects a lesson

`Spinner` (M3), `Popover` and `Modal` (M4) all take a **required** name, because a
`role="status"` and a `role="dialog"` take none from their content. The obvious move here was
a `label` prop doing the same job — it was written that way first, and then measured as
doing **nothing**.

`useRole(context, { role: "menu" })` puts `aria-labelledby` on the panel pointing at the
trigger, and `aria-labelledby` beats `aria-label`. The menu was already named "Bay 3", from
the button, and the prop was silently discarded. That is also the APG menu-button pattern.

So the rule the other three teach is narrower than it looked:

> An overlay with **no trigger relationship** needs its own name.

A menu has one, so the requirement moves to the trigger — which `Button` and `IconButton`
already enforce.

## A context menu is this component with no trigger

Press and hold a card on a phone and the menu opens at the finger. That is the same
component in **anchor mode**: `anchorRef` replaces `trigger`, and the panel hangs off a
**virtual element** — an object that knows only its own rectangle, which here is the
zero-sized rect at the point pressed. `flip` and `shift` treat it exactly like a button,
so a press near the bottom of the window still gets a menu that fits.

`useLongPress` in `@charcuterie/logic` is the other half. It watches the pointer, cancels
on a 10px drift so a scroll that starts on the card is a scroll, and hands back the point.
Two details in it are not obvious:

- **The native `contextmenu` is a second trigger, not only something to suppress.** Chrome
  on Android recognises its own long press at about the same 500ms and fires `pointercancel`
  at the sequence, which clears the timer a beat before it would have run. Whichever arrives
  first fires the callback, once.
- **Touch and pen only, by default.** A mouse already has a context menu, and the browser's
  own carries "Open link in new tab" and "Copy link address" — taking that over to show four
  app actions is a loss on a desktop and a gain on a phone. Pass `["mouse"]` deliberately,
  for a surface that is not a link.

The CSS half belongs to the consumer: `-webkit-touch-callout: none` on the pressed element,
or iOS shows its link preview sheet over the menu. Safari fires no `contextmenu`, so no
handler can reach it.

**`label` is required here**, and that is the rule above read from the other side. With the
trigger gone nothing names the panel, so the prop that was inert in trigger mode becomes the
only name the menu has. The type enforces it: `anchorRef` and `trigger` are mutually
exclusive, and `label` belongs to exactly one of them.

Dismissal needs no special case. floating-ui watches `pointerdown` for an outside press, and
the press that opened a context menu is over before the panel exists — so it cannot close
itself on the way in.

<Canvas of={MenuStories.ContextMenu} />

## One kind, and that is the other half of the `Tabs` argument

`Tabs` needed **two** state kinds because a tab is chosen *and* focused, and in `manual`
activation those two disagree on purpose. A menu needs **one** — `RovingFocus` — because a
menu item is never selected, only invoked.

Two components, opposite conclusions, same model. That is the composition claim being paid
off rather than asserted.

<Canvas of={MenuStories.AllVariants} />

## Focus moves in, once

A menu is one of the few patterns where focus must jump on open, or the keyboard user who
pressed Enter is still on the trigger with a menu they cannot reach.

It happens in the panel's own effect rather than through `FloatingFocusManager`'s
`initialFocus`, because the element to focus is whichever one `RovingFocus` says is active —
and that is not known until the items have registered. `initialFocus={-1}` turns the
manager's own aim off, so there is one owner for the caret.

`modal={false}`, so Tab **leaves** the menu and lands on the next control in the page, which
is what closes it. A trapped menu is a dialog.

<Canvas of={MenuStories.AllStates} />

## Registration is membership

A disabled item never registers with the roving group, so the arrow keys skip it with
nothing in `RovingFocus` knowing what "disabled" means. It stays in the DOM and stays
announced — "not right now", rather than "does not exist".

## No type-ahead, deliberately

The APG lists type-ahead as **optional** for a menu and **required** for a listbox, and none
of the fleet's menus are long enough to need it — the largest has six items. Adding it means
a keystroke buffer and a timer in a component that has neither, for a menu you can cross in
three arrow presses.

<Canvas of={MenuStories.Interactive} />

## One trigger, two slots

A `Menu` and a `Tooltip` on the same button — the obvious thing to write, and until 1.0.1
it did not work.

Both components clone onto the trigger and both hand it a floating-ui
`refs.setReference` — the element each of them positions its panel against. Every merge in
the library was last-write-wins, so the inner `Tooltip` wrote its ref and the `Menu`'s was
gone. A menu with no reference element is not an error: floating-ui simply leaves the panel
at `left: 0; top: 0`, in the corner of the viewport, which reads as a CSS bug.

A `ref` is a subscription and an `on*` is a listener, and neither survives being replaced.
So both are **merged** now — between two slots by `mergeSlotWiring`, and between a slot and
the element you wrote by `useClonedChild`, which is what makes
`<Menu trigger={<Button ref={buttonRef} onClick={toggle} />} />` keep your ref *and* your
handler.

`SharedTrigger` is padded so the trigger sits away from the viewport origin: at the corner,
an anchored panel and an unanchored one land in the same place, and the test asserts that
padding is still there before it asserts anything else.

<Canvas of={MenuStories.SharedTrigger} />

## The row is a click target, so it is `lg` by default

`itemSize` takes the same `sm` / `md` / `lg` a `Button` takes, from the same
density-aware `--control-height-*` tokens — so a `md` menu item and a `md` `Button`
measure the same, and the `kiosk` density grows both together.

It **defaults to `lg`**, which nothing else in the library does. The asymmetry is the
decision rather than an oversight: a menu item is a *pointer* target before it is a list
row. It is read once and then aimed at, usually in a hurry, and a menu rarely holds more
than about eight items — so it can spend the height a dense list cannot. At `comfortable`
density `lg` is 2.75rem, which is also the 44px WCAG 2.5.5 target; on the `kiosk` density
it is 3.75rem with no prop change.

The floor is a **minimum**, not a fixed height (`min-h-`, where a `Button` uses `h-`), so a
long label that wraps to a second line still grows past it.

<Canvas of={MenuStories.ItemSizes} />

## A short window steps the size back down, and the panel scrolls

Two separate protections, because they answer different failures.

A window shorter than **40rem** drops every panel row one step, and one shorter than
**30rem** takes them to `sm` — so a `lg` menu is never the reason its own last item is off
the screen. That is `usePanelItemSize`, a `matchMedia` read rather than a CSS media query:
`text-sm` → `text-md` is a Tailwind bridge onto `--font-size-*` rather than a variable a
media query can reassign, so stepping the whole `ControlSize` in JS is what keeps one
literal class string per size. It costs a re-render on resize, which is not the axis the
library keeps as a repaint.

Independently, the panel is now clamped to the space the viewport actually left it and
scrolls inside that. A menu had **no** clamp before — survivable at a 32px row, and not at
44px, where nine items are 400px and the panel simply ran off the bottom of the screen with
no way to reach the last one.

## A long label wraps, rather than pushing the panel off screen

A portalled panel is `position: fixed`, so its shrink-to-fit width stops at the
**viewport** — not at the space `shift` left it. A label long enough to reach that cap
therefore produced a menu exactly as wide as the window, positioned 8px in, with 8px of
itself off the right edge: clipped and unreachable. Measured at 390px.

That was pre-existing and size-independent, but a `lg` row's larger type is what makes a
real label reach it, so the clamp lands in the same change. `isWidthClamped` caps the panel
at floating-ui's shifted `availableWidth`, and the label wraps inside its row — which is
also why a row's height is a `min-h-` rather than an `h-`.

<Canvas of={MenuStories.LongLabel} />
