Error Summary
When a submission stops, the user should learn why, and reach the field in one click. The server writes the heading and the list. The component does the two things the browser does not do by itself: it moves focus here when the box becomes visible, and it gives focus to the control itself when a link is followed.
// main.ts, runs once when the app startsimport { defineLtErrorSummary } from "@lo-tek/interfaces/error-summary"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/error-summary.css"
defineLtErrorSummary()How to use it
Section titled “How to use it”<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/error-summary/error-summary.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/label/label.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/input/input.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><form novalidate> <!-- The component sets the class, role="alert" and tabindex="-1". --> <lt-error-summary id="errors" hidden> <h2></h2> <ul class="lt-list"></ul> </lt-error-summary>
<label class="lt-label" for="email">Email address</label> <input class="lt-input" id="email" type="email" /> <button class="lt-button">Send application</button></form>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtErrorSummary } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/error-summary/lt-error-summary.js"
defineLtErrorSummary()</script>
<script type="module"> const box = document.getElementById("errors")
function showErrors(errors) { box.querySelector("ul").innerHTML = errors .map((e) => `<li><a href="#${e.id}">${e.message}</a></li>`) .join("")
// The heading and hidden are written here, as a server would. if (errors.length > 0) { box.querySelector("h2").textContent = errors.length === 1 ? "The form has one error" : `The form has ${errors.length} errors` } // Hidden and shown again, so focus also moves when the form fails // again while the box is already visible. box.hidden = true box.hidden = errors.length === 0 }
const email = document.getElementById("email")
document.querySelector("form").addEventListener("submit", (event) => { event.preventDefault()
const errors = email.value.includes("@") ? [] : [{ id: "email", message: "Enter an email address with an @" }]
showErrors(errors) })</script>The component moves focus here as soon as hidden is removed. The messages here are written in the script. If they come from a server or from the user, set them with textContent, not through innerHTML.
// src/lotek.d.ts, gives <lt-error-summary> types in JSXimport "@lo-tek/interfaces/react-jsx"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/error-summary.css"
import { lt } from "@lo-tek/interfaces/react"import { defineLtErrorSummary } from "@lo-tek/interfaces/error-summary"
defineLtErrorSummary()
type FormError = { id: string; message: string }
export function ErrorSummary({ errors }: { errors: FormError[] }) { const box = lt.errorSummary({ count: errors.length, id: "errors" })
return ( <lt-error-summary {...box.host}> <h2 {...box.title}> {errors.length === 1 ? "The form has one error" : `The form has ${errors.length} errors`} </h2> <ul {...lt.list()}> {errors.map((e) => ( <li key={e.id}><a href={`#${e.id}`}>{e.message}</a></li> ))} </ul> </lt-error-summary> )}---import { lt } from "@lo-tek/interfaces"
import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/error-summary.css"
const { errors } = Astro.propsconst box = lt.errorSummary({ count: errors.length, id: "errors" })---
<lt-error-summary {...box.host}> <h2 {...box.title}>The form has {errors.length} errors</h2> <ul {...lt.list()}> {errors.map((e) => <li><a href={`#${e.id}`}>{e.message}</a></li>)} </ul></lt-error-summary>
<script> import { defineLtErrorSummary } from "@lo-tek/interfaces/error-summary" defineLtErrorSummary()</script><!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/error-summary/error-summary.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtErrorSummary } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/error-summary/lt-error-summary.js"
defineLtErrorSummary()</script>
<lt-error-summary id="errors" hidden> <h2></h2> <ul class="lt-list"></ul></lt-error-summary>The server sends the whole box again when the form is validated, with the heading and the list filled in, and without hidden when there are errors. The component notices that the box became visible and moves focus there.
If the form fails again while the box is already visible, focus does not move, because the signal is that hidden is removed. Send two patches in the same response then: first the box with hidden, then the box without. The component sees that it was hidden and shown again, and moves focus. See The markup.
The markup
Section titled “The markup”const box = lt.errorSummary({ count: errors.length, id: "errors" })<lt-error-summary class="lt-error-summary" role="alert" tabindex="-1" id="errors"> <h2 class="lt-error-summary__title">The form has two errors</h2> <ul class="lt-list"> <li><a href="#email">Enter an email address with an @</a></li> <li><a href="#birth-date">Enter the date as day, month and year</a></li> </ul></lt-error-summary>The server writes the heading and the list. The component used to create the heading itself, and then it disappeared at the first update from the server and never came back. lt.errorSummary() writes the class, role="alert" and tabindex="-1" when the markup is made with JavaScript; otherwise the component sets them, and the class on the heading, and puts them back if a patch removes them. If the server has written a different role, it stays. See Markup and behaviour.
When there are no errors, the builder function sets hidden on the box. An empty summary with the heading “The form has 0 errors” is worse than none.
Links point to the field’s id
Section titled “Links point to the field’s id”Each link in the list should point to the id of the control that failed. If it points to a <label>, the component follows the label on to the control, whether the label points with for or wraps the control.
<li><a href="#birth-date">Enter the date as day, month and year</a></li>...<label class="lt-label" for="birth-date">Date of birth</label><input class="lt-input" id="birth-date" type="date">Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-error-summary | The box itself. Sits on <lt-error-summary> |
.lt-error-summary__title | The heading above the list |
| Variable | Fallback |
|---|---|
--lt-error-summary-padding | var(--lt-spacing-4) |
--lt-error-summary-radius | var(--lt-spacing-1) |
.lt-error-summary { --lt-error-summary-padding: var(--lt-spacing-6);}Accessibility
Section titled “Accessibility”The box is role="alert", so it is announced when it becomes visible. It has tabindex="-1" so it can receive focus without becoming a tab stop.
Two things are easy to forget, and both are the reason the component exists:
- Focus moves to the box when it becomes visible. Without that, someone listening to the page does not know the submission stopped. Set
data-autofocus="false"if you do not want it. The attribute is not calledautofocus, because that is a boolean property on the element, and React 19 sets the property instead of the attribute. Opting out would then not work. The signal is thathiddenis removed. If the box is already visible when the form fails again, the app has to togglehiddenon and off, or callfocus()itself. The component does not move focus because the list changes, since a summary that shrinks while the user corrects would then pull focus out of the field. One exception: if focus did not land the first time, because the box was in a hidden panel, it tries again at the next change to the list, but only when nobody is in a field. If the box has notabindex, the component sets it. - Focus goes to the control itself when a link is followed. A plain anchor link only gives focus to a target that can take it. If it points to a label or a heading, the browser scrolls the view, but
document.activeElementbecomes<body>, and the screen reader is not told where the user is. Tab continues from the target regardless; it is the focus and the announcement that are missing. The component follows a label to its control, and gives a target that cannot take focustabindex="-1"for the jump.
The component also attaches to links the server sends in later, so a summary that is updated while the user corrects keeps working.