lenec ru

← все посты

Доступность форм в React: ошибки, метки, фокус, ARIA без перебора

17K

Доступность форм — тема, на которой проседает 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).

Чек-лист, который я применяю на каждой форме

  1. Каждое поле имеет label с htmlFor.
  2. На полях с известными значениями — autocomplete.
  3. Ошибки связаны с полем через aria-describedby, поле помечено aria-invalid.
  4. Required-поля имеют атрибут required и видимую звёздочку (скрытую от скринридера).
  5. Связанные поля сгруппированы в fieldset с legend.
  6. Кастомные контролы — на готовых библиотеках с правильной семантикой.
  7. Управление фокусом в модалках работает: входит в модалку, возвращается обратно.
  8. После сабмита есть aria-live-регион с результатом.
  9. Все интерактивные элементы доступны клавиатурой (Tab, Enter, Space, стрелки где нужно).
  10. Контраст текста и фона минимум 4.5:1 для обычного текста.

Десять пунктов, на которые уходит пара часов после написания формы. И благодаря этому форма реально работает для слепых пользователей, а не просто «не падает с ошибкой в axe-core».

Что запомнить

Доступность форм — это не отдельный этап перед релизом, а набор привычек, которые ты вырабатываешь один раз и применяешь автоматически. Метки, autocomplete, связь полей с ошибками, фокус — пять базовых вещей, которые покрывают большинство случаев.

Не пиши кастомные селекты с нуля, если можешь взять готовое. Не симулируй кнопки через div. И не верь только автоматическим тестам — пройди свою форму с клавиатуры и со скринридером хотя бы раз. Сразу станет понятно, где что не так.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.