On this page
Import
import {
Alert,
Button,
Checkbox,
Column,
Combobox,
DatePicker,
DescriptionList,
Field,
Grid,
Link,
Progress,
Radio,
RadioGroup,
Row,
Select,
Text,
Textarea,
TextField,
} from '@envsemble/envui';
Usage
A form is a native <form noValidate onSubmit={…}>, and the application runs the checks. On submit, set each wrong field's error and focus the error summary, or the field when only one is wrong. Then check a field in its onBlur, and clear its error as soon as the value fits.
import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
import { Alert, Button, Link, Select, Column, Row, Text, TextField } from '@envsemble/envui';
type FieldName = 'name' | 'email' | 'team';
type Values = Record<FieldName, string>;
/** Each field's message, in the order of the form, for the fields that are wrong. */
function validate(values: Values) {
const errors: Partial<Record<FieldName, string>> = {};
if (!values.name.trim()) errors.name = 'Enter your full name';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email.trim()))
errors.email = 'Enter an email address like name@example.com';
if (!values.team) errors.team = 'Choose a team';
return errors;
}
export default function ErrorSummary() {
const id = useId();
const [values, setValues] = useState<Values>({ name: 'Sam Lee', email: 'sam.lee@', team: '' });
const [errors, setErrors] = useState<Partial<Record<FieldName, string>>>({});
const [submitted, setSubmitted] = useState(false);
const [saved, setSaved] = useState('');
const summary = useRef<HTMLDivElement>(null);
// Counts the refused submits; each moves focus to the summary once it has rendered.
const [refused, setRefused] = useState(0);
useEffect(() => {
if (refused) summary.current?.focus();
}, [refused]);
const fields = {
name: useRef<HTMLInputElement>(null),
email: useRef<HTMLInputElement>(null),
team: useRef<HTMLButtonElement>(null),
};
const change = (field: FieldName, value: string) => {
const next = { ...values, [field]: value };
setValues(next);
// After a submit, an error goes as soon as its field is fixed; no new one appears while typing.
if (submitted)
setErrors((current) =>
validate(next)[field] ? current : { ...current, [field]: undefined },
);
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const found = validate(values);
setErrors(found);
setSubmitted(true);
if (Object.keys(found).length) {
setSaved('');
setRefused((count) => count + 1);
return;
}
setSaved('Your details are saved.');
};
const listed = (Object.keys(fields) as FieldName[]).filter((field) => errors[field]);
return (
<form noValidate onSubmit={submit}>
{/* envUI has no form column yet: one column, kept to a width that reads easily. */}
<Column gap={5} style={{ maxInlineSize: '32rem' }}>
{listed.length > 0 && (
// envUI has no error summary yet: a danger Alert that takes focus lists the errors, each
// linked to its field. Without `announce` it has no role, so it's read once, as it takes
// focus; its title is a heading, so people can find it again.
<Alert
ref={summary}
tabIndex={-1}
tone="danger"
title={
<span role="heading" aria-level={2}>
There is a problem
</span>
}
>
<Column render={<ul />} gap={1}>
{listed.map((field) => (
<li key={field}>
<Link
href={`#${id}-${field}`}
onClick={(event) => {
// Focus the field itself; following the fragment would only scroll to it.
event.preventDefault();
fields[field].current?.focus();
}}
>
{errors[field]}
</Link>
</li>
))}
</Column>
</Alert>
)}
<TextField
ref={fields.name}
id={`${id}-name`}
label="Full name"
name="name"
autoComplete="name"
value={values.name}
error={errors.name}
onValueChange={(value) => change('name', value)}
/>
<TextField
ref={fields.email}
id={`${id}-email`}
label="Email address"
name="email"
type="email"
autoComplete="email"
spellCheck={false}
value={values.email}
error={errors.email}
onValueChange={(value) => change('email', value)}
/>
<Select
ref={fields.team}
id={`${id}-team`}
label="Team"
name="team"
placeholder="Choose a team"
items={teams}
value={values.team || null}
error={errors.team}
onValueChange={(value) => change('team', value ?? '')}
/>
<Row wrap gap={3}>
<Button type="submit">Save details</Button>
</Row>
<Text role="status" emphasis="muted">
{saved}
</Text>
</Column>
</form>
);
}
const teams = [
{ value: 'design', label: 'Design' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'operations', label: 'Operations' },
];Sending
<Button type="submit" pending={sending}>
Create project
</Button>
When the server refuses or can't be reached, keep the values and focus a danger Alert at the top.
Error summary
A danger Alert with tabIndex={-1} and a ref, without announce, so it is read once as it takes focus; its title takes role="heading". Each error is a Link whose onClick focuses its field through the field's ref.
Grouped choices and browser help
A RadioGroup is a fieldset whose legend is its label; Checkboxes go in a native fieldset and legend. Set type and autoComplete, such as autoComplete="email", on fields people fill in often.
A long form in steps
Keep every step's answers in state above the steps, so Back keeps them. Progress takes segments and max of the number of steps, value and aria-valuetext, such as "Step 2 of 3: Team". Continue, a type="submit" Button, checks only its step's fields, then focuses the next step's heading, a Text with tabIndex={-1}. The last step shows the answers in a DescriptionList, as the LongFormInSteps example does.
Composition
One Column, kept to a width that reads easily, such as maxInlineSize: '32rem', with the actions in a Row after the last field, the primary action last. A Grid suits only short fields that belong together, such as a city and a postal code. Field's parts build a control envUI lacks.
Accessibility
You provide
noValidate, the checks and their messages, and focus after a refused submit:tabIndex={-1}and a ref on the summary.- Links in the summary whose
onClickfocuses their field. - A
fieldsetandlegendaround a group of checkboxes, and arole="status"message once saved.
envUI provides
- Native inputs, radios and checkboxes, so typing, autofill and submission behave as the platform's.
- A label tied to every field; the description and error linked to it, and
aria-invalidwhileerroris set. - RadioGroup as a
fieldsetwith itslegend, and a pending Button that reports busy.
Changelog
- 1.0.0
The page is on the figure template, with a long form in steps, which controls to choose, and the labels and error messages of the content guidelines.