Data entry form
The page every line-of-business app repeats: a long form whose data must arrive clean and must not get lost. This template composes the whole input stack — grouped amounts with raw wire values, IME normalization, a masked postal code that fills the address, debounced draft autosave, the unsaved-changes guard, request hygiene on the save button, and the shared error-dialog host for expired sessions and edit conflicts.
Live template
Section titled “Live template”Edit anything: the unsaved badge appears (dirty guard), pausing two seconds drafts the form (autosave), a complete postal code fills the address (lookup + OOB), and Save posts the record and turns the guard clean. Amounts regroup on blur; the wire stays raw.
Page skeleton
Section titled “Page skeleton”<!-- method/action keep the no-JS submit working (post/redirect/get). disinherit matters: htmx attributes inherit, and the postal field below issues its own lookup GET — without the disinherit it would inherit disabled-elt, find no submit button among its own descendants, and htmx would log an error on every lookup. hx-sync must be disinherited too: inherited, `this:abort` makes the form, the postal lookup and the autosave share ONE in-flight slot — a Save clicked during the 2 s draft tick would be silently dropped. --><form id="report-form" data-hc-dirty-guard method="post" action="/reports/42" data-hx-post="/reports/42" data-hx-sync="this:abort" data-hx-disabled-elt="find button[type=submit]" data-hx-disinherit="hx-disabled-elt hx-sync" data-hx-target="#report-errors" data-hx-swap="innerHTML">
<!-- The 422 field-errors fragment swaps in here; installFieldErrors distributes each item to the field it names. --> <div id="report-errors" aria-live="polite"></div>
<!-- Amount: grouped display, raw wire value --> <div class="hc-field"> <label class="hc-field__label" for="amount">Amount</label> <div class="hc-input-group"> <span class="hc-input-addon">¥</span> <input class="hc-input" id="amount" name="amount" type="text" inputmode="numeric" data-numeric data-hc-format="number" value="1,280,000"> </div> </div>
<!-- Codes: fullwidth leftovers self-correct --> <div class="hc-field"> <label class="hc-field__label" for="sku">SKU</label> <input class="hc-input" id="sku" name="sku" data-hc-normalize="ascii"> </div>
<!-- Postal → address (mask + lookup + OOB autofill) --> <div class="hc-field"> <label class="hc-field__label" for="postal">Postal code</label> <input class="hc-input" id="postal" name="postal" inputmode="numeric" placeholder="123-4567" pattern="\d{3}-\d{4}" data-hc-mask="postal-jp" aria-describedby="postal-result" data-hx-get="/address-by-postal" data-hx-trigger="change[target.value.length==8]" data-hx-include="this" data-hx-target="#postal-result"> <p class="hc-field__hint" id="postal-result" aria-live="polite"></p> </div> <div class="hc-field"> <label class="hc-field__label" for="pref">Prefecture</label> <input class="hc-input" id="pref" name="pref" autocomplete="address-level1"> </div> <!-- #city / #addr1 follow the same shape -->
<!-- Autosave: a request-owning div drafts the whole form --> <div data-hx-post="/reports/42/draft" data-hx-include="closest form" data-hx-trigger="input from:closest form changed delay:2s" data-hx-target="#draft-status" data-hx-swap="innerHTML"></div> <p class="hc-field__hint" id="draft-status" aria-live="polite"></p>
<button class="hc-button" data-variant="primary" type="submit">Save</button></form>
<!-- One shared host: 401 login dialogs and 409 conflict dialogs land here --><div id="error-dialog" data-hc-remote-dialog-root data-hc-session-expiry></div>
<script type="module"> document.body.addEventListener('htmx:beforeSwap', (event) => { if ([401, 409, 422].includes(event.detail.xhr.status)) { event.detail.shouldSwap = true; event.detail.isError = false; } });</script>Wiring map
Section titled “Wiring map”| Region | Component / behavior | Recipe | Contract |
|---|---|---|---|
| Amount field | installFormat | — | client-only (formdata rewrite) |
| SKU field | installNormalize | — | client-only |
| Postal → address | installMask | postal-address | postal-address/contract.md |
| Draft autosave | — (pure htmx) | autosave | autosave/contract.md |
| Unsaved guard | installDirtyGuard | unsaved-changes | unsaved-changes/contract.md |
| Save hygiene | data-hx-sync + data-hx-disabled-elt | — | Errors & recovery |
| Session expiry | installSessionExpiry | session-expiry | session-expiry/contract.md |
| Edit conflicts | — (version field) | edit-conflict | edit-conflict/contract.md |
| Field errors | installFieldErrors | field-errors | field-errors/contract.md |
Adapt it
Section titled “Adapt it”- Rename the endpoints.
/reports/42and/address-by-postalare placeholders — each region only needs its linked contract. - Add the version field (
<input type="hidden" name="version">) when the record can be edited concurrently — the edit-conflict dialog rides the same error host that is already on the page. - Real CSRF:
<meta name="csrf-token">+installCsrfHeader()— replays after re-login pick the rotated token up automatically. - Validation stays on the server —
422+ the field-errors fragment; the allowance in the skeleton already lets it swap. - Drop draft-sensitive fields server-side (
type="password") per the autosave contract.