Review

Announce co-op form errors once via polite live region

Kettle & Co-op's membership application re-validates on every keystroke and stuffed each field error into a separate assertive live region, so VoiceOver rattled off partial zips and half-typed emails while applicants were still typing. Gate validation to submit (and blur-after-submit), publish one aggregated summary into a single aria-live="polite" region with aria-atomic, and wire fields with aria-invalid / aria-describedby so assistive tech gets a calm, complete message instead of a flood.

ReactTier 1a11yaria-liveforms

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

src/membership/ApplyForm.tsx+31-10
2828export function ApplyForm() {
2929 const [values, setValues] = useState({ email: "", zip: "", household: "" });
30- const [errors, setErrors] = useState<FieldErrors>({});
30+ const [errors, setErrors] = useState<FieldErrors>({});
31+ const [submitted, setSubmitted] = useState(false);
32+ const [announce, setAnnounce] = useState("");
3133
32- function onChange(field: keyof typeof values, value: string) {
33- const next = { ...values, [field]: value };
34- setValues(next);
35- // re-validate every keystroke → live regions spam partial input
36- setErrors(validate(next));
37- }
34+ function onChange(field: keyof typeof values, value: string) {
35+ const next = { ...values, [field]: value };
36+ setValues(next);
37+ // only re-check after the first submit so typing stays quiet
38+ if (submitted) setErrors(validate(next));
39+ }
3840
3941 function onSubmit(e: React.FormEvent) {
4042 e.preventDefault();
4143 const next = validate(values);
4244 setErrors(next);
45+ setSubmitted(true);
46+ const msgs = Object.values(next);
47+ // one polite announcement with the full list — not per-field assertive
48+ setAnnounce(
49+ msgs.length ? `Please fix ${msgs.length} errors: ${msgs.join(". ")}.` : ""
50+ );
4351 if (Object.keys(next).length === 0) submitApplication(values);
4452 }
4553
4654 return (
4755 <form onSubmit={onSubmit} noValidate>
48- {errors.email && <p role="alert">{errors.email}</p>}
49- {errors.zip && <p role="alert">{errors.zip}</p>}
50- {errors.household && <p role="alert">{errors.household}</p>}
56+ <div aria-live="polite" aria-atomic="true" className="sr-only">
57+ {announce}
58+ </div>
59+ {Object.keys(errors).length > 0 && (
60+ <div id="apply-errors" role="group" aria-labelledby="apply-errors-title">
61+ <h2 id="apply-errors-title">Check these fields</h2>
62+ <ul>
63+ {errors.email && <li><a href="#email">{errors.email}</a></li>}
64+ {errors.zip && <li><a href="#zip">{errors.zip}</a></li>}
65+ {errors.household && <li><a href="#household">{errors.household}</a></li>}
66+ </ul>
67+ </div>
68+ )}
5169 <label htmlFor="email">Work email</label>
5270 <input
5371 id="email"
5472 value={values.email}
5573 onChange={(e) => onChange("email", e.target.value)}
74+ aria-invalid={Boolean(errors.email)}
75+ aria-describedby={errors.email ? "email-err" : undefined}
5676 />
77+ {errors.email && <p id="email-err">{errors.email}</p>}
5778 {/* zip + household inputs follow the same pattern */}
5879 <button type="submit">Request membership</button>
5980 </form>
6081 );
6182}