Errors and accessibility
When a widget shows an error, and the helpers that wire a control to its label, description and errors.
formdef leaves the markup of a field to its widget. Three helpers do the wiring every widget
repeats: fieldParts, errorsOf and fieldId.
When an error is due
A field can be invalid long before it should say so: an empty required field is invalid from the start. A widget shows the error once it is due: the field is invalid, and it was edited and left, or the form was submitted.
Edited and left means a person who tabs through an empty form sees no errors, and a person who is still typing is not interrupted. After a submit, every invalid field reports at once.
fieldParts
fieldParts(field) returns what a widget spreads onto its control and the ids of the elements
around it:
import { type , } from '@formdef/core'
type = <string> & { : string; ?: string }
export const = ({ , , }: ) => {
const { , , , , } = (, { })
return (
<div>
<label htmlFor={.}>{}</label>
{ && <p id={}>{}</p>}
<input
{...}
value={.state.}
onChange={() => .(..)}
/>
{ && . > 0 && <p id={}>{.(' ')}</p>}
</div>
)
}| Key | Holds |
|---|---|
control | id, name, onBlur, aria-invalid, aria-describedby and aria-errormessage, to spread onto the control. Its value and change handler differ per control and stay with the widget. |
descriptionId, errorId | The ids for the description and the error element. |
isErrorDue | Whether the error is due. |
errors | The error texts, as errorsOf returns them. |
The wiring follows a few rules, so a screen reader never meets a broken reference:
aria-invalidistruewhile an error is due and absent otherwise, neverfalse.aria-describedbynames the description only when the widget passes one, and the error only while an error with text is due.aria-errormessagenames the error under the same condition. An id never points at an element that is not rendered.- The id starts with the form's own id, so two forms on one page never share one. A widget that
writes
id={field.name}would collide in exactly that case.
errorsOf
errorsOf(field) turns a field's errors into the texts to show. Validators return strings, schema
issues or objects; errorsOf reads the message of each, leaves out an error without text, and
keeps a text that two validators report only once.
fieldId and an error summary
fieldId(formId, path) builds the id fieldParts gives the control at path, for code that
has a path but no field. An error summary is the usual case. formdef ships none, because every
field already reports its own error after a submit; a long form where an invalid field can be out
of view may want one. An inline entry can build it:
const : <string, string> = { : 'Name', : 'Email' }
export const = createForm({
: .({ : .().(1, 'Enter your name'), : .('Enter an email') }),
: { : '', : '' },
fields: {
name: { : 'text', : { : 'Name' } },
email: { : 'text', : { : 'Email' } },
},
: [
{
: () => (
<.Subscribe selector={(state) => (state. > 0 ? state. : {})}>
{() => {
const = .()
.(([, ]) => (?.. ?? 0) > 0)
.(([]) => )
if (. === 0) {
return null
}
return (
<nav aria-label="Errors in this form">
<ul>
{.(() => (
<li ={}>
<a href={`#${(., )}`}>{[] ?? }</a>
</li>
))}
</ul>
</nav>
)
}}
</.Subscribe>
),
},
'name',
'email',
],
})Each link jumps to its control. The summary does not take the focus by itself: an element that does while a submit is running can make TanStack drop an error from the server (Validation explains why).
fieldId folds ., [ and ] in the path to __, so address.zip becomes
<formId>-address__zip. The fold cannot be undone: a key literally named a__b would collide with
the path a.b.