Form Error Summary
FreeThe list of what is wrong at the top of a long form, with a way into each field — including the ones a collapsed group or a wizard step is currently hiding.
import { SvFormErrorSummaryComponent } from '@styloviz/forms';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
The field the form is hiding
This is the defect it exists for. A long form fails validation, the invalid field is inside a collapsed accordion group or on a wizard step the user has moved past, and so nothing on screen looks wrong — the form simply refuses to go, with no way to find out why. Activating an entry emits `(errorActivated)` FIRST, which is the host's chance to open the group, and focus lands on the field a frame later.
<sv-form-error-summary [errors]="errors()" (errorActivated)="reveal($event)" /> reveal(e: SvFormError): void { if (e.groupId === 'billing') this.billingOpen.set(true); }
Errors in reading order, and only after a submit
The list is a map of the page. One whose entries follow whatever order the validators happened to run in is a map nobody can follow — so order them as they appear. And keep it empty until the user has attempted something: a summary on a pristine form tells them they have made mistakes they have not yet had the chance to make.
readonly errors = computed<SvFormError[]>(() => { if (!this.submitted()) return []; const out: SvFormError[] = []; if (!this.name().trim()) out.push({ fieldId: 'name', label: 'Full name', message: 'Enter your name' }); if (!this.postcode().trim()) { out.push({ fieldId: 'postcode', label: 'Postcode', message: 'Enter a postcode', groupId: 'billing' }); } return out; });
A server rejection, and the recheck it arrives from
Not every problem is a field's. "Your card was declined" and "your session expired" reject the SUBMISSION, so they have no control to focus — omit `fieldId` and the entry is listed as text rather than as a link that could only report that it went nowhere. It still counts, because the form is refusing to submit over it. While the answer is being recomputed, `busy` marks the list as the last attempt's verdict (`aria-busy` plus a note) and HOLDS the announcement, so the reader hears the outcome instead of a count that was obsolete when it was spoken.
<sv-form-error-summary [errors]="errors()" [busy]="submitting()" /> async submit(): Promise<void> { this.submitting.set(true); const rejected = await this.api.pay(this.form.value); // 402 this.serverErrors.set( rejected ? [{ label: 'Payment', message: 'Your card was declined' }] : [], ); this.submitting.set(false); // the summary speaks now, once }
The second failed submit
`autoFocus` moves focus here when the summary appears — that is, when the list goes from empty to non-empty. A repeat submit with the same errors is not a transition, so nothing moves and the user is left wondering whether the button worked. `focusSummary()` is the reliable half of the pair; call it from the submit handler.
@ViewChild(SvFormErrorSummaryComponent) summary!: SvFormErrorSummaryComponent; submit(): void { this.submitted.set(true); this.summary.focusSummary(); // safe to call blind — a no-op with no errors }
When to use
- Any form long enough that an invalid field can be off-screen — which in practice is most forms past about six fields.
- Any form with collapsible groups, wizard steps or tabs, where a field can be invalid AND not rendered. This is the case a per-field message alone cannot cover.
- A short, always-visible form does not need one. Two fields both on screen with their own messages are already reachable, and a summary above them is a second copy of what is already said.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| errors | readonly SvFormError[] | [] | Every invalid field, in the order they appear in the form. Reading order, not validation order. The list is a map of the page, and a map whose entries are in the order the validators happened to run is a map the reader cannot follow. Pass an empty array while the form is untouched: a summary that appears before the user has attempted anything tells them they have made mistakes they have not yet had the chance to make. |
| heading | string | '' | The heading above the list. `{n}` is replaced with the number of problems. |
| headingLevel | 2 | 3 | 4 | 2 | Heading level, so the summary sits correctly in the page outline. It renders a real `<h2>`–`<h4>`, not a styled `<div>`. A screen-reader user navigating by heading is the reader most likely to be looking for this, and a `<div>` with big text is invisible to that. |
| autoFocus | boolean | true | Move focus here when the summary appears — that is, when the error list goes from empty to non-empty. ⚠️ **A second failed submit with the same errors is not a transition**, so this will not fire for it, and the user is left where they were with no indication anything happened. Call focusSummary from your submit handler for that case; it is the reliable half of this pair, and `autoFocus` is the one that means you usually do not have to remember. |
| busy | boolean | false | The list is being rechecked — an async validator or a server round-trip is in flight. WHAT IS ON SCREEN DURING A SERVER SUBMIT IS THE LAST ATTEMPT'S VERDICT, and a summary that asserts it while the answer is being recomputed is stating something it does not currently know. This marks it as such: `aria-busy`, a visible note, and — the part that matters most — every announcement is HELD rather than skipped, so the reader hears the outcome of the recheck instead of a count that was already obsolete when it was spoken. |
| busyMessage | string | 'Rechecking…' | The note shown while busy. |
| customClass | string | '' | Extra classes on the root region. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| errorActivated | SvFormError | An entry was activated. Emitted **before** the focus attempt, so a host can open the accordion group, switch the wizard step or select the tab that contains the field. |
| reachFailed | { error: SvFormError; reason: SvFormErrorReachFailure } | The field could not be reached even after `(errorActivated)` had a frame to reveal it — `'not-found'` when nothing has that id, `'not-focusable'` when something does but neither it nor anything inside it can take focus. Almost always a wrong `fieldId`. Worth logging. |
Customization & theming
- `heading` overrides the wording, with `{n}` substituted for the count; the default pluralises between "There is a problem" and "There are N problems".
- `headingLevel` (2–4) places it in the page outline — it renders a real heading element, not a styled div.
- `autoFocus` (on by default) moves focus here when the summary appears; `focusSummary()` does it on demand for a repeat submit.
- `customClass` adds classes to the region.
- `busy` marks the list as being rechecked: `aria-busy` on the region, a `busyMessage` note (default "Rechecking…"), and every announcement held until the recheck lands.
- An entry with no `fieldId` is a submission-level problem — rendered as text, counted in the heading, and never a jump that could fail.
Best practices
- Do: Word `label` exactly as the user sees it above the field. `contact.email` tells a reader nothing about which of the two email fields is wrong, and a summary that cannot be matched to a field by eye is a list of complaints rather than a way in.
- Do: Use the same `message` the field itself shows. Two wordings for one problem read as two problems.
- Do: Handle `(errorActivated)` whenever a field can be hidden — it is the only moment the host gets to open the group before focus is attempted.
- Do: Pass errors in reading order, and only after a submit attempt.
- Do: Leave `fieldId` off a problem that is not a field's — a declined card, an expired session, a form already submitted. The alternative hosts reached for was inventing an id, which turns the entry into a link that reports "(could not jump to this field)" and tells the user the page is broken on top of the error they already have.
- Do: Set `[busy]` while an async validator or a server round-trip is in flight. What is on screen during a submit is the LAST attempt's verdict, and a summary that asserts it is stating something it does not currently know.
- Don't: Don't reach for an `<a href="#field-id">` instead. An anchor can only reach an element that is in the DOM, and the fields this component exists for are exactly the ones that are not.
- Tip: Log `(reachFailed)`. It fires when nothing has that `fieldId` even after the reveal, which is almost always a typo in the id — and the entry says so on screen rather than doing nothing.
Accessibility
- A documented WCAG technique (G139, and the error-identification half of 3.3.1) rather than an invention: the list at the top of the form, each entry linking to its field.
- It is a focusable region (`tabindex="-1"`) named by its own heading, NOT a `role="alert"`. An alert interrupts, and this list changes on every submit — the reader would get the whole thing re-announced over the top of the field they had just reached. Focus arrives once instead, announcing the heading and the count, and the reader moves through at their own pace.
- Entries are buttons, not anchors, because the fields worth linking to are the ones not currently in the DOM. `(errorActivated)` fires first so the host can reveal the field, and focus is attempted on the next frame.
- A target that is not itself focusable — a `<fieldset>` holding a required radio group, say — falls through to the first focusable control inside it. Sending the user to the group and leaving focus on `<body>` would be the same silent nothing the component exists to prevent.
- Focus uses `preventScroll` and then centres the field deliberately, so it arrives with its label and its message both visible rather than scrolled twice to the nearest edge.
- A list that CHANGES while the reader is elsewhere on the page is announced politely through the kit's one announcer — a server error joining a list already on screen was otherwise known only to someone looking at it. The announcement and the focus move are exclusive (focusing the region already reads the heading and the count), a burst of async validators supersedes itself under one key rather than queueing counts the user has passed, and a change that does not change the count says nothing.
- An entry whose field could not be reached says so, and emits `(reachFailed)`. Silence there is worse than no summary: the user has been told where the problem is and that they cannot go to it, with no way to tell a broken link from a field that is merely off-screen.
Feedback on Form Error Summary
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.