Validation and accessible errors
@samline/forms calculates errors, sets aria-invalid, and focuses the first invalid field after a failed submit. Your application owns labels, error text, live announcements, and styling.
Complete accessible example
Section titled “Complete accessible example”-
Add labels and persistent message containers
Use
novalidatewhen the controller should own the validation messages. Without it, native constraint validation can stop thesubmitevent before the controller runs.<form id="signup" novalidate><div class="field"><label for="signup-email">Email</label><inputid="signup-email"name="email"type="email"autocomplete="email"aria-describedby="signup-email-error"/><p id="signup-email-error" class="field-error"></p></div><div class="field"><label for="signup-password">Password</label><inputid="signup-password"name="password"type="password"autocomplete="new-password"aria-describedby="signup-password-error"/><p id="signup-password-error" class="field-error"></p></div><div id="signup-summary" role="alert" aria-live="polite"></div><button type="submit">Create account</button></form> -
Configure validation
import { form } from '@samline/forms'const signup = form('signup', {validators: {email: {required: { value: true, message: 'Enter your email address.' },pattern: {value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,message: 'Enter a valid email address.'}},password: {required: { value: true, message: 'Choose a password.' },minLength: { value: 8, message: 'Use at least 8 characters.' }}}}) -
Render every state update
subscribe()invokes the listener immediately and after controller notification points. It is safer than readinggetState()once.const errorNodes = {email: document.querySelector<HTMLElement>('#signup-email-error')!,password: document.querySelector<HTMLElement>('#signup-password-error')!}const summary = document.querySelector<HTMLElement>('#signup-summary')!signup.subscribe(state => {for (const [name, node] of Object.entries(errorNodes)) {node.textContent = state.errors[name]?.join(' ') ?? ''}const messages = Object.values(state.errors).flat()summary.textContent = messages.length > 0? `Please fix ${messages.length} ${messages.length === 1 ? 'error' : 'errors'}.`: ''})signup.onSubmit(async (_form, _data, formData) => {const response = await fetch('/api/signup', { method: 'POST', body: formData })if (response.status === 422) {const body = await response.json() as { fieldErrors: Record<string, string[]> }signup.setErrors(body.fieldErrors)}})
Built-in rule behavior
Section titled “Built-in rule behavior”| Rule | Empty value | Strings | Arrays and files |
|---|---|---|---|
required |
Fails when empty. | Trims whitespace before deciding. | Fails when the array is empty. |
minLength |
Runs even when empty. | Uses JavaScript string length. | Uses item count. |
maxLength |
Runs even when empty. | Uses JavaScript string length. | Uses item count. |
pattern |
Skipped. | Tests the string. | Tests a comma-joined string; files contribute their names. |
numeric |
Skipped. | Requires a finite signed decimal string. | Fails because collections are not decimal strings. |
min |
Skipped. | Requires a numeric value greater than or equal to the bound. | Fails numeric parsing before comparison. |
max |
Skipped. | Requires a numeric value less than or equal to the bound. | Fails numeric parsing before comparison. |
sameAs |
Skipped until both fields have values. | Exact, case-sensitive equality. | Ordered item equality; files compare by object identity. |
validate |
Always runs. | Receives the normalized value and all form values. | Receives the array unchanged. |
All configured rules run and their messages accumulate. Custom validators still run when a built-in rule failed. Stateful regular expressions are safe because lastIndex is reset before and after each test.
Numeric validation is intentionally strict. After trimming surrounding whitespace it accepts signed decimal forms such as -12, +3.5, 4., and .75. It rejects exponent notation (1e3), hexadecimal (0x10), Infinity, separators, and partial numbers. min and max imply numeric parsing, use inclusive comparisons, and report a numeric error for a non-empty non-number before checking the bound.
Cross-field equality with sameAs
Section titled “Cross-field equality with sameAs”Use sameAs for password, email, PIN, or account-number confirmation:
const signup = form('signup-form', { validators: { password: { required: true, minLength: 8 }, password_confirmation: { required: true, sameAs: { value: 'password', message: 'Passwords do not match.' } } }})The declaration creates a one-way validation dependency: password_confirmation owns the error and is automatically revalidated when password changes after validation is active. The default autoValidate: true activates validation during construction; with autoValidate: false, call validate() or revalidate() once to activate it. Changes made through setValue() use the same input pipeline. Do not add a watcher just to call revalidate().
Things to avoid:
- Do not use
sameAsinstead ofrequired; equality is skipped while either value is empty. - Do not rely on a
_confirmationnaming convention. Thevaluemust be the exact field name. - Do not expect unordered collection equality. Arrays must contain the same entries in the same order.
- Do not add reciprocal rules to force source-field revalidation. The dependency is already inferred from the confirmation rule.
Circular dependencies cannot recurse indefinitely. The controller collects affected fields iteratively with a visited set, then validates each field at most once for that input event. This avoids event loops and does not install per-field listeners.
Custom dependencies with dependsOn
Section titled “Custom dependencies with dependsOn”Custom validators can read any value, but the controller needs explicit metadata to know which source changes should trigger them:
form('booking', { validators: { end_date: { dependsOn: ['start_date', 'timezone'], validate: ({ value, values }) => isValidRange(values.start_date, value, values.timezone) ? null : 'Choose an end date after the start date.' } }})dependsOn accepts exact field names, adds no message itself, and uses the same cycle-safe transitive traversal as sameAs. With autoValidate: false, reactive validation starts after the first validate() or revalidate() call.
Validate repeated members with each
Section titled “Validate repeated members with each”Use group rules for the collection and each for each concrete control:
form('signers', { validators: { 'signer_email[]': { minLength: { value: 2, message: 'Add at least two signers.' }, each: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, validate: ({ element, index }) => element?.disabled ? `Signer ${index! + 1} must be enabled.` : null } } }})Rules inside each receive one member as value, the concrete element when a controller validates DOM fields, and its zero-based index. Group rules still receive the aggregate field value. FormErrors deliberately remains Record<string, string[]>: member messages are flattened under the field name in member order. Internally, the controller retains element ownership so css-error and aria-invalid appear only on failing members; a group-level or manual error still marks the whole group.
Exact field names
Section titled “Exact field names”Validator keys are literal HTML field names. Wildcards and object-path expansion are not supported:
form('items', { validators: { 'rows[].name': { required: true } // matches name="rows[].name" }})rows[*].name does not match rows[0].name. For indexed names, create exact rules before constructing the controller or validate a values object with validateValues().
Manual and validation errors
Section titled “Manual and validation errors”- Validation errors come from
validatorsand are recomputed byvalidate(). - Manual errors come from
setErrors()and remain separate internally. - Returned state merges both maps.
clearErrors()clears only manual errors.clearErrorsOnSubmitandclearManualErrorsOnChangecontrol when manual errors disappear.- A partial
validate(['email'])preserves existing validation errors for fields not requested.
State caveats
Section titled “State caveats”getState() is a pure snapshot; it never runs validation. With the default autoValidate: true, isValidated starts as true because initial validation runs during construction. With autoValidate: false, an invalid form can report isValid: true until you call validate() or submit it. Initial css-filled synchronization still runs in either mode and is not validation.
Direct validate() and revalidate() calls update validation state and visual attributes but do not independently notify subscribers. Render from their returned result when you need synchronous UI feedback.