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:

form/text-field.tsx
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>
  )
}
KeyHolds
controlid, 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, errorIdThe ids for the description and the error element.
isErrorDueWhether the error is due.
errorsThe error texts, as errorsOf returns them.

The wiring follows a few rules, so a screen reader never meets a broken reference:

  • aria-invalid is true while an error is due and absent otherwise, never false.
  • aria-describedby names the description only when the widget passes one, and the error only while an error with text is due. aria-errormessage names 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.