GitHub

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.

Stable wc import '@esa/ecology/esa-error-summary'; Angular usage

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.

Theming surface

Every public token this component reads, extracted from its source at build time, split by blast radius: the hooks that belong to this component, then the shared and system tokens it consumes alongside everything else. The Resolves to column walks each token's real lineage — component → semantic → primitive → raw value — so you can see exactly where re-pointing it lands. A spoke re-skins by overriding these in its theme-<slug>.css; component internals are never edited. No hook for what you need? File it with /request-lego.

Type roles it names

This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.

RoleRe-pointReach
.typography-label-md-strong
  • --typography-label-md-strong-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-md-strong-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-label-md-strong-font-weight 550 via --typography-font-weight-semibold → --font-weight-550
  • --typography-label-md-strong-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-md-strong-line-height 1.6 via --line-height-normal
+3 more components esa-avatar, esa-empty-state, esa-header-nav

Wired to this component

None. This component has no hooks of its own — it themes entirely through the shared tokens below, so every override here also moves its siblings.

Shared tokens it reads

Owned by a family or by the semantic layer. Still overridable — but scope the override (esa-foo { --form-border-color: … }) unless you mean to move everything that reads it. Reach says what else that is.

Token Reach Resolves to
--focus-ring-color component focus ring group · +34 more #3e9b4f via --color-border-default-focus → --color-grass-10
--focus-ring-offset component focus ring group · +31 more 2px
--focus-ring-width component focus ring group · +35 more 2px via --border-width-focus → --border-width-200
--border-width-default semantic every component 1px via --border-width-100
--color-background-utility-danger-subtle semantic every component #fffcfc via --color-red-1
--color-border-utility-danger semantic every component #fdbdbe via --color-red-6
--color-content-default semantic every component #202020 via --color-gray-12
--color-content-utility-danger semantic every component #ce2c31 via --color-red-11
--radius-md semantic every component 0.5rem via --radius-200
--spacing-100 primitive every component 0.25rem
--spacing-200 primitive every component 0.5rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem