Form layout and validation

Ask for information in a form, check it, and help people fix what's wrong.

BetaSince 1.0.0
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.

ErrorSummary.tsx
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 onClick focuses their field.
  • A fieldset and legend around a group of checkboxes, and a role="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-invalid while error is set.
  • RadioGroup as a fieldset with its legend, 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.