Доступность форм в React: ошибки, метки, фокус, ARIA без перебора
Доступность форм — тема, на которой проседает 90% фронтов. Не потому что разработчики плохие, а потому что её редко учат, и редко требуют на собеседовании. А потом у пользователя со скринридером кнопка отправки звучит как «button button», и форма становится непроходимой.
Расскажу, что я делаю минимально на каждой форме, чтобы она была доступна. Без выписок из WCAG, без ARIA на каждый угол. Только то, что реально нужно и реально работает.
Метка к каждому полю — обязательно
Самое базовое и самое часто забываемое. Любой input должен иметь связанный label. Не placeholder, не aria-label — настоящий label с htmlFor.
// плохо — placeholder вместо метки
<input type="email" placeholder="Email" />
// плохо — div рядом
<div>Email</div>
<input type="email" />
// хорошо
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// тоже хорошо — label-обёртка
<label>
Email
<input type="email" />
</label>
Метка делает три вещи: озвучивается скринридером, увеличивает кликабельную область (можно тапнуть по тексту), связывает поле с обработчиками автозаполнения. Без неё — никакой доступности.
Если по дизайну метку «не хочется показывать» — оставь её и спрячь визуально через утилиту sr-only. Не убирай.
autocomplete: автозаполнение в браузере
Это про доступность тоже, не только про удобство. Атрибут autocomplete подсказывает браузеру, что в поле, и пользователь может заполнить его одним кликом из менеджера паролей или адресной книги.
<input type="email" autocomplete="email" />
<input type="password" autocomplete="current-password" />
<input type="password" autocomplete="new-password" /> // на регистрации
<input name="name" autocomplete="name" />
<input name="phone" autocomplete="tel" />
Полный список — в спецификации HTML, но я держу под рукой набор: email, name, given-name, family-name, tel, street-address, postal-code, country. Этого хватает на 95% форм.
Ошибки: связь поля и сообщения
Если у поля есть ошибка валидации, скринридер должен её прочитать вместе с полем. Это делается через aria-invalid и aria-describedby:
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={Boolean(error)}
aria-describedby={error ? "email-error" : undefined}
/>
{error && (
<p id="email-error" role="alert">
{error}
</p>
)}
Что тут важно: aria-describedby ссылается на id сообщения. role="alert" заставляет скринридер прочитать ошибку сразу при появлении (не дожидаясь, пока пользователь снова сфокусируется на поле).
Я не использую role="alert" на каждой ошибке — это даёт громкое озвучивание. Беру его только там, где ошибка появляется неожиданно (после сабмита). Для ошибок, которые видны сразу под полем, достаточно aria-describedby и aria-invalid.
Required: визуальная и семантическая
Звёздочка возле метки — для зрячих пользователей. Атрибут required или aria-required — для скринридеров.
<label htmlFor="name">
Имя <span aria-hidden="true">*</span>
</label>
<input id="name" required />
Звёздочка скрыта от скринридера через aria-hidden, потому что атрибут required уже всё рассказал. Иначе скринридер прочитает «Имя звёздочка» — звучит странно.
Группы полей: fieldset и legend
Если у тебя группа связанных полей — например, выбор адреса или серия и номер документа, — оборачивай их в fieldset с legend:
<fieldset>
<legend>Серия и номер паспорта</legend>
<label htmlFor="series">Серия</label>
<input id="series" name="series" />
<label htmlFor="number">Номер</label>
<input id="number" name="number" />
</fieldset>
Скринридер прочитает «Серия и номер паспорта, Серия, поле ввода» — пользователь сразу понимает контекст. Без fieldset поля выглядят как два не связанных инпута.
Особенно важно для radio-кнопок: каждая радио-группа должна быть в fieldset, иначе пользователь не понимает, что это варианты одного выбора.
Кастомные контролы: чек-листы
Если ты пишешь свой селект, кастомный чекбокс или комбобокс — на тебе ответственность за всю клавиатурную семантику. Минимум, что я проверяю:
- Контрол получает фокус через Tab.
- Открывается через Enter и Space.
- Стрелки вверх/вниз перемещают по опциям.
- Esc закрывает.
aria-expandedотражает состояние «открыт/закрыт».aria-activedescendantилиaria-selectedна текущей опции.
Это прилично работы. Поэтому я почти всегда беру готовый react-aria от Adobe или radix-ui вместо собственной реализации. На свой кастомный селект уйдёт неделя, на готовом — час, и доступность не сломается.
Управление фокусом
После открытия модалки с формой — фокус должен уйти внутрь модалки, на первое поле. После закрытия — вернуться на кнопку, которая открывала. Это базовое требование к accessibility, и его очень часто нарушают.
function Modal({ open, onClose, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(
document.activeElement as HTMLElement,
);
useEffect(() => {
if (!open) return;
const trigger = triggerRef.current;
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
return () => trigger?.focus();
}, [open]);
// ...
}
Готовые библиотеки модалок (radix dialog, react-aria) делают это сами. Если пишешь свой — не забудь.
Ещё момент: внутри модалки фокус не должен «убегать» на элементы под ней. Для этого нужен focus trap. Опять же — готовые библиотеки решают это из коробки. Свой реализовывать только если есть очень веская причина.
Сабмит и сообщение об успехе
После успешного сабмита формы скринридер должен узнать, что произошло. Я обычно делаю aria-live-регион:
<form onSubmit={handleSubmit(onSubmit)}>
{/* поля */}
<button type="submit">Отправить</button>
<p role="status" aria-live="polite">
{status === "ok" && "Форма отправлена"}
{status === "error" && "Ошибка отправки"}
</p>
</form>
aria-live="polite" означает «прочитай это, как только сможешь, не прерывая текущее озвучивание». Для ошибок я иногда использую aria-live="assertive", но осторожно — пользователю это слышится как «крик».
Не злоупотреблять ARIA
Главное правило ARIA: «лучше не использовать ARIA, чем использовать её неправильно». Если у тебя есть нативный элемент — используй его, не нужно его симулировать через div+aria.
// плохо
<div role="button" tabIndex={0} onKeyDown={...} onClick={...}>
Отправить
</div>
// хорошо
<button type="button" onClick={...}>Отправить</button>
Семантика бесплатная и работает из коробки. ARIA нужна только когда натива нет (комбобокс, sortable list, drag-and-drop).
Чек-лист, который я применяю на каждой форме
- Каждое поле имеет
labelсhtmlFor. - На полях с известными значениями —
autocomplete. - Ошибки связаны с полем через
aria-describedby, поле помеченоaria-invalid. - Required-поля имеют атрибут
requiredи видимую звёздочку (скрытую от скринридера). - Связанные поля сгруппированы в
fieldsetсlegend. - Кастомные контролы — на готовых библиотеках с правильной семантикой.
- Управление фокусом в модалках работает: входит в модалку, возвращается обратно.
- После сабмита есть
aria-live-регион с результатом. - Все интерактивные элементы доступны клавиатурой (Tab, Enter, Space, стрелки где нужно).
- Контраст текста и фона минимум 4.5:1 для обычного текста.
Десять пунктов, на которые уходит пара часов после написания формы. И благодаря этому форма реально работает для слепых пользователей, а не просто «не падает с ошибкой в axe-core».
Что запомнить
Доступность форм — это не отдельный этап перед релизом, а набор привычек, которые ты вырабатываешь один раз и применяешь автоматически. Метки, autocomplete, связь полей с ошибками, фокус — пять базовых вещей, которые покрывают большинство случаев.
Не пиши кастомные селекты с нуля, если можешь взять готовое. Не симулируй кнопки через div. И не верь только автоматическим тестам — пройди свою форму с клавиатуры и со скринридером хотя бы раз. Сразу станет понятно, где что не так.