Reference lookup
Business forms reference masters: the customer on the order, the item
on the line, the cost centre on the expense. Power users type the code;
everyone else needs a searchable dialog (SAP calls it F4 help). This
recipe is that field as a contract — two inputs, one truth: the
visible *_code for humans, a hidden *_id for identity, and a
server that never lets them disagree.
Also known as: master lookup, code lookup, foreign-key picker.
Live demo
Section titled “Live demo”Edit the code and leave the field: C-1043 resolves, C-9999 shows
the 422 shape (message, cleared id), an empty code clears. The 🔍
button opens the searcher; picking a row re-renders the field and
closes the dialog — and the inactive customer is visible but refused.
Loading field…
The markup
Section titled “The markup”<div class="hc-field" id="customer-field" data-hc-lookup> <label class="hc-field__label" for="customer-code">Customer</label> <div class="hc-input-group"> <input class="hc-input" id="customer-code" name="customer_code" value="C-1041" data-hx-get="/customers/resolve" data-hx-trigger="change" data-hx-target="#customer-field" data-hx-swap="outerHTML"> <button class="hc-button" type="button" aria-haspopup="dialog" aria-label="Search customers" data-hx-get="/customers/lookup" data-hx-target="#lookup-root" data-hx-swap="innerHTML">🔍</button> </div> <p class="hc-field__hint">Acme Trading K.K.</p> <input type="hidden" name="customer_id" value="cus_9f2"></div>
<div id="lookup-root" data-hc-remote-dialog-root></div>The rules that make it sound:
- Two fields, one truth. The code is what users see; the hidden id (an opaque token — composite keys fold into it, per the snapshot pager key rule) is what submits; the display name is presentation and never submits.
- An unresolved code means an empty id. The classic defect is a
stale id riding under a corrected code — every unresolved response
clears the hidden input, and
hc validateinsists the whole field swaps as one (outerHTML) so code, hint, and id can never drift. - The consuming endpoint re-validates on submit anyway. The id is client-supplied; it means nothing by itself.
Resolve (direct entry)
Section titled “Resolve (direct entry)”change GETs /resolve?customer_code=… and the whole field comes
back:
| Input | Response |
|---|---|
| known code | 200 — hint = name, id filled, code normalised (case/width) to its canonical form |
| unknown code | 422 — aria-invalid, message replaces the hint, id empty, raw code echoed |
| empty code | 200 — cleared; required-ness is the submit endpoint’s business |
The 422 needs the standard one-line beforeSwap allowance from
mutating form.
The dialog
Section titled “The dialog”A remote dialog
carrying a live search
and a result list. Each selectable row is a button targeting
#customer-field with outerHTML — picking re-renders the field
directly, and the successful request closes the dialog
(data-hc-close-dialog-on-success). The search form inside the dialog
must opt out with data-hc-close-dialog-on-success="false" — the
nearest carrier wins, and without the opt-out the first debounced
keystroke’s 200 would dismiss the dialog before a row can be picked.
Two stances worth copying:
- Inactive masters render visible but refused —
aria-disabled="true", no wiring, the reason in the row (“inactive since 2026-04”). Visible-but-refused beats silently missing, the same stance as the result cap banner. - Authorization is per row — never list masters the user may not reference.
Progressive enhancement
Section titled “Progressive enhancement”Without JavaScript the field is a plain code input: the 🔍 button is
type="button" and inert, and validation happens at submit time —
which the consuming endpoint performs regardless. No dead ends, one
fewer convenience.
Accessibility
Section titled “Accessibility”- The 🔍 button needs
aria-haspopup="dialog"and anaria-label— an icon is not a name. - Unresolved =
aria-invalid+ an adjacent text message; resolved = the name in the field’s own hint line. - The dialog titles itself; the results are plain buttons in a list.
- Free text + reference in one field is this recipe with a nullable id — the contract only insists the code and id never disagree.
- Multi-select references are transfer territory.
- For a small, local list,
hc-comboboxtypeahead is lighter; this recipe earns its dialog when the master is large or browsed by several columns.
Related
Section titled “Related”- Postal address — the one-way lookup (code → fields, no dialog).
- Remote dialog and Live search — the two parts this composes.
- Field errors — the error-marking vocabulary the 422 reuses.