Combobox

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

html
<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:

html
<!-- 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:

html
<!-- 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

DoDon't
use combobox + data-allow-create for single growing-list / add-if-missinginvent a new Hyperpart or bespoke create-dropdown for 'add to catalogue'
on growing-list form submit, upsert unknown values into the cataloguereject every value not in the original seed options (breaks Add "…")
filter options client-side from the server-rendered <option> listreplace the select with a div and invent a new submit contract
keep leftover filter visible and fail validity until a listed option is committedrevert leftover junk to the previous label so submit invents that option
use tags for multi free-form labels; search-select for remote FKsoverload 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

field tags search-select

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).

NodeAttrConstraint
[data-combobox]namepresent (any value)

Ingestion model ComboboxOption

Server-side shape before render — one normalisation boundary for producers.

FieldTypeRequired
valuestringrequired
labelstringrequired

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.

python
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

Same Hyperpart, two recipes. Progressive enhancement: server renders a real <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