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
| 28 | 28 | export function ApplyForm() { | |
| 29 | 29 | 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(""); | |
| 31 | 33 | ||
| 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 | + | } | |
| 38 | 40 | ||
| 39 | 41 | function onSubmit(e: React.FormEvent) { | |
| 40 | 42 | e.preventDefault(); | |
| 41 | 43 | const next = validate(values); | |
| 42 | 44 | 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 | + | ); | |
| 43 | 51 | if (Object.keys(next).length === 0) submitApplication(values); | |
| 44 | 52 | } | |
| 45 | 53 | ||
| 46 | 54 | return ( | |
| 47 | 55 | <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 | + | )} | |
| 51 | 69 | <label htmlFor="email">Work email</label> | |
| 52 | 70 | <input | |
| 53 | 71 | id="email" | |
| 54 | 72 | value={values.email} | |
| 55 | 73 | onChange={(e) => onChange("email", e.target.value)} | |
| 74 | + | aria-invalid={Boolean(errors.email)} | |
| 75 | + | aria-describedby={errors.email ? "email-err" : undefined} | |
| 56 | 76 | /> | |
| 77 | + | {errors.email && <p id="email-err">{errors.email}</p>} | |
| 57 | 78 | {/* zip + household inputs follow the same pattern */} | |
| 58 | 79 | <button type="submit">Request membership</button> | |
| 59 | 80 | </form> | |
| 60 | 81 | ); | |
| 61 | 82 | } |