Forms
Build forms that tell people what went wrong and where. Field wiring, validation, an error summary and the react-hook-form recipe.
// you'll learn
- What
Fieldconnects for you, and what is left to your markup - Two ways to validate: on the field, or with your own state
- When to add an
ErrorSummary, and where focus goes after a failed submit - How to plug in react-hook-form and zod without losing any of it
Wire a field
Field ties a label, a description and an error to the control inside it. FieldLabel becomes a <label> for the control, and FieldDescription and FieldError are linked with aria-describedby, so a screen reader reads the hint and the error when the control gets focus.
import { Field, FieldDescription, FieldError, FieldLabel, Input } from "@camaleo/react";
<Field validationMode="onBlur">
<FieldLabel>Work email</FieldLabel>
<Input type="email" autoComplete="email" required />
<FieldDescription>We reply within one business day.</FieldDescription>
<FieldError match="valueMissing">Enter your work email.</FieldError>
</Field>
Input, Textarea, NativeSelect, Select, Combobox, NumberField, Checkbox, Switch, Slider and the date fields all join the Field they sit in. Group related fields with Fieldset and FieldsetLegend. For a checkbox or switch on its own, a plain Label around it is enough.
Validate on the field
Field runs the browser's constraint checks (required, type="email", minLength…) and your own validate function. FieldError shows the message; match picks which failure it reports, and a validate result is shown as is.
validationMode sets when it checks. Base UI's default, "onSubmit", is meant for its own Form component, which Camaleo doesn't wrap, so set "onBlur" or "onChange" on the Field, or let a form library decide (below).
Lowercase letters, numbers, dots and underscores.
<Field
validationMode="onChange"
validate={(value) => (String(value ?? "").length < 3 ? "Use at least 3 characters." : null)}
>
<FieldLabel>Username</FieldLabel>
<Input autoComplete="username" />
<FieldError />
</Field>
When the result comes from somewhere else (a server, a form library), set invalid on the Field and pass the same flag to match on FieldError, which then shows its children as they are. That is how the recipe below works.
Write errors that say how to fix the problem: "Enter an email like ana@company.com", not "Invalid input". Each component page has a do/don't pair about it.
Summarize the errors
When a form has more than a few fields, a failed submit should land on an ErrorSummary at the top: a danger alert whose title says how many fields need fixing, with one link per error. Each link moves focus to its control.
const summary = useRef<HTMLDivElement>(null);
// After a failed submit, focus the summary so it is read first.
useEffect(() => {
if (failedAttempts > 0) summary.current?.focus();
}, [failedAttempts]);
{errors.length > 0 && (
<ErrorSummary
ref={summary}
tabIndex={-1}
title={`Fix ${errors.length} fields to continue.`}
errors={errors.map((e) => ({ target: e.id, message: e.message }))}
/>
)}
target is the id of the control. Give each control an id from useId(), so two copies of the form on one page never collide.
Use react-hook-form and zod
react-hook-form keeps the values and runs the schema; Camaleo shows the result. Wrap each control in a Controller, pass fieldState.invalid to the Field, and spread field on the control:
const { control, handleSubmit } = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { email: "" },
// The ErrorSummary takes focus, not the first invalid field.
shouldFocusError: false,
});
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid}>
<FieldLabel>Work email</FieldLabel>
<Input {...field} id={ids.email} type="email" autoComplete="email" />
<FieldError match={fieldState.invalid}>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
Set shouldFocusError: false when the form has an ErrorSummary. Otherwise react-hook-form moves focus to the first invalid field first, and people who use a screen reader never hear the summary.
A Checkbox reports checked instead of value, so connect it by hand: checked={field.value}, onCheckedChange={field.onChange} and ref={field.ref}.
A complete form
Everything above in one form: five fields, a schema, the summary with focus, and a confirmation that takes focus when the form is gone. Send it empty, then fix the fields one by one. Open the Code tab for the whole file.
Before you ship it
- Every control has a visible label, not just a placeholder
- Each error says how to fix it, in the field and in the summary
- After a failed submit, focus lands on the summary; each link reaches its field
noValidateon the<form>, so browser bubbles don't compete with your messages- Ids come from
useId(), andautoCompleteis set on personal data