Prototype Library

Error Summary

The form-level validation summary: a focusable panel above the form listing every problem, each one a link to the field that has it.

Inherited wc import '@esa/ecology/esa-error-summary';

Preview

Renders nothing until it has errors. Set errors, then call focus() — that is what tells the user something went wrong.

<esa-error-summary id="summary"></esa-error-summary>

<script>
  summary.errors = [
    { field: 'project-name', message: 'Enter a project name.' },
    { field: 'pathway', message: 'Select a review pathway.' },
  ];
  summary.focus();   // ← the announcement
</script>

Why a summary, and why it takes focus

Inline error-text on each field is necessary and not sufficient. When the user presses Submit their focus is on the button, usually at the bottom of the form. Errors are added above it, off-screen, and nothing announces them: a screen reader is on one element at a time and hears nothing about a mutation elsewhere; someone at high page zoom sees only the region around the button; anyone on a long form is scrolled past the fields that failed. All three get a form that appears to do nothing when pressed.

This is the W3C's advisory technique for both SC 3.3.1 (Error Identification) and SC 3.3.3 (Error Suggestion), and it is what GOV.UK ships on every form — including single-field ones, so the pattern is consistent rather than conditional.

It is deliberately not a live region, and adding role="alert" would be a regression. Moving focus is a change of context that assistive technology already surfaces, so SC 4.1.3 does not apply — that criterion exists for updates which happen without taking focus. Focus is also more robust: an announcement is transient and cannot be replayed, whereas a focused summary is still sitting there when the user wants to re-read it.

The alternative scales badly. Turn on live-error for every field and a six-error form fires six assertive announcements at once; assertive updates can clear each other's queue, so the user hears some subset, in no guaranteed order, while their focus is somewhere else. One summary, one focus move, one reading order — and it carries what a per-field announcement cannot: how many problems there are, and a route to each.

Wiring it to a form

Put novalidate on the form to suppress the browser's own error bubbles — they are unstyleable, they do not respect page zoom, and Chrome dismisses them after about five seconds, which can be while the user is still reading. Then intercept submit, collect what failed, and set both the summary and each field's error-text.

<form id="intake" novalidate>
  <esa-error-summary id="summary"></esa-error-summary>

  <esa-text-field id="name" label="Project name" required></esa-text-field>
  <esa-text-field id="email" label="Contact email" type="email"
                  autocomplete="email" required></esa-text-field>

  <button type="submit">Save and continue</button>
</form>

<script>
  intake.addEventListener('submit', (e) => {
    e.preventDefault();
    const failed = [];

    for (const f of [name, email]) {
      const bad = !f.checkValidity();
      // Same wording in both places.
      f.errorText = bad ? f.validationMessage : '';
      if (bad) failed.push({ field: f.id, message: f.validationMessage });
    }

    summary.errors = failed;
    summary.focus();               // no-op when failed is empty
    if (!failed.length) intake.submit();
  });
</script>

Submit the form above empty, then with a malformed email. Focus lands on the summary, and each link puts you on the field it names — including the input inside that component's shadow root, which the summary reaches by calling focus() on the host rather than by an IDREF that could never cross the boundary.

API

PropTypeDefaultDescription
errors EsaFieldError[] [] The problems to list. Each entry is { field, message }: `field` is the id of the HOST element to send the user to, `message` is what is wrong in the user's words. Omit `field` for a form-level error ("Your session expired") and it renders as plain text rather than a link. Renders nothing when empty, so it is safe to leave in the DOM.
heading string 'There is a problem' Heading text. The error count is appended for you — do not put a number here, or it will be the thing that disagrees with the list underneath it.
heading-level number 2 Which heading element to render, 2–6. A summary sits inside a form under whatever heading introduces it, so h2 is usually right — but heading order is a document-level concern the component cannot see, and skipping a level is its own failure (SC 1.3.1). Property: `headingLevel`.

Accessibility

  • tabindex="-1" with role="group" and aria-labelledby pointing at the heading — a bare <div> is name-prohibited, so without the role the container would announce nothing when it takes focus.
  • The focus ring uses :focus, not :focus-visible. This is one of the few places that is correct: the user was sent here by a failed submit rather than clicking, and :focus-visible heuristics can decide not to paint — which would move a sighted keyboard user with no indication of where they went.
  • The count is inside the heading, so it is announced when focus lands. It is the single most useful thing to say at that moment, and a separate paragraph below would not be read.
  • Items are a real <ul>, so the list role reinforces the count.
  • Links are underlined, not just coloured — the panel sits on a tinted danger ground where a colour shift alone would be the SC 1.4.1 failure.
  • Each link resolves its field via getRootNode(), so a summary nested inside a spoke's own shadow root still finds fields in that same root.
  • Pair it with, never instead of, error-text on each invalid control. The summary says what failed; the inline message sits where the fixing happens. Word them the same.