Form with validation
The components exist separately. What decides whether a form is usable is the decisions around them: when you validate, where focus goes, and what the error message says.
Try submitting the form empty, and then with an incomplete email address:
When to validate
Section titled “When to validate”| When | What happens |
|---|---|
| While the user types | No new errors. An error message that is already there disappears as soon as the field becomes valid. |
| When the field is left | Validate the field, if the user has typed in it. They are done with it then, and the message interrupts nobody. |
| When the form is submitted | Validate everything. This is where all the errors exist at once. |
We say nothing about a field the user has not touched until submission. “Enter your email address” before you have started is scolding someone who has not done anything yet.
The summary is read from the fields and keeps up when something is validated. If you fix one of two errors, the remaining one stays on its own, and the heading counts down. A summary stuck on the first number is worse than none.
Along the way you write it only when its content changes, and you do not show it. The box is role="alert", and every change in it is announced immediately: rewritten on every keystroke or every Tab, it interrupts the label of the next field every time.
The three things that must match per field
Section titled “The three things that must match per field”<label class="lt-label" data-required="symbol" for="email">Email address</label><input class="lt-input" id="email" type="email" required data-state="invalid" aria-invalid="true" aria-describedby="email-help email-error"/><p class="lt-help-text" id="email-help">We send the receipt here.</p><p class="lt-error-text" id="email-error">Enter an email address with an @ sign.</p>data-state gives the colour, aria-invalid tells the screen reader, and aria-describedby points to the text. Without the last one, the error message exists only for those who can see it.
The error text has to come out of aria-describedby again when the field becomes valid. Otherwise the field points to hidden text, and screen readers announce either nothing or something that is not on the screen.
To avoid keeping track of this yourself, use Field, either as <lt-field> or as lt.field(). Both compute all three from one state.
The summary
Section titled “The summary”Error Summary sits at the top of the form and collects the errors. You write the box, the heading and the list with lt.errorSummary(). The web component does the two things you would otherwise have to remember: it moves focus to itself when it appears, and sends focus to the right field when a link is followed. Moving focus can be turned off with data-autofocus="false", and in this pattern it has to be: the summary is also built when a field is left, and then the box would take focus from the field the user just tabbed to. The demo above turns it off, and moves focus to the box itself when submission fails.
<lt-error-summary class="lt-error-summary" role="alert" tabindex="-1" data-autofocus="false" id="form-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 @ sign</a></li> <li><a href="#date-of-birth">Enter a date that exists</a></li> </ul></lt-error-summary>The heading and hidden are written by whoever renders the form, not by the web component. If the list is empty, lt.errorSummary({ count: 0 }) sets hidden on the box, and the heading is left untouched: “The form has 0 errors” is the last thing the user should hear.
The link text is the error message, not the name of the field. Someone hearing the list should understand what is wrong without jumping to the field first.
On submission you rebuild the list, even when the same errors remain. Then it is announced again, and the user knows the attempt was rejected.
What the browser misses
Section titled “What the browser misses”type="email" accepts jo@example, without a dot and top-level domain. That is a valid address on an internal network, and so correct of the browser, but in a form that sends a receipt by email it is almost always a typo. The demo therefore adds one more rule:
const ADDRESS = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
const valid = field.checkValidity() && ADDRESS.test(field.value)Anything stricter is not worth it. An address is only confirmed once someone has clicked the link in the email you sent.
Errors from the server
Section titled “Errors from the server”The browser’s validation catches format. The server catches everything else: that the address is not in the register, that the deadline has passed, that someone else has applied for the same thing. The two have to end up in the same place.
const form = document.getElementById("application")
form.addEventListener("submit", async (event) => { event.preventDefault()
const response = await fetch("/api/application", { method: "POST", body: new FormData(form), })
if (response.ok) return
const { errors } = await response.json() // [{ field: "email", message: "…" }]
for (const { field: name, message } of errors) { const field = form.elements[name] const errorText = document.getElementById(`${field.id}-error`)
errorText.textContent = message errorText.hidden = false
field.setAttribute("aria-invalid", "true") field.dataset.state = "invalid"
// The three things from the section above: without the id here, the // message exists only for those who can see it. const describedBy = new Set( (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean), ) describedBy.add(errorText.id) field.setAttribute("aria-describedby", [...describedBy].join(" ")) }
// The same summary as above, built from the errors the server sent. // `textContent` and not `innerHTML`: the message comes from outside. const box = document.getElementById("form-errors")
box.querySelector("ul").replaceChildren( ...errors.map(({ field: name, message }) => { const item = document.createElement("li") const link = document.createElement("a") link.href = `#${form.elements[name].id}` link.textContent = message item.append(link) return item }), )
// The heading is written here, as the server would otherwise do. box.querySelector("h2").textContent = errors.length === 1 ? "The form has one error" : `The form has ${errors.length} errors`
box.hidden = false box.focus()})If the error has no field, such as “the service is down”, it belongs in an Alert above the form, not in the summary. The summary is a list of things the user can fix.
Wording
Section titled “Wording”- Say what the user should do: “Enter a date that exists”, not “Invalid value”.
- Include an example when the format is unusual: “for example jo@example.com”.
- Write in the page’s language. The browser’s own messages follow the browser language, not the page’s, so
validationMessagecan turn up in another language in the middle of an English form. The demo above therefore uses its own texts. - Drop the exclamation mark, and do not blame the user.
What the system does not do
Section titled “What the system does not do”- The order of the fields. The keyboard follows the markup.
- What is required.
data-requiredis only visual, andrequiredon the field is what stops the submission. - Saving drafts. A long form should survive the network going away, but that is the app’s responsibility.