Доступные формы

Форма — самое хрупкое место сайта: именно здесь пользователь уходит, если разметка сделана «на глаз».

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

Зачем это на практике

Представьте незрячего пользователя, который оформляет заказ. Он идёт по полям клавишей Tab, а скринридер (программа экранного доступа) читает вслух то, что «видит» в дереве доступности: имя поля, тип, состояние. Если подписи нет, он услышит: «поле ввода, редактирование». И всё. Какое поле? Индекс? Квартира? Отчество? Он вернётся, попробует угадать по контексту — и в половине случаев просто уйдёт.

Формы — это точка, где сайт зарабатывает: регистрация, оплата, заявка, подписка. Поэтому дефект доступности в форме бьёт по деньгам напрямую, а не «когда-нибудь при проверке». И заметьте: правильная разметка помогает не только незрячим. Подпись, связанная с полем, увеличивает область попадания — клик по слову «Согласен» ставит галочку в чекбокс. Это выигрыш для человека с тремором рук, для того, кто держит телефон одной рукой в метро, и вообще для всех.

label и связь с полем — ошибка номер один

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

Плохо:

<p>Электронная почта</p>
<input type="email" name="email">

Мы, зрячие, связываем текст и поле близостью. Браузер так не умеет: для него это просто абзац и, отдельно, безымянный инпут.

Хорошо, способ 1 — for и id:

<label for="email">Электронная почта</label>
<input id="email" type="email" name="email" autocomplete="email">

Хорошо, способ 2 — обёртка (когда проставить уникальный id неудобно):

<label>
  Электронная почта
  <input type="email" name="email" autocomplete="email">
</label>

По умолчанию выбирайте первый способ: он надёжнее в старых скринридерах и не мешает вёрстке.

Способ 3 — aria-label, когда видимой подписи нет по дизайну (строка поиска с иконкой лупы):

<input type="search" name="q" aria-label="Поиск по сайту">

placeholder — это не подпись

Соблазн велик: серый текст внутри поля выглядит чисто, дизайнер доволен. Но placeholder проваливается сразу по четырём пунктам:

  • он исчезает при вводе — проверить, что именно ты заполнил, уже нельзя;
  • у него по умолчанию низкий контраст, и часто он не проходит даже минимальные требования;
  • скринридеры обрабатывают его по-разному: кто-то молчит, кто-то читает вместо имени;
  • его ломает автозаполнение и автоперевод страницы.

Вывод простой: placeholder — это пример формата, а не подпись. «+7 (999) 000-00-00» — уместно. «Телефон» — нет.

ПриёмКогда использоватьПодпись видна?
<label for="…">по умолчанию, в 90% случаевда
<label>-обёрткакогда нельзя проставить idда
aria-labelподписи нет по дизайну (иконка-поиск)нет
aria-labelledbyподпись уже есть в другом элементе (заголовок, ячейка)да

Подсказки и обязательность

Подпись отвечает на вопрос «что это за поле». Подсказка — на вопрос «как его заполнить». Связывать её нужно через aria-describedby: скринридер прочитает её после имени поля.

<label for="pwd">Пароль</label>
<input id="pwd" type="password" autocomplete="new-password"
       aria-describedby="pwd-hint">
<p id="pwd-hint">Минимум 8 символов, хотя бы одна цифра.</p>

Ключевое отличие от aria-label: описание дополняет имя, а не заменяет его. Пользователь услышит «Пароль, поле ввода, минимум 8 символов, хотя бы одна цифра».

Обязательность передаёт атрибут required: он и валидирует форму, и попадает в дерево доступности как состояние «обязательно». Звёздочка в подписи — визуальный дубль, и его обязательно нужно расшифровать в начале формы: «Поля со звёздочкой обязательны». Если вы валидируете сами и required не ставите, добавьте aria-required="true".

Приём, который экономит всем нервы: если из восьми полей семь обязательные, помечайте необязательные. «(необязательно)» рядом с одним полем читается лучше, чем семь звёздочек.

И не забывайте про autocomplete. Это тоже доступность (критерий WCAG 1.3.5): браузер подставит имя, адрес, телефон, и человеку с когнитивными трудностями или нарушением моторики не придётся печатать всё вручную.

Сообщения об ошибках, которые реально доходят

Типичная картина: поле краснеет, под ним появляется текст, страница остаётся на месте. Для зрячего пользователя — нормально. Для скринридера — не произошло ничего. Фокус остался на кнопке «Отправить», новый текст в DOM его никак не касается, а красная рамка — это CSS, которого в дереве доступности не существует в принципе.

Чтобы ошибка дошла, нужны три вещи:

  1. aria-invalid="true" на поле — состояние «здесь ошибка»;
  2. текст ошибки, связанный с полем через aria-describedby — чтобы он читался вместе с полем при возврате к нему;
  3. «живая область» (role="alert") — чтобы сообщение объявили сразу, как только оно появилось.
<label for="inn">ИНН</label>
<input id="inn" name="inn" inputmode="numeric"
       aria-invalid="true" aria-describedby="inn-error">
<p id="inn-error" role="alert">ИНН состоит из 10 или 12 цифр. Вы ввели 9.</p>

Что услышит пользователь:

ИНН, поле ввода, недопустимое значение.
ИНН состоит из 10 или 12 цифр. Вы ввели 9.

Отдельно про сам текст. «Ошибка», «Неверное значение», «Поле заполнено некорректно» — сообщения-пустышки: они констатируют факт и не помогают. Хорошее сообщение говорит, что не так и что сделать: «Дата в формате ДД.ММ.ГГГГ, например 05.09.1998».

Сводка ошибок сверху

Для длинных форм после отправки показывайте сводку и переводите на неё фокус:

<div id="form-errors" role="alert" tabindex="-1">
  <h2>Форма не отправлена: 2 ошибки</h2>
  <ul>
    <li><a href="#email">Электронная почта: адрес без символа @</a></li>
    <li><a href="#inn">ИНН: должно быть 10 или 12 цифр</a></li>
  </ul>
</div>

tabindex="-1" делает контейнер фокусируемым программно (через .focus()), но не добавляет его в обход по Tab. Пользователь сразу узнаёт, сколько ошибок, и по ссылке прыгает к нужному полю. Такая сводка — единственное, что спасает в форме на 30 полей.

И анти-совет, который встречается повсеместно: не блокируйте кнопку «Отправить», пока форма невалидна. Отключённая кнопка ничего не объясняет, часто не получает фокус и превращается в тупик — человек видит серую кнопку и не понимает, что от него хотят. Пусть кнопка всегда нажимается, а по клику появляется сводка.

Группировка: fieldset и legend

Одиночной подписи достаточно, пока поле самодостаточно. Но у радиокнопок подпись есть у каждой опции, а вопрос — общий:

<fieldset>
  <legend>Способ доставки</legend>
  <label><input type="radio" name="delivery" value="pickup"> Самовывоз</label>
  <label><input type="radio" name="delivery" value="courier"> Курьер</label>
</fieldset>

Скринридер прочитает: «Способ доставки, Самовывоз, переключатель, 1 из 2». Без fieldset он скажет только «Самовывоз, переключатель» — и человек не понимает, на какой вопрос отвечает.

Те же правила работают для группы чекбоксов («Что вас интересует») и для смысловых блоков длинной формы: «Данные получателя», «Адрес доставки», «Оплата». Если fieldset мешает вёрстке (у него есть свои особенности отображения), эквивалент — role="group" плюс aria-labelledby, указывающий на заголовок блока.

Как это работает

Браузер строит из DOM дерево доступности (accessibility tree) — упрощённую копию страницы, где у каждого узла есть роль, имя, состояние и значение. Скринридер, голосовое управление и брайлевский дисплей читают именно его, а не ваш красивый CSS.

Имя элемента (accessible name) вычисляется по строгому алгоритму, приоритет сверху вниз:

  1. aria-labelledby — побеждает всё;
  2. aria-label;
  3. связанный <label> (через for/id или обёртку);
  4. запасные варианты вроде title и placeholder — последняя надежда, вести себя могут по-разному.

Отсюда важнейшее практическое следствие: aria-label молча перебивает видимую подпись. Если в разметке <label>Телефон</label>, а на инпуте висит aria-label="phone", пользователь услышит «phone». Ломается и голосовое управление: человек говорит «нажми Телефон», а система ищет элемент с именем «phone» и не находит. Это отдельный критерий WCAG 2.5.3 «Label in Name»: доступное имя должно содержать видимый текст.

Состояния (required, aria-invalid, disabled) кладутся в то же дерево. Поэтому менять их нужно атрибутами и ARIA, а не классами CSS: класс .is-error в дерево доступности не попадает вообще.

Частые ошибки

ОшибкаЧем плохаКак правильно
placeholder вместо подписиисчезает при вводе, низкий контраст, нестабильно читается<label>, а placeholder — только под пример формата
Красная рамка без текстасмысл передан только цветом; для скринридера — ничегоaria-invalid + текст ошибки + aria-describedby
Сообщение об ошибке появляется без role="alert"текст в DOM есть, но его никто не объявляетживая область или перевод фокуса на сводку
<div onclick> вместо кнопкине фокусируется, не жмётся Enter/Space, роли нет<button type="submit">
aria-label, не совпадающий с видимой подписьюломает голосовое управление (WCAG 2.5.3)доступное имя включает видимый текст
Кнопка «Отправить» disabled до валидациитупик: не объясняет, что не таккнопка активна, после клика — сводка ошибок
Радиогруппа без fieldsetнепонятно, на какой вопрос отвечает выборfieldset + legend

Чек-лист доступной формы

[ ] У каждого поля есть label с for/id (или aria-label, если подписи нет по дизайну)
[ ] placeholder не подменяет подпись
[ ] Обязательность передана через required / aria-required, а не только звёздочкой
[ ] Подсказки связаны через aria-describedby
[ ] У поля с ошибкой стоит aria-invalid="true"
[ ] Текст ошибки связан с полем и объявляется (role="alert")
[ ] Текст ошибки говорит, ЧТО сделать, а не только «неверно»
[ ] Радио- и чекбокс-группы обёрнуты в fieldset + legend
[ ] Проставлен autocomplete там, где это персональные данные
[ ] Форму можно заполнить и отправить, не трогая мышь

Итоги

  • Подпись без программной связи — не подпись. for/id или обёртка <label> — по умолчанию.
  • placeholder — пример формата, а не имя поля. Никогда не заменяйте им label.
  • Подсказка — через aria-describedby: она дополняет имя, а aria-label его заменяет.
  • Ошибка доходит только при сочетании трёх вещей: aria-invalid + связанный текст + role="alert" (или перевод фокуса на сводку).
  • Радиокнопки и чекбоксы — в fieldset с legend, иначе выбор теряет вопрос.
  • Всё, что вы выражаете классами CSS, в дереве доступности не существует. Состояния — атрибутами.
Проверьте себя
1. Форма показывает ошибку так: поле обводится красной рамкой через CSS-класс .is-error, под полем появляется <p>Неверный формат</p>. Что услышит пользователь скринридера?
A«Недопустимое значение, неверный формат» — браузер сам сопоставит текст под полем
BНичего: ни красная рамка, ни новый абзац сами по себе не попадают в дерево доступности как ошибка поля
C«Неверный формат» — текст рядом с полем всегда читается автоматически
2. У поля есть видимая подпись <label for="phone">Телефон</label>, а разработчик дополнительно повесил на input aria-label="phone". Что произойдёт?
AСкринридер прочитает обе подписи: «Телефон, phone»
Baria-label будет проигнорирован, потому что label важнее
CСкринридер прочитает «phone»: aria-label перебивает label, и голосовая команда «нажми Телефон» перестанет работать