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_COMBOBOX.md
Raw

CONTRACT_COMBOBOX.md

# CONTRACT_COMBOBOX (v1, frozen) — combobox reference (popover × cursor × Channel)
The composition case the whole project exists for: server-rendered async results +
client-owned active descendant + versioned query exchange + browser-owned listbox
shell, all on one DOM subtree with two writers. Productionizes the combobox spike
(`reports/COMBOBOX_SPIKE_REPORT.md`) into a shipped component + the shipped
`LiveInteractionContracts.Channel` guard.
## Classification: green / protected-reflection — with a construction requirement
Axis A: **green** — openness in the top layer; cursor one-frame client; results and
selection server-owned. Axis B: **protected-reflection** (`aria-expanded`,
`aria-activedescendant`, `data-stale` on the input; `data-highlighted` on options).
**The construction requirement (what makes this different from menu/select):** the
green claim for *results* holds only if the app routes every result render through
`Channel.accept/2` (see CHANNEL_CONTRACT.md §The shipped helper). The reference
suite asserts both directions — guard-on drops a superseded version server-side
(`stale_drops`), and the fixture's guard-off negative control demonstrates the
failure mode the helper prevents (stale results reaching the DOM with only client
telemetry flagging them).
## Responsibilities
| Atom | Owner | Mechanism |
|---|---|---|
| Listbox openness | browser | `popover="manual"` (server constant) + top layer; hook calls show/hide on user gestures only |
| Query versioning | client emits, **server guards** | hook stamps a monotonic `v` per input event; server calls `Channel.note_query/2` + `Channel.accept/2` |
| Results | server | HEEx `<:item>` slot, stamped `data-version={rendered_version}` |
| Staleness prevention | **server (construction)** | `Channel.accept/2` refuses superseded versions on the render path |
| Staleness detection | client (telemetry) | `data-stale` on the input when rendered < last emitted — never authority |
| Cursor | client (one-frame) | `aria-activedescendant` on the input; `data-highlighted` on options; MutationObserver re-projects after every patch (rebind by logical id, deterministic collapse) |
| Selection | server | lossless `on_value_change` (Enter / option click); close is a user-gesture |
| IME | browser | no query emission during composition; exactly one at `compositionend` |
Focus stays in the input throughout (it is a form element — no WebKit focus-handoff
needed, unlike the click-trigger components).
## Conformance points (9), verified on chromium / firefox / webkit, 2 runs each
1. Basic query/result: rendered catches up to seen; listbox opens; ARIA wired;
focus in input; not stale.
2. **Stale drop by construction** (guard on): a superseded version, force-released
out of order via the fixture's deterministic gate, never reaches the DOM;
`stale_drops` increments.
3. **Negative control** (guard off): the same stale release reaches the DOM and only
client telemetry (`data-stale`) flags it — proving the helper is what buys the
guarantee.
4. Cursor collapse: active option removed by the next result set → cleared, no
dangling `aria-activedescendant`, listbox stays open.
5. Cursor rebind: active option present in a wholesale replacement → rebinds to the
live node by logical id.
6. IME gating (synthetic): zero emissions during composition; exactly one at
`compositionend`. (Real-device IME conformance remains an honest unknown —
BACKLOG.)
7. Popover shell: the server-rendered `popover` attribute survives every result
replacement; stays open through an unrelated sibling patch.
8. Selection: Enter on the active option → lossless `on_value_change`, server records,
listbox closes.
9. Zero-JS anchored positioning (`placement` attr; constraints:
REFERENCE_POPOVER_CONTRACT.md §Positioning).
Determinism note: version numbers are asserted **relatively** (rendered == seen,
deltas) — engines differ in how many input events one programmatic fill dispatches
(Firefox fires two), so absolute version constants are not portable. Evidence:
`priv/combobox-reference/combobox-reference-results.json`.
## Honest caveats
- **The guard is library code, but routing through it is still the app's move.** A
LiveView that assigns results without `Channel.accept/2` silently downgrades to
convention; the `data-version` stamp + `data-stale` telemetry exist to make that
visible, not to fix it.
- **Real cross-engine IME is untested** (synthetic events only) — non-optional for
CJK markets, tracked in BACKLOG; the "no emit during composition" rule is frozen.
- **Selection and results are server round-trips** — same explicit latency trade as
select; no optimistic client writes.
- **Typeahead/autocomplete-inline are not implemented** (APG lists them); add
cursor-only when a consumer needs them.