Skip to content

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.

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.

¥

Fullwidth digits normalize on blur; the wire value stays raw.

Try 123-4567 or 600-8216.

Pause two seconds after typing — the draft saves itself.

Unsaved changes

<!-- 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>
RegionComponent / behaviorRecipeContract
Amount fieldinstallFormatclient-only (formdata rewrite)
SKU fieldinstallNormalizeclient-only
Postal → addressinstallMaskpostal-addresspostal-address/contract.md
Draft autosave— (pure htmx)autosaveautosave/contract.md
Unsaved guardinstallDirtyGuardunsaved-changesunsaved-changes/contract.md
Save hygienedata-hx-sync + data-hx-disabled-eltErrors & recovery
Session expiryinstallSessionExpirysession-expirysession-expiry/contract.md
Edit conflicts— (version field)edit-conflictedit-conflict/contract.md
Field errorsinstallFieldErrorsfield-errorsfield-errors/contract.md
  • Rename the endpoints. /reports/42 and /address-by-postal are 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 server422 + 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.