Render field errors
Validation messages are keyed by field, so each one can be drawn next to the input it is about rather than collected into a list at the top of the form.
Press Next with both fields empty.
import { defineFlow, step } from '@wizzard-packages/core';
/** * One step with two fields, so there are two messages to place rather than one * sentence to print. That is the whole difficulty this page is about. */export const checkout = defineFlow({ id: 'render-field-errors', order: ['details', 'done'], steps: { details: step<{ email: string; card: string }>({ label: 'Your details', validate: { $ref: 'details' }, }), done: step({ label: 'Done' }), },});One message per field, keyed by the field name. The keys are what make the placement possible.
import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/** * A validator answers with one message per field, keyed by the field's name, or * `null` when there is nothing to say. The keys are what let the rendering put * each message beside the input it belongs to. */type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = { details: (_args, scope: Scope): Fields => { const { email, card } = (scope.data['details'] as { email?: string; card?: string } | undefined) ?? {};
const errors: Record<string, string> = {}; if (typeof email !== 'string' || email.trim() === '') { errors['email'] = 'Enter your email address.'; } else if (!EMAIL.test(email)) { errors['email'] = 'That does not look like an email address.'; }
const digits = typeof card === 'string' ? card.replace(/\s/g, '') : ''; if (!/^\d{16}$/.test(digits)) errors['card'] = 'A card number is sixteen digits.';
return Object.keys(errors).length === 0 ? null : errors; },};The field and its message are one component, so the two cannot drift apart.
import { WizardProvider, useErrors, useField, useNavigation, useStep, useWizard,} from '@wizzard-packages/react';import { useId } from 'react';
import { checkout } from './flow';import { registry } from './registry';
export function App() { return ( <WizardProvider flow={checkout} registry={registry}> <Wizard /> </WizardProvider> );}
/** * One field and its message, kept together so the two cannot drift apart: the * input points at the message with `aria-describedby`, and says it is wrong * with `aria-invalid`, which is what a screen reader reads out. */function Field(props: { label: string; value: string | undefined; error: string | undefined; disabled: boolean; onChange: (value: string) => void;}) { // Generated, not written. Both renderings of this example are in the page at // once - the tab that is not showing is hidden, not removed - so a fixed `id` // would be in the document twice, and `for` and `aria-describedby` would both // resolve to whichever came first rather than to the field beside them. const id = useId(); const describedBy = props.error === undefined ? undefined : `${id}-error`;
return ( <p> <label htmlFor={id}>{props.label}</label> <input id={id} value={props.value ?? ''} onChange={(e) => props.onChange(e.target.value)} disabled={props.disabled} aria-invalid={props.error !== undefined} aria-describedby={describedBy} /> {props.error !== undefined && ( <span id={describedBy} role="alert"> {props.error} </span> )} </p> );}
export function Wizard() { const wizard = useWizard(); const { current } = useStep(); const { next, isBusy } = useNavigation(); const [email, setEmail] = useField<string>('details.email'); const [card, setCard] = useField<string>('details.card'); const errors = useErrors();
const step = current ?? 'details'; const starting = current === null;
return ( <form onSubmit={(e) => { e.preventDefault(); void next(); }} > {step === 'details' && ( <> <Field label="Your email" value={email} error={errors['email']} disabled={starting} onChange={setEmail} /> <Field label="Card number" value={card} error={errors['card']} disabled={starting} onChange={setCard} /> {/* A message the engine could not have produced: the card was well formed and the payment service still said no. It goes in the same place, so the rendering needs no second path for it. */} <button type="button" onClick={() => wizard.setErrors('details', { card: 'That card was declined.' })} disabled={starting} > Pretend the server refused </button> </> )} {step === 'done' && <p>Both fields were fine.</p>}
<button type="submit" disabled={starting || isBusy || step === 'done'}> Next </button> </form> );}import { defineFlow, step } from '@wizzard-packages/core';
/** * One step with two fields, so there are two messages to place rather than one * sentence to print. That is the whole difficulty this page is about. */export const checkout = defineFlow({ id: 'render-field-errors', order: ['details', 'done'], steps: { details: step<{ email: string; card: string }>({ label: 'Your details', validate: { $ref: 'details' }, }), done: step({ label: 'Done' }), },});import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/** * A validator answers with one message per field, keyed by the field's name, or * `null` when there is nothing to say. The keys are what let the rendering put * each message beside the input it belongs to. */type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = { details: (_args, scope: Scope): Fields => { const { email, card } = (scope.data['details'] as { email?: string; card?: string } | undefined) ?? {};
const errors: Record<string, string> = {}; if (typeof email !== 'string' || email.trim() === '') { errors['email'] = 'Enter your email address.'; } else if (!EMAIL.test(email)) { errors['email'] = 'That does not look like an email address.'; }
const digits = typeof card === 'string' ? card.replace(/\s/g, '') : ''; if (!/^\d{16}$/.test(digits)) errors['card'] = 'A card number is sixteen digits.';
return Object.keys(errors).length === 0 ? null : errors; },};<script setup lang="ts">import { provideWizard } from '@wizzard-packages/vue';
import Wizard from './Wizard.vue';import { checkout } from './flow';import { registry } from './registry';
// The wizard is provided here and consumed by the child. Vue's inject reads the// parent chain, so the component that provides cannot also use the composables.provideWizard({ flow: checkout, registry });</script>
<template> <Wizard /></template><script setup lang="ts">import { useErrors, useField, useNavigation, useStep, useWizard } from '@wizzard-packages/vue';import { computed, useId } from 'vue';
const wizard = useWizard();const { current } = useStep();const { next, isBusy } = useNavigation();const email = useField<string>('details.email');const card = useField<string>('details.card');const errors = useErrors();
const step = computed(() => current.value ?? 'details');const starting = computed(() => current.value === null);
// Generated, not written. Both renderings of this example are in the page at// once - the tab that is not showing is hidden, not removed - so a fixed `id`// would be in the document twice, and `for` and `aria-describedby` would both// resolve to whichever came first rather than to the field beside them.const emailId = useId();const cardId = useId();</script>
<template> <form @submit.prevent="next()"> <template v-if="step === 'details'"> <!-- Each input points at its own message with `aria-describedby` and says it is wrong with `aria-invalid`, which is what a screen reader reads. --> <p> <label :for="emailId">Your email</label> <input :id="emailId" v-model="email" :disabled="starting" :aria-invalid="errors['email'] !== undefined" :aria-describedby="errors['email'] === undefined ? undefined : `${emailId}-error`" /> <span v-if="errors['email'] !== undefined" :id="`${emailId}-error`" role="alert"> {{ errors['email'] }} </span> </p>
<p> <label :for="cardId">Card number</label> <input :id="cardId" v-model="card" :disabled="starting" :aria-invalid="errors['card'] !== undefined" :aria-describedby="errors['card'] === undefined ? undefined : `${cardId}-error`" /> <span v-if="errors['card'] !== undefined" :id="`${cardId}-error`" role="alert"> {{ errors['card'] }} </span> </p>
<!-- A message the engine could not have produced: the card was well formed and the payment service still said no. It goes in the same place. --> <button type="button" :disabled="starting" @click="wizard.setErrors('details', { card: 'That card was declined.' })" > Pretend the server refused </button> </template> <p v-else-if="step === 'done'">Both fields were fine.</p>
<button type="submit" :disabled="starting || isBusy || step === 'done'">Next</button> </form></template>import { defineFlow, step } from '@wizzard-packages/core';
/** * One step with two fields, so there are two messages to place rather than one * sentence to print. That is the whole difficulty this page is about. */export const checkout = defineFlow({ id: 'render-field-errors', order: ['details', 'done'], steps: { details: step<{ email: string; card: string }>({ label: 'Your details', validate: { $ref: 'details' }, }), done: step({ label: 'Done' }), },});import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/** * A validator answers with one message per field, keyed by the field's name, or * `null` when there is nothing to say. The keys are what let the rendering put * each message beside the input it belongs to. */type Fields = Record<string, string> | null;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = { details: (_args, scope: Scope): Fields => { const { email, card } = (scope.data['details'] as { email?: string; card?: string } | undefined) ?? {};
const errors: Record<string, string> = {}; if (typeof email !== 'string' || email.trim() === '') { errors['email'] = 'Enter your email address.'; } else if (!EMAIL.test(email)) { errors['email'] = 'That does not look like an email address.'; }
const digits = typeof card === 'string' ? card.replace(/\s/g, '') : ''; if (!/^\d{16}$/.test(digits)) errors['card'] = 'A card number is sixteen digits.';
return Object.keys(errors).length === 0 ? null : errors; },};import { createWizard } from '@wizzard-packages/core';
import { checkout } from './flow';import { registry } from './registry';
/** * Where the messages live, with nothing rendering them: in the wizard's state, * keyed by field, put there either by the validator or by the host. */const wizard = createWizard({ flow: checkout, registry });
await wizard.start();
const refused = await wizard.next();if (!refused.ok) console.log(`both fields: ${JSON.stringify(refused.errors)}`);
wizard.set('details.email', 'ada@example.com');const stillRefused = await wizard.next();if (!stillRefused.ok) console.log(`email fixed: ${JSON.stringify(stillRefused.errors)}`);
// A message the engine could not have produced, put in the same place.wizard.setErrors('details', { card: 'That card was declined.' });console.log(`from the host: ${JSON.stringify(wizard.getSnapshot().errors['details'])}`);
wizard.set('details.card', '4242424242424242');const moved = await wizard.next();console.log(`next: ${moved.ok ? 'ok' : moved.reason}`);console.log(`step: ${String(wizard.getSnapshot().current)}`);console.log(`errors now: ${JSON.stringify(wizard.getSnapshot().errors['details'])}`);A test in the repository asserts these exact lines.
both fields: {"email":"Enter your email address.","card":"A card number is sixteen digits."}email fixed: {"card":"A card number is sixteen digits."}from the host: {"card":"That card was declined."}next: okstep: doneerrors now: undefinedReading the messages
Section titled “Reading the messages”useErrors(), a named import from @wizzard-packages/react or @wizzard-packages/vue, returns
the map for the current step, or for a step you name. It is a plain object keyed by field:
const errors = useErrors();errors['email']; // string | undefinedOn Vue it is a ComputedRef of the same object, which the template unwraps for you.
Making it accessible
Section titled “Making it accessible”Three attributes do the work, and the example above carries all three:
aria-invalidon the input, so the field announces itself as wrong.aria-describedbypointing at the message’sid, so the message is read with the field rather than stranded somewhere after it.role="alert"on the message, so it is announced when it appears.
A message that is only a red border says nothing to anyone who cannot see the border.
Messages your server produced
Section titled “Messages your server produced”A card can be perfectly well formed and still be declined. setErrors puts that message in the
same place as the validator’s, so the rendering needs no second path for it:
wizard.setErrors('details', { card: 'That card was declined.' });wizard.setErrors('details', null); // clearThe next successful validation of that step replaces what is there, which is why the message disappears once the field is corrected.
Where to read more
Section titled “Where to read more”Validation covers when validators run and how to run one by hand. Block Next until valid is the other half of this page: the refusal that produces these messages in the first place.