Доступные формы — нативная валидация vs кастом

Половина форм в рунете ломают скринридер, перехватывают Tab и показывают ошибку красным текстом без `aria-describedby`. Разбираем, что даёт нативная HTML-валидация бесплатно, где её недостаточно, и как сделать кастомную без потери доступности.

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

По данным WebAIM 2025, 86% форм на топ-миллион сайтов имеют хотя бы одну ошибку accessibility. У русскоязычного сегмента доля выше — потому что половина команд верит, что accessibility «нужен только для госсайтов».

Что нативный HTML даёт бесплатно

Браузер сам делает много полезного, если вы не мешаете:

  • <label for="..."> связывает подпись и поле — скринридер читает «Имя, обязательно, edit text».
  • required блокирует сабмит и показывает сообщение от браузера.
  • type="email", type="tel", type="url" — валидация формата и нужная клавиатура на мобиле.
  • pattern="..." + title="..." — кастомный regex и сообщение для скринридера.
  • autocomplete="name|email|tel" — менеджер паролей и автозаполнение.
  • inputmode="numeric|decimal" — цифровая клавиатура без смены типа.

Минимальная доступная форма заявки

<form action="/lead" method="post" novalidate>
  <div>
    <label for="name">Имя</label>
    <input id="name" name="name" type="text" required
           autocomplete="name" aria-describedby="name-hint">
    <p id="name-hint">Как к вам обращаться</p>
  </div>
  <div>
    <label for="phone">Телефон</label>
    <input id="phone" name="phone" type="tel" required
           autocomplete="tel" inputmode="tel"
           pattern="\+?[0-9 ()\-]{7,}"
           title="Минимум 7 цифр, можно +, пробелы, скобки">
  </div>
  <button type="submit">Отправить</button>
</form>

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

Почему ставят novalidate

Нативное сообщение «Please fill out this field» — на языке браузера, не сайта. У русского пользователя оно может быть на английском. Это раздражает дизайнера и часто становится поводом отрубить нативку через novalidate и нарисовать всё самим.

Это работает только если дальше реализованы все 6 пунктов из списка ниже. Иначе форма становится недоступной.

Шесть требований к кастомной валидации

  1. Каждое поле имеет видимый label. Placeholder — не замена. Он исчезает при вводе и не читается скринридером как подпись.
  2. Ошибка связана с полем через aria-describedby. Иначе скринридер прочитает только название поля, без сообщения.
  3. Поле в ошибке имеет aria-invalid="true". Скринридер скажет «invalid».
  4. Сообщение об ошибке появляется в role="alert" или aria-live="polite". Иначе скринридер не уведомит о новой ошибке после сабмита.
  5. Фокус после сабмита прыгает на первое невалидное поле. Иначе пользователь клавиатуры теряется.
  6. Цвет — не единственный сигнал ошибки. Дальтоники, плохое освещение, тёмная тема — везде один красный border читается как «обычный». Нужна иконка, текст, рамка с pattern.

Минимальный кастом-вариант

<div>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required
         aria-describedby="email-error" aria-invalid="false">
  <p id="email-error" role="alert"></p>
</div>
<script>
const input = document.querySelector('#email');
const error = document.querySelector('#email-error');
input.addEventListener('invalid', e => {
  e.preventDefault();
  input.setAttribute('aria-invalid', 'true');
  error.textContent = input.validationMessage; // нативное сообщение
});
input.addEventListener('input', () => {
  if (input.checkValidity()) {
    input.setAttribute('aria-invalid', 'false');
    error.textContent = '';
  }
});
</script>

Здесь мы оставили нативную валидацию (constraint validation API), но рендерим сообщение сами в нужном месте, с правильным aria-describedby и role="alert".

Когда без JS-валидации не обойтись

Нативка не покрывает:

  • Серверную проверку (email уже занят, промокод недействителен).
  • Кросс-полевую логику (пароль = подтверждение, дата выезда после даты заезда).
  • Условные обязательные поля (юрлицо → ИНН обязателен, физлицо — нет).
  • Кастомные сообщения на языке сайта.
  • Маски ввода (телефон, дата, карта).

Маски — главный убийца accessibility

Половина библиотек масок (inputmask, imask, react-input-mask) ломают:

  • Вставку из буфера — пользователь копирует «+7 (999) 123-45-67», получает кашу.
  • Голосовой ввод — Android Voice Input не понимает наполовину заполненную маску.
  • Скринридер — VoiceOver читает каждый символ маски при движении курсора.
  • Менеджер паролей — не понимает, куда вставить телефон.

Лучший паттерн в 2026 — без маски, но с inputmode="tel" и нормализацией на сервере. Пользователь пишет как удобно (с пробелами, скобками, +7 или 8), сервер приводит к +7XXXXXXXXXX. Это не ломает accessibility и не раздражает на мобиле.

Чекбоксы и радиокнопки

Группы обязательно оборачивать в <fieldset> с <legend>. Иначе скринридер прочитает «checkbox, not checked» без контекста, что выбираем.

<fieldset>
  <legend>Способ связи</legend>
  <label><input type="radio" name="contact" value="call"> Звонок</label>
  <label><input type="radio" name="contact" value="tg"> Telegram</label>
  <label><input type="radio" name="contact" value="email"> Email</label>
</fieldset>

Согласие на обработку данных

Чекбокс «Согласен на обработку персональных данных» — отдельная боль. По 152-ФЗ согласие должно быть осознанным. В UX это значит:

  • Чекбокс не должен быть по умолчанию отмечен.
  • Текст согласия — кликабельная ссылка на политику в новом табе (target="_blank" rel="noopener").
  • Поле обязательное — не отправляется без галки.
  • В описании ошибки конкретно: «Подтвердите согласие на обработку данных», не «Заполните обязательные поля».

Live region для статуса сабмита

После нажатия «Отправить» — что произошло, скринридер не узнает без aria-live.

<p id="form-status" role="status" aria-live="polite"></p>
<script>
form.addEventListener('submit', async e => {
  e.preventDefault();
  status.textContent = 'Отправляем заявку...';
  const res = await fetch(form.action, { method:'POST', body: new FormData(form) });
  status.textContent = res.ok
    ? 'Заявка отправлена. Свяжемся в течение часа.'
    : 'Ошибка отправки. Попробуйте ещё раз или напишите в Telegram.';
});
</script>

Чеклист на час

  1. У каждого <input> есть <label> со связью через for/id.
  2. Placeholder не заменяет label.
  3. Все обязательные поля помечены required.
  4. Сообщение об ошибке связано через aria-describedby.
  5. При ошибке выставляется aria-invalid="true".
  6. Контейнер ошибки имеет role="alert" или aria-live="polite".
  7. Группы радио/чекбоксов в <fieldset> с <legend>.
  8. Цвет — не единственный сигнал.
  9. Фокус после сабмита уходит на первое невалидное поле.
  10. Тестируйте Tab-навигацией без мыши — все элементы достижимы.
  11. Тестируйте VoiceOver (Cmd+F5 на macOS) или NVDA — поле читается осмысленно.

Вывод

Нативный HTML даёт большую часть accessibility бесплатно — нужно не мешать. Кастомная валидация оправдана только если соблюдены 6 требований по ARIA-связкам и фокусу. На лендинге студии накатывать что-то сложнее формы из 3 полей с нативной валидацией + лёгким JS-перехватом сообщений — обычно overkill.