Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts CONTRACT_SELECT.md
Raw

CONTRACT_SELECT.md

# CONTRACT_SELECT (v1, frozen) — select reference (popover + cursor + server-owned value)
Select composes three already-contracted machines: a popover listbox (openness in
the top layer), a Cursor over server-rendered options (`aria-activedescendant`), and
a **selected value** projected into a hidden form input.
## Classification: green / protected-reflection — and why the amber guess dissolved
The playbook's provisional table marked select **amber** ("value is form state").
That guess assumed the selected value would live in browser editing state (a real
`<select>`'s value property, or a visible input the user edits). This component
resolves it **by construction**: the value never lives in the browser at all.
- Selection is a **lossless client→server event** (`on_value_change`, same legitimacy as
menu's activation event — the server owns the outcome).
- The server re-renders the trigger label, the hidden `<input value=...>`, and
`aria-selected` on the options — all **server-owned constants**, patch-safe by
construction (a patch re-asserting them is a no-op, not a threat).
- The hook never writes the value anywhere; it only moves the one-frame cursor and
relays the native events.
Axis A: **green** — open state in the top layer; cursor is one-frame client-owned;
value is server-owned. Axis B: **protected-reflection**`aria-expanded`,
`aria-activedescendant` (trigger) and `data-highlighted` (options) are client-managed
mirrors protected with `JS.ignore_attributes`.
The general lesson (this is the durable part): **"form state" is only amber if you
let the browser own it.** A select whose value round-trips through the server is a
green machine with a server-owned projection — the same move that made the menu's
activation lossless instead of stateful.
## Responsibilities
| Atom | Owner | Mechanism |
|---|---|---|
| Listbox openness | browser | `popover` + top layer; `popovertarget` invoker |
| Cursor (highlighted option) | client (one-frame) | `aria-activedescendant` on the trigger; `data-highlighted` on options; live-DOM lookups |
| Selected value | **server** | `on_value_change` event → assigns → re-render of label / hidden input / `aria-selected` |
| Option existence | server | HEEx `<:option>` slot; removal collapses the cursor deterministically |
| Form integration | server | hidden `<input name value>` (rendered only when `name` is set) |
Keyboard (behavior per APG/React Aria, ownership per KERNEL): ArrowDown/ArrowUp move
the cursor; Enter selects the active option (pushes `on_value_change`, then closes — a
user-gesture close, same legitimacy as Escape); Escape closes; option click selects
and closes. On open, the cursor starts on the currently selected option (APG
open-on-selected placement). WebKit focus handoff: one-time `trigger.focus()` on the
native `toggle` open event (CURSOR_CONTRACT.md §Focus handoff) — never a re-focus on
patch.
## Conformance points (9), verified on chromium / firefox / webkit, 2 runs each
1. Native open: `aria-expanded`, listbox visible, options live, focus on trigger.
2. Cursor movement: `aria-activedescendant` targets live option DOM ids; focus never
leaves the trigger.
3. Unrelated patch while open: open state + cursor survive; patched option content
arrives.
4. Enter selects: server round-trip updates change log, hidden input, trigger label;
listbox closes; reopen shows `aria-selected` and open-on-selected cursor.
5. Click selects: same round-trip via `phx-click`.
6. Value survives an unrelated patch (server-owned by construction — this point
exists to keep the classification honest, not because it is at risk).
7. Active option removed by a patch: deterministic collapse, no dangling
`aria-activedescendant`, listbox stays open.
8. Escape closes; `aria-expanded` false; cursor cleared.
9. Zero-JS anchored positioning (`placement` attr): 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).
Evidence: `priv/select-reference/select-reference-results.json` (per-engine raw
snapshots; version-specific numbers live there, not here).
## Honest caveats
- **This is not a native `<select>`.** The native control's value/popup delegation
is a different ledger row (value green, popup unknown — not automatable). This
component trades the native popup for a popover listbox precisely so openness and
cursor become contractable.
- **Selection latency is a server round-trip.** Between Enter and the patch, the
trigger still shows the old label (no optimistic client write — that would create
a second writer). On a dead connection, selection does not happen. This is the
architecture's explicit trade, stated, not hidden.
- **Typeahead (printable-character jump) is not implemented** in v1 of this
component; APG lists it for full parity. Add it cursor-only (no server round-trip)
when a consumer needs it.