Skip to content

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.

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…

<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 validate insists 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.

change GETs /resolve?customer_code=… and the whole field comes back:

InputResponse
known code200 — hint = name, id filled, code normalised (case/width) to its canonical form
unknown code422aria-invalid, message replaces the hint, id empty, raw code echoed
empty code200 — cleared; required-ness is the submit endpoint’s business

The 422 needs the standard one-line beforeSwap allowance from mutating form.

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

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.

  • The 🔍 button needs aria-haspopup="dialog" and an aria-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-combobox typeahead is lighter; this recipe earns its dialog when the master is large or browsed by several columns.