Combobox forms Controller
Searchable single-select over a list of options — a native <select> progressively enhanced into a type-to-filter combobox. Fixed lists by default; growing catalogues (add a missing value) use the same Hyperpart with data-allow-create — not a separate part.
Layer: L1 surface · Recipe: single-select-form — single-select (form field). Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.
Copy this
<div class="hm-stack hm-measure" data-gap="md">
<label class="field" for="hm-cb-field">
<span class="field__label">Priority (fixed list)</span>
<select id="hm-cb-field" name="priority" data-combobox class="form-input">
<option value="">Select a priority…</option>
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</label>
<label class="field" for="hm-cb-dept">
<span class="field__label">Department (growing list — type to add)</span>
<select id="hm-cb-dept" name="department" data-combobox data-allow-create class="form-input">
<option value="">Pick or add a department…</option>
<option value="operations">Operations</option>
<option value="finance">Finance</option>
<option value="support">Support</option>
</select>
</label>
</div>Server exchange
No dedicated htmx request of this Hyperpart's own — the controller never issues one. The native <select> value rides the enclosing form (or any hx-* you put on that form). That form handler is the server contract for this part.
Fixed list (default — no data-allow-create): closed set. Accept only values that were in the seed <option> list.
Growing list (data-allow-create): the client may submit a value that was not in the seed options (Add "…" appends a local <option> with value = label string). On form submit the server must accept that unknown string and upsert it into the catalogue, then store the field (FK or label per your model). The new option is page-local until that submit succeeds — do not treat client create as durable storage.
If you put hx-* on a control that uses this markup, that action's exchange belongs to the action, not this part.
Swap contract
Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.
No host HTMX exchange on this part — presentation or client chrome only. exchange envelope: n/a.
If a host wraps this markup in hx-*, that host owns the swap contract (sole identity + envelope). Prefer innerMorph / outerMorph for stable slots; replacement for flash; body-only responses under inner swaps.
Envelope response examples
This part has no owned exchange (envelope n/a). If a host adds hx-*, that host’s envelope applies — typically body_only:
<!-- Host wraps this presentation part with hx-* (host owns envelope) -->
<!-- Prefer: hx-swap="innerMorph" hx-target="#panel-body" -->
<!-- Server returns body_only interior for #panel-body -->
<div class="dz-stack">content…</div>
Do not re-own the slot:
<!-- WRONG: server returns the presentation root with a new id every poll -->
<div id="combobox-root" data-dz-region>…</div>
How to use it
Seams
- server renders a real <select data-combobox> — progressive enhancement
- native select stays as the submitted value after the overlay mounts
- FIXED list: omit allow-create — filter + pick only; form POST is closed enum
- GROWING list (mutable catalogue): data-allow-create — type a miss → Add "…" row → appends <option> + commits; form POST must upsert the catalogue
- data-focus-after-select=blur|keep|select (default blur)
Do / Don't
| Do | Don't |
|---|---|
| use combobox + data-allow-create for single growing-list / add-if-missing | invent a new Hyperpart or bespoke create-dropdown for 'add to catalogue' |
| on growing-list form submit, upsert unknown values into the catalogue | reject every value not in the original seed options (breaks Add "…") |
| filter options client-side from the server-rendered <option> list | replace the select with a div and invent a new submit contract |
| keep leftover filter visible and fail validity until a listed option is committed | revert leftover junk to the previous label so submit invents that option |
| use tags for multi free-form labels; search-select for remote FKs | overload combobox for multi-create or server-search FK flows |
Pitfalls
- pointerdown on the bare select must enhance first and swallow the native menu
- state is data-open on the root — not a JS open flag a morph would drop
- leftover typed filter must not invent the previous option (do not revert on blur)
- allow-create is client option-list UX only — the enclosing form handler must accept/upsert unknown values; do not treat the new option as durable alone
- multi free-create chips are tags, not combobox; remote ids are search-select
Keyboard / AT
- input is role=combobox with aria-expanded / aria-activedescendant
- ArrowUp/Down move highlight; Enter selects or creates; Esc closes
- Add "…" row is role=option (same listbox semantics)
- leftover junk fails custom validity on overlay and native select
Related parts
DOM contract
What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.
contracts/combobox.py
Required in the DOM: root [data-combobox] (part combobox). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).
| Node | Attr | Constraint |
|---|---|---|
[data-combobox] | name | present (any value) |
Ingestion model ComboboxOption
Server-side shape before render — one normalisation boundary for producers.
| Field | Type | Required |
|---|---|---|
value | string | required |
label | string | required |
Exemplar render()
Executable in CI: the Python below is render(); the boxed preview is render(EXEMPLARS[0]) — the first fixture the dual-lock tests emit, not a separate widget and not gallery mock data. Sample model: label=Priority.
def render(field: ComboboxField) -> str:
opts = []
for o in field.options:
sel = " selected" if o.value == field.selected and o.value != "" else ""
# bare-string producer shape lands as value==label after validator
opts.append(
f'<option value="{html.escape(o.value, quote=True)}"{sel}>'
f"{html.escape(o.label)}</option>"
)
return (
f'<label class="dz-field" for="{html.escape(field.field_id, quote=True)}">'
f'<span class="dz-field__label">{html.escape(field.label)}</span>'
f'<select id="{html.escape(field.field_id, quote=True)}" '
f'name="{html.escape(field.name, quote=True)}" data-dz-combobox '
f'class="dz-form-input">{"".join(opts)}</select></label>'
)
Live output of render(EXEMPLARS[0]) — fixture markup the dual-lock validates (sample field values only).
Notes
<select data-combobox> (placeholder option first). JS builds a filterable listbox; the native select remains the submit value. Fixed list (priority above): the set is authoritative and closed — filter and pick only (workflow priority, severity tiers, anything you do not let users invent). Growing list / mutable catalogue (department above): set data-allow-create on the <select>. When the typed query has no exact match, an Add "…" row appears; Enter/click appends a new <option> and commits it (value = label string). That is the common "pick from our list, or add one" pattern — departments, cost centres, queues, product lines — not a new Hyperpart. Server still owns persistence: on submit upsert the catalogue row if the value is new; the client only extends the option list for this page. Not this part: multi free-form chips → tags; remote FK typeahead → search-select. Do not invent a fourth picker. After select: data-focus-after-select=blur|keep|select (default blur). Leftover typed filter must not invent the previous option (cycle 2135): leftover junk stays visible and fails custom validity so submit cannot post the previous value as if the leftover were accepted. Empty leftover on blur restores the selected label; exact option label/value commits; growing-list leftover commits as Add "…".Source files
One logical Hyperpart, 4 code items (CSS layered, JS bundled). Bound by HYPERPART: combobox — python tools/hyperpart.py combobox lists them.
site/registry.py · contracts/combobox.py · components/combobox.css:1 · controllers/dz-combobox.js