Lesson 05 / 25
Form Patterns
The criterion for grouping fields, focus and announcement management in a multi-step form, save and undo behavior, and comparing two layouts with a rule check.
Contents
The previous four lessons specified components one at a time. The borrowing form, though, carries a button, three fields, a radio group, and a dropdown list; even if each one is built correctly on its own, the form as a whole may not work. Which group a field belongs to, how a step change is announced in a form split into three steps, and whether submission can be undone are not written in any component specification.
This lesson specifies the form as a whole. What is measured is the defects that individually correct components can produce together.
The Splitting Criterion
A form is not split because it has many fields. The splitting criterion is that the fields form decision sets independent of one another: in the borrowing form, record selection, member information, and the duration decision are separate sets; each can be validated on its own, and the user does not move to the next without completing one.
When the criterion is not met, splitting causes harm. Spreading a five-field correction-report form across three pages keeps the user from seeing all of it at once and adds a burden that requires focus management at every transition. Short forms stay in a single view.
Field Grouping
The criterion for grouping is that the fields answer a shared question. “Return date” and “Borrow duration” answer a shared question; “Membership number” and “Borrow duration” do not. Standing close together on screen is not a grouping justification.
Fields that share a question are wrapped with fieldset and legend; the group’s name
is the question itself. A group carrying only one field is not built — that field’s
label is already the question, and an additional group name doubles the length of the
announcement.
<h2 id="step-2" tabindex="-1">Member information</h2> <p id="step-status" role="status">Step 2 / 3</p> <form action="/borrow/step-2" method="post"> <fieldset> <legend>Notification method</legend> <p><label><input type="radio" name="notification" value="email" checked> Email</label></p> <p><label><input type="radio" name="notification" value="none"> No notification</label></p> </fieldset> <p><label for="member-no">Membership number</label> <input id="member-no" name="member-no" required inputmode="numeric" aria-describedby="member-help"> <span id="member-help">The 8-digit number on the card.</span></p> <p><button type="submit">Next</button> <button type="button" name="back">Back</button></p> </form>
The order and type of the buttons are part of the specification. The “Next” button is
the form’s first submit button; a user who presses Enter while in a text field runs
this button. The “Back” button carries type="button"; if it did not, implicit
submission would take the form backward, and the user would lose a step.
A Step Change Is Not a Page Change
In a transition made by refreshing the page, focus returns to the start of the document, and the user enters the new document from the top. In a transition made without refreshing the page, nothing happens on its own: the content on screen changes, focus stays on the old button, and for a user who cannot see the screen, it looks as if nothing happened.
The specification requires three things: focus moves to the new step’s heading, the
transition is announced with a polite live region, and the step indicator states which
step it is in text. The heading carrying tabindex="-1" is so it can take focus without
entering the tab order.
The script below runs the same scenario under two layouts and checks four rules.
// form.mjs — auditing focus management, announcements, and value preservation in a multi-step form const STEPS = [ { name: "Record confirmation", heading: "step-1", fields: [ { id: "record", label: "Record number", required: true, rule: (v) => /^K-\d{3}$/.test(v), message: "Record number must be in the K-000 format." }, ] }, { name: "Member information", heading: "step-2", fields: [ { id: "member-no", label: "Membership number", required: true, rule: (v) => /^\d{8}$/.test(v), message: "Membership number must be 8 digits." }, { id: "email", label: "Notification address", required: false, rule: (v) => v === "" || v.includes("@"), message: "Enter a valid email address." }, ] }, { name: "Duration and confirmation", heading: "step-3", fields: [ { id: "duration", label: "Borrow duration", required: true, rule: (v) => ["14", "21", "28"].includes(v), message: "Select a borrow duration." }, ] }, ]; const initial = () => ({ step: 0, values: {}, errors: [], focus: "input#record", announcement: "", done: false, }); function validate(step, values) { const h = []; for (const a of STEPS[step].fields) { const v = values[a.id] ?? ""; if (a.required && v === "") h.push([a.id, a.label, "This field is required."]); else if (v !== "" && !a.rule(v)) h.push([a.id, a.label, a.message]); } return h; } // layout: movesFocus, announcesTransition, preservesValues function machine(layout) { const focusTo = (target, previous) => (layout.movesFocus ? target : previous); const announce = (m) => (layout.announcesTransition ? m : ""); return (d, e) => { if (e.kind === "input") return { ...d, values: { ...d.values, [e.id]: e.value } }; if (e.kind === "back") { if (d.step === 0) return d; const y = d.step - 1; return { ...d, step: y, errors: [], values: layout.preservesValues ? d.values : {}, focus: focusTo("h2#" + STEPS[y].heading, d.focus), announcement: announce(`Step ${y + 1} / ${STEPS.length}: ${STEPS[y].name}`) }; } if (e.kind === "next" || e.kind === "submit") { const errors = validate(d.step, d.values); if (errors.length > 0) { return { ...d, errors, focus: focusTo("div#error-summary", d.focus), announcement: announce(`${errors.length} field(s) need correction`) }; } if (e.kind === "submit" && d.step === STEPS.length - 1) { return { ...d, errors: [], done: true, focus: focusTo("h2#result", d.focus), announcement: announce("Borrow request recorded") }; } const y = Math.min(d.step + 1, STEPS.length - 1); return { ...d, step: y, errors: [], focus: focusTo("h2#" + STEPS[y].heading, d.focus), announcement: announce(`Step ${y + 1} / ${STEPS.length}: ${STEPS[y].name}`) }; } return d; }; } const SCENARIO = [ { kind: "input", id: "record", value: "K-118" }, { kind: "next" }, { kind: "input", id: "member-no", value: "1234" }, { kind: "next" }, { kind: "input", id: "member-no", value: "10045512" }, { kind: "next" }, { kind: "back" }, { kind: "next" }, { kind: "input", id: "duration", value: "21" }, { kind: "submit" }, ]; const FOCUSABLE = new Set([ "input#record", "div#error-summary", "h2#step-1", "h2#step-2", "h2#step-3", "h2#result", ]); function run(heading, layout, logTrace) { const next = machine(layout); let d = initial(); const trace = []; console.log(heading); if (logTrace) console.log(" action".padEnd(24) + "step".padEnd(31) + "focus".padEnd(20) + "announcement"); for (const e of SCENARIO) { const previous = d; d = next(d, e); trace.push({ e, previous, d }); if (!logTrace) continue; const label = e.kind === "input" ? `input ${e.id}` : e.kind; const stepName = d.done ? "result" : `${d.step + 1}/${STEPS.length} ${STEPS[d.step].name}`; console.log(" " + label.padEnd(22) + stepName.padEnd(31) + d.focus.padEnd(20) + (d.announcement || "-")); } const findings = []; for (const { e, previous, d: s } of trace) { if (e.kind === "input") continue; if (s.focus === previous.focus && s.step !== previous.step) findings.push(`${e.kind}: step changed, focus did not move`); if (s.errors.length > 0 && s.focus !== "div#error-summary") findings.push(`${e.kind}: error present, focus did not move to the summary`); if (!FOCUSABLE.has(s.focus)) findings.push(`${e.kind}: focus target is not focusable (${s.focus})`); if (s.announcement === "") findings.push(`${e.kind}: transition was not announced`); for (const k of Object.keys(previous.values)) { if (previous.values[k] !== s.values[k]) findings.push(`${e.kind}: value of the ${k} field was lost`); } } console.log(` findings: ${findings.length}`); for (const b of findings) console.log(" " + b); return trace; } run("flawed layout (focus does not move, transitions are not announced, values are cleared going back):", { movesFocus: false, announcesTransition: false, preservesValues: false }, false); console.log(""); const trace = run("fixed layout:", { movesFocus: true, announcesTransition: true, preservesValues: true }, true); const failed = trace.find(({ d }) => d.errors.length > 0).d; console.log("\nsummary links in the failed transition:"); for (const [id, label, message] of failed.errors) { console.log(` <a href="#${id}">${label}: ${message}</a>`); }
flawed layout (focus does not move, transitions are not announced, values are cleared going back):
findings: 14
next: step changed, focus did not move
next: transition was not announced
next: error present, focus did not move to the summary
next: transition was not announced
next: step changed, focus did not move
next: transition was not announced
back: step changed, focus did not move
back: transition was not announced
back: value of the record field was lost
back: value of the member-no field was lost
next: error present, focus did not move to the summary
next: transition was not announced
submit: error present, focus did not move to the summary
submit: transition was not announced
fixed layout:
action step focus announcement
input record 1/3 Record confirmation input#record -
next 2/3 Member information h2#step-2 Step 2 / 3: Member information
input member-no 2/3 Member information h2#step-2 Step 2 / 3: Member information
next 2/3 Member information div#error-summary 1 field(s) need correction
input member-no 2/3 Member information div#error-summary 1 field(s) need correction
next 3/3 Duration and confirmation h2#step-3 Step 3 / 3: Duration and confirmation
back 2/3 Member information h2#step-2 Step 2 / 3: Member information
next 3/3 Duration and confirmation h2#step-3 Step 3 / 3: Duration and confirmation
input duration 3/3 Duration and confirmation h2#step-3 Step 3 / 3: Duration and confirmation
submit result h2#result Borrow request recorded
findings: 0
summary links in the failed transition:
<a href="#member-no">Membership number: Membership number must be 8 digits.</a>
The flawed layout produces fourteen findings, and two of them are cascading defects: because values are cleared going back, the next two transitions also fail to pass validation. The only thing visible on screen is “the form going blank when you go back”; the remaining twelve findings never appear on screen.
Two rows in the fixed run’s trace need attention. In the fourth row, validation fails and focus goes to the error summary; the step number does not change. In the seventh row, going back is also a step change and requires the same focus and announcement handling as going forward; leaving the backward direction silent is a commonly skipped case.
Keyboard Contract
| Key | Behavior |
|---|---|
Tab / Shift+Tab |
Moves between fields in source order. |
Enter (single-line field) |
Runs the form’s first submit button. |
Enter (multi-line field) |
Inserts a line break; does not submit the form. |
Enter (on a button) |
Runs the focused button. |
The contract’s constraint is which button implicit submission runs. If the form’s
first submit button is “Back,” a user who presses Enter in a field falls back a step.
Every button that does not carry submission behavior is written with type="button".
Saving and Undo
A borrow request produces a record; it is a binding operation on the user’s own data. The 3.3.4 Error Prevention (Legal, Financial, Data) criterion requires at least one of three routes for this kind of submission: submission has to be reversible, entered data has to be checkable and correctable, or it has to be confirmed before submission.
In the three-step form, the second route is established on its own: the last step shows all entered values as a summary and carries a link back to the relevant step next to each row. It is not enough for the summary to be plain text; the user has to be able to reach the field they want to correct with an action.
Two additional rules are written into the specification. Leaving the page while unsaved changes exist is met with a warning; when the warning is a modal dialog, it requires focus management, and this is the next topic’s responsibility. If there is a session timeout, 2.2.1 Timing Adjustable comes into play: the user has to be able to extend the time, or the data has to be preserved. A twenty-minute session silently wiping a long form removes this criterion.
Measurable Constraints
3.3.2 Labels or Instructions requires a label or instruction on every field; 3.3.1 Error Identification requires the erroneous field to be identified in text; 3.3.3 Error Suggestion requires a correction suggestion to be given when known. The “Membership number must be 8 digits” message in the check is the counterpart of this third criterion: it does not stop at reporting the error, it states the correct format.
1.3.5 Identify Input Purpose requires the purpose to be declared programmatically on fields that collect the user’s own information; the name and notification address fields in the borrowing form fall within this scope.
2.4.6 Headings and Labels requires step headings to actually describe the content: “Step 2” is not a heading, “Member information” is the heading, and the step number is given in separate text.
Common Mistake
The step indicator being only visual. A filling bar or three dots that change color say nothing to a user who cannot see the screen. Catching it means checking whether the indicator’s text equivalent is present in the tree.
Leaving the error summary where it was built. When the summary is built but focus is not moved, the user cannot learn that submission failed. Catching it is the check’s second rule: whether focus moves to the summary when an error exists is tested.
Leaving the backward direction silent. The focus and announcement handling established for going forward is also required going back. Catching it means running both directions through the same rule set.
Building a single-field group. Wrapping one field in a fieldset announces the
field’s name twice, together with the group name. Catching it means counting the number
of fields inside the group.
Summary
- A form is split by independent decision sets, not by field count; short forms stay in a single view.
- The criterion for grouping is that fields answer a shared question; single-field groups are not built.
- A step change requires three operations: moving focus to the new step’s heading, announcing the transition with polite priority, and giving the step indicator in text. Going back is also a step change.
- The form’s first submit button runs on implicit submission; buttons that do not carry
submission behavior are written with
type="button". - 3.3.4 requires at least one of the routes of undo, correction, or confirmation for binding submissions; the correctable summary on the last step establishes the second route.
- Most of the findings the check produces are not visible on screen; focus and announcement defects surface only through rule testing.
Next Step
Every component in this topic ran an action: running a search, applying a filter, submitting a form. The record titles in the results table, by contrast, do a different job — they take the user to another resource. The two can be made to look alike on screen, but their keyboard contracts, their roles in the tree, and what the user expects from them differ. The next lesson ties this distinction to a rule and shows which concrete breakages the confusion produces.
To keep your progress and take notes, Log in
My notes
Log in to take notes.