Current section
Files
Jump to
Current section
Files
live_interaction_contracts
CONTRACT_MENU.md
CONTRACT_MENU.md
# CONTRACT_MENU (v1, frozen)
Menu is the first **composition** built on top of the popover reference: it is
`popover` (open/close, browser-owned) plus a `Cursor` (`aria-activedescendant`)
over server-rendered, identity-bearing menu items. It is the first reuse of the
popover anatomy composed with the cursor contract (see BUILDING_COMPONENTS.md §6).
## Classification: green (+ cursor machinery)
Menu inherits popover's green classification for open/close (see
REFERENCE_POPOVER_CONTRACT.md) and adds the cursor construct on top. All seven
conformance points below passed on every engine, across both runs, so the
classification stands as green — with the same caveat popover already carries:
a green machine's component still touches the amber `ignore_attributes`
technique for its client-managed ARIA reflection, not for its open/active
*state*. That is not hidden; it is the same finding popover already documents,
now applied to three attributes instead of one (`aria-expanded`,
`aria-activedescendant`, `data-highlighted`).
## Responsibilities
* **Open/closed state** — browser-owned, identical mechanism to popover: native
`popovertarget` + top layer. The server never renders or drives it.
* **Structural attributes** — `popover`, `role="menu"`, `role="menuitem"` are
server-rendered constants in the component template, per KERNEL rule 1.2.
* **Content and item identity** — items are server-rendered HEEx, each carrying
a caller-supplied logical id reflected as `data-item`; the component derives
each item's DOM id from the caller's single explicit `id` plus that logical
id, so identity is stable and inspectable independent of DOM node identity.
* **The cursor** — client-owned, per CURSOR_CONTRACT.md: `aria-activedescendant`
lives on the trigger (which the hook explicitly focuses once, on open — see
the caveat below), `data-highlighted` lives on the active item, both moved by
ArrowDown/ArrowUp. This is deliberately **not** roving tabindex.
* **Selection** — item activation (Enter on the active item, or a real click on
any item) is relayed to the server as a plain LiveView event carrying the
item's logical id. The client never decides the outcome of a selection; it
only reports that one was requested.
* **Collapse policy** — if a patch removes the active item, the cursor clears
deterministically (`aria-activedescendant` back to empty, no item left
`data-highlighted`) rather than pointing at a node that no longer exists. This
is the "clear" policy CURSOR_CONTRACT.md names as the default.
## Conformance points (8), verified on chromium / firefox / webkit, 2 runs each
(Point 8, added with the `placement` attr: zero-JS anchored positioning — glued on
open, geometry stable across a patch, follows a patch-moved anchor, flips at the
viewport edge, tracks scroll. Constraints: REFERENCE_POPOVER_CONTRACT.md
§Positioning.)
1. **Open via trigger** — native `popovertarget` click opens the menu;
`aria-expanded` reflects true; all items are present. Passed, all engines.
2. **ArrowDown/ArrowUp move the cursor to a live, real node** — verified by
reading `aria-activedescendant` off the trigger and cross-checking it
against `data-highlighted` on the actual item node, not by string-comparing
markup. Passed, all engines.
3. **Cursor survives an unrelated content patch** — a patch that re-renders a
*different* item leaves the active item's pointer and its `data-highlighted`
attribute untouched. Passed, all engines.
4. **Cursor survives a patch that re-renders the active item itself** — the
decisive test: a patch that changes the active item's own displayed content
(same DOM node, confirmed via a JS expando marker so this is not merely
"no crash" but a checked node-identity claim) still carries
`aria-activedescendant` and `data-highlighted` afterward, protected by
`JS.ignore_attributes(["aria-activedescendant"])` / `(["data-highlighted"])`.
Passed, all engines.
5. **Deterministic collapse when the active item is removed** — removing the
active item via a patch clears the cursor (no dangling
`aria-activedescendant`, no item left marked active) rather than leaving it
pointed at a node that is no longer in the DOM. Passed, all engines.
6. **Light-dismiss** — a real click outside the menu closes it via the native
`popover="auto"` behavior; no client code is involved. Passed, all engines.
7. **Item activation is an event, not client state** — both Enter-on-active-item
and a real click on an item cause a server-rendered counter (fed only by the
server's `handle_event` for the selection event) to advance with the
correct logical id, proving the server — not the client — recorded the
outcome. Passed, all engines.
## Honest caveats
* **Focus-on-open is a deliberate exception to "never re-assert focus."**
WebKit does not move focus to a `<button>` on click by default (unlike
Chromium/Firefox), so without an explicit assignment the trigger never has
focus and never receives the ArrowDown/ArrowUp/Enter keydowns the cursor
depends on. The hook calls `.focus()` on the trigger exactly once, when the
native `toggle` event reports the menu opened. This is *not* the forbidden
per-patch re-focus (CURSOR_CONTRACT.md rule 4) — it never fires on a patch,
only on the native open transition — but it is a real, engine-driven
divergence worth naming rather than silently working around.
* **No wraparound.** ArrowDown/ArrowUp clamp at the first/last item rather than
wrapping. This is a deliberate, simple choice, not a conformance requirement
either way; a caller wanting wraparound would need a different hook.
* **Single-level menu only.** Submenus, item groups, and typeahead are out of
scope for this reference; the contract is about the popover+cursor anatomy
holding under patches, not menu UX completeness.
* **The cursor's live-item lookup is a query, not a cache.** The hook never
holds a stale array of item nodes across ticks — every keydown and every
mutation-observer callback re-queries `role="menuitem"` children live. This
avoids an entire class of stale-reference bugs but means very large item
lists re-query on every keypress; not a concern at reference scale, worth
knowing before reusing this hook verbatim for a large virtualized list.
## Relationship to the kernel
Menu is the first evidence that the anatomy generalizes: `popover.ex`'s "one
component + named slots + a single explicit id, derive all wiring" idiom
extended cleanly to a second machine composed on top of the first, and the
cursor's `aria-activedescendant` + `ignore_attributes` + live-query rebind
model held across all three engines without new escape hatches beyond the
open-time focus assignment documented above.
*Evidence: `priv/menu-reference/menu-reference-results.json` (version-specific
engine numbers live there, not in this file, per BUILDING_COMPONENTS.md §5).*