busy-office-ui
Menu

Pattern: inline validation summary

Who uses it: anyone filling a form with more than a couple of required fields — worth it the moment a reader could miss which field failed. What "done" looks like: every problem named in one place AND on its field, with nothing to hunt for. On submit, if the form is invalid: list every problem field up top, move focus to that summary first (not straight into a field — a screen-reader user hears the overview before jumping in), and let each link focus its exact field. Composed entirely from existing components — the summary box is just .bo-alert bo-alert--danger, and every field's error state (red border, red label, required asterisk) comes free from the existing :user-invalid styling already in .bo-form-field. Try it: submit the empty form below. Not for per-row saves — in an editable grid each row saves on its own (PATCH per line, 422 → that row re-rendered with cell errors), so a failure comes back on the row the user just acted on and a summary would only restate it. The summary earns its place on a document-level submit — posting the whole form — where several fields can fail at once, including some scrolled out of view.

New purchase order
Delivery instructions
Internal notes

The last two fields sit inside a closed <details> and a collapsed card on purpose: each still blocks submit, so the summary lists it, and following that entry opens the container before focusing the field. Collapsing a required field does not excuse it.

Markup

<form data-validation-summary novalidate>
  <div class="bo-alert bo-alert--danger" data-validation-summary-box hidden role="alert">
    <p>There is a problem</p>
    <ul></ul>  <!-- populated on an invalid submit -->
  </div>
  <div class="bo-form-field">
    <label class="bo-form-field__label" for="vendor">Vendor</label>
    <input class="bo-input" id="vendor" required />
  </div>
  ...
</form>

<script type="module">
  import { initValidationSummary } from "@busy-office/ui/js";
  initValidationSummary(); // once — wires the [data-validation-summary] form
</script>

novalidate is required, not optional. Without it the browser's own interactive validation stops an invalid submit before the submit event fires, so this behavior never runs at all — measured: the summary stays hidden and lists nothing. If the invalid field also sits inside a hidden container the browser cannot focus it either, so the form refuses to submit with no visible reason and only a console error.

Field labels come from each field's own <label for> text — no extra markup needed to name a field in the summary list.

A field hidden inside a tab, a <details> or a collapsed card still blocks submit, so the summary lists it — and following its link opens whatever is hiding it before moving focus, activating the tab so aria-selected stays correct. That reveal is exported as reveal(el) for any other in-page link that has to land on a field the user cannot currently see.

If your app boosts forms with htmx's hx-boost (as this docs site does — found live while building this demo): add hx-boost="false" to any [data-validation-summary] form. Without it, htmx's own submit interception races this behavior's — htmx fetches and swaps the page before (or instead of) the summary rendering, so the failed-validation state never sticks.

Anatomy

  1. Summary alert — at the top of the form, listing every error as a link to its field.
  2. Field-level messages — the same text beside the control, wired with aria-describedby.
  3. Focus move — on submit failure focus goes to the summary, so screen-reader users hear the count first.
  4. Live region — the summary is role="alert", so a dynamic failure announces without a page change.

Data contract

RequestReturns / on failure
POST /form (invalid)422 with a field→message map; re-render the SAME form with values preserved and both channels populated
POST /form (valid)redirect or the success fragment — never leave a stale summary on screen
client-sidenative constraint validation runs first; the server contract is identical either way

Two swap targets, one endpoint

A validating endpoint answers in two places: the 422 re-renders the form where the user is looking, while the success usually has to update something else — a timeline, a row, a list behind the dialog. Point hx-target at the form, and let the success response carry the other update out of band:

<!-- The button posts and swaps the FORM area -->
<button hx-post="/po/88213/reject" hx-target="#dialog-body" hx-swap="innerHTML">Reject</button>

<!-- 422: the form comes back, values kept, error wired to the field -->
<div class="bo-alert bo-alert--danger" role="alert" id="err">A reason is required.</div>
<div class="bo-richtext__content" contenteditable="true"
     aria-invalid="true" aria-describedby="err">…</div>

<!-- 200: the dialog body gets a confirmation, and the timeline OUTSIDE
     the dialog updates out of band in the same response -->
<p>Rejected — reason recorded.</p>
<ol id="timeline-88213" hx-swap-oob="outerHTML" class="bo-timeline">…</ol>

Verified by dogfooding the reference app (2026-08-17): without the out-of-band swap the success response replaced the dialog body with a timeline and the real timeline never changed — the action appeared to do nothing. A 422 reaches the user with no app code either — htmx 4 swaps non-2xx responses by default (see using with htmx).

Document-level messages — the one thing the summary is not for

Some messages are about no field at all. "Posting period closed", "this vendor is on hold", "the month-end run is in progress" — there is nothing for the user to retype, so listing them in a field summary sends them hunting for an input that does not exist. Those get a strip at the top of the document instead:

Posting period 2026-07 is closed. This purchase order can be edited and saved, but not posted until the period reopens or a controller reverses the close.

That is an ordinary alert placed in the document header — no new component, no new class. A strip earns its place by being about the document; the moment a message names a field, it belongs on that field and in the summary above.

The test, and why it stays this way

  • Can the user fix it by changing a value on this screen? Then it is a field error — attach it to the field, list it in the summary. "Vendor is blocked" is a field error even though only the server could know it.
  • Is it a condition of the document or the system? Then it is a strip: it explains why an action is unavailable, and the user's next step is elsewhere (a different period, a different approver, waiting).
  • Say what is still possible. A strip that only says "closed" invites a support call; one that says "you can still save, just not post" does not.
  • One strip, not a feed. If two document-level conditions hold, the one that blocks the user's likely next action goes first — and if you find yourself needing scroll, paging or filters in here, the screen is doing too much. That is the signal to split the screen, not to build a message centre.

Deliberately not a message centre. A collected inbox of everything the server wanted to say is what a screen grows when errors sit far from where the user is looking. Field-first keeps them where the eyes already are; a document strip is the narrow exception for messages that have no field to sit on. The framework ships no message-manager component, and that is a boundary rather than a gap.

States

StateWhat ships
No errorsthe summary is absent from the DOM, not hidden — an empty alert region confuses assistive tech
One errorstill use the summary; the consistent path beats a special case
Many errorslist all of them, each linking to its field; do not truncate
Server-only errore.g. "vendor is blocked" — the summary is the ONLY place it can appear
Fixed as you goremove entries as fields become valid, but keep the summary until resubmit — the count is the user’s progress bar
Cross-field errorattach it to the field the user should change, not to a phantom "form" field
No JSthe form carries novalidate so JS can own validation — without initValidationSummary() that also disables the browser's own native constraint-validation UI, so submitting the empty form is not stopped client-side at all: it POSTs straight through with empty field values, and only the server's 422 (see Data contract) catches it

Components used

complexity 2 of 4

↑↓ move Enter open Esc close