Доступность (a11y) Доступные формы: подписи, подсказки, ошибки, валидация
0%

Доступные формы: подписи, подсказки, ошибки, валидация

Доступные формы: подписи, подсказки, ошибки, валидация

Форма — это место, где интерфейс перестаёт быть текстом и становится сделкой. Здесь оформляют заказ, записываются к врачу, подают заявление, переводят деньги. Именно поэтому недоступная форма стоит дороже недоступной статьи: непрочитанную статью можно обойти, невыполненный платёж — нет. И именно поэтому в WCAG под формы отведён целый блок критериев (раздел 3.3 «Input Assistance»), а в WCAG 2.2 добавились ещё три новых, все три — про формы.

Мы уже разобрали фундамент: семантику, ARIA и клавиатуру с фокусом; общую рамку даёт вводная статья портала «Гайд по Accessibility». Здесь мы берём один сценарий и проходим его до конца: от подписи поля до момента, когда человек со скринридером узнаёт, что заказ ушёл. Механика самих компонентов ввода — комбобоксы, датапикеры, мультиселекты — в следующей статье.

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

Почему формы ломаются чаще всего

Ежегодный анализ WebAIM Million стабильно даёт одну и ту же картину: поля ввода без подписи — в верхней тройке самых частых нарушений на главных страницах миллиона сайтов, рядом с низким контрастом и картинками без alt. Причина не в сложности стандарта, а в том, что форму почти всегда собирают из дизайн-макета, а в макете подпись часто нарисована внутри поля — то есть как плейсхолдер, который для машины не подпись.

Вторая причина — форма это состояние: пустое, заполняемое, проверяемое, ошибочное, отправленное. Каждый переход нужно как-то сообщить. Зрячему пользователю переход сообщает цвет и движение; остальным — ничего, если разработчик не сделал это явно. Поле, которое покраснело, для скринридера просто не изменилось. Вот карта требований, вокруг которых крутится вся глава.

Три критерия появились именно в WCAG 2.2 и целятся ровно в формы: 3.3.7 Redundant Entry (не заставляйте вводить одно и то же дважды в рамках одного процесса), 3.3.8 Accessible Authentication (Minimum) (вход не должен требовать запоминания или расшифровки — разрешайте вставку пароля и автозаполнение) и 2.5.7 Dragging Movements (если поле заполняется перетаскиванием ползунка, должен быть способ без перетаскивания). Полный текст — в WCAG 2.2 и Understanding WCAG 2.2.

Анатомия доступного поля

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

Анатомия доступного поля: подпись, значение, подсказка и ошибка и то, во что каждая часть превращается в озвучке

Часть интерфейса Во что превращается Чем задаётся
Видимая подпись имя (name) <label for>, обёртка <label>, aria-labelledby
Тип контрола роль (role) сам тег: input, select, textarea
Введённый текст значение (value) содержимое поля
Обязательность состояние required (или aria-required)
Ошибка состояние aria-invalid="true"
Подсказка и текст ошибки описание (description) aria-describedby
Красная рамка, иконка, отступ ничего только CSS

Последняя строка — главная мысль всей статьи. Всё, что вы выразили цветом, положением или иконкой, для ассистивной технологии не существует, пока не выражено в разметке; это же требование стоит в WCAG 1.4.1 Use of Color (A). Проверить результат можно не устанавливая скринридер: DevTools → вкладка Accessibility → панель Accessibility Tree. Для сфокусированного поля там видно Name, Role, Description, invalid, required. Если Name пустое — поле безымянное, и никакие красивые подписи вокруг не помогли.

Подписи

Три законных способа подписать поле

<!-- 1. Явная связь: самый надёжный вариант, подпись может стоять где угодно -->
<label for="phone">Телефон</label>
<input id="phone" name="phone" type="tel" autocomplete="tel">

<!-- 2. Обёртка: связь по вложенности, id не нужен -->
<label>
  Телефон
  <input name="phone" type="tel" autocomplete="tel">
</label>

<!-- 3. Ссылка на существующий текст, когда подпись — не <label> -->
<h2 id="delivery-title">Адрес доставки</h2>
<input aria-labelledby="delivery-title" name="address">

Первый вариант — рабочая лошадка. У него есть бонус, которого не даёт aria-label: клик по подписи ставит фокус в поле и переключает чекбокс, то есть увеличивает зону попадания — важно и для людей с тремором, и для всех на телефоне. aria-labelledby перебивает <label> (см. порядок вычисления имени в ACCNAME), поэтому одновременно их не используют. А aria-label для полей формы — крайняя мера. Он не виден на экране, не находится поиском по странице, не переводится автопереводчиком и молча протухает при рефакторинге. Уместен там, где видимой подписи объективно нет — поле поиска с одной лупой, ячейка таблицы с редактируемым значением. Но если подпись есть визуально, aria-label с другим текстом ломает голосовое управление и нарушает 2.5.3 Label in Name (A): человек говорит «нажать Телефон», а система ищет контрол с именем «Введите номер мобильного» и не находит.

Плейсхолдер — не подпись

placeholder — самая частая причина безымянных полей, и проблем у него сразу пять. Он исчезает при вводе — человек, вернувшийся к полузаполненной форме, уже не знает, что в поле (бьёт по всем, кто заполняет форму в три подхода, а не только по людям с нарушениями памяти и внимания). Он низкоконтрастен по умолчанию и обычно не проходит 1.4.3 Contrast (Minimum), а если поднять контраст — станет неотличим от реального значения. Он непредсказуемо озвучивается: по ACCNAME это предпоследний источник имени, и связки используют его по-разному. Он ломает автозаполнение и автоперевод. И он выглядит как заполненное поле — поле пропускают, а потом не понимают, почему форма не отправляется.

Правильно: подпись сверху, плейсхолдер — только пример значения, и то не обязательно.

<!-- Плохо -->
<input type="text" placeholder="Дата рождения">

<!-- Хорошо: подпись, формат в описании, плейсхолдер как пример -->
<label for="bday">Дата рождения</label>
<input id="bday" type="text" inputmode="numeric" autocomplete="bday"
       placeholder="31.12.1990" aria-describedby="bday-hint">
<p id="bday-hint">Формат: день.месяц.год</p>

Плавающие подписи и скрытые подписи

Floating label (подпись, уезжающая наверх при фокусе) технически корректен — это настоящий <label>, просто анимированный CSS. Цена в другом: в уменьшенном виде подпись обычно 11–12 px и низкоконтрастная, а при масштабировании текста до 200% (1.4.4 Resize Text) часто налезает на значение. Берёте паттерн — проверьте его при 200% и при prefers-reduced-motion.

Визуально скрытая подпись — законный инструмент, если контекст рядом делает назначение очевидным зрячему:

/* Скрыть визуально, оставить в дереве доступности. НЕ display:none и не visibility:hidden */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
<form role="search">
  <label for="q" class="visually-hidden">Поиск по каталогу</label>
  <input id="q" type="search" name="q">
  <button type="submit">Найти</button>
</form>

Помните про голосовое управление: если подписи не видно, человек не знает, как её произнести. Для поиска это неважно (скажут «нажать Найти»), для формы из десяти скрытых подписей — важно.

Группы полей

Одиночная подпись отвечает на вопрос «что это за поле». Групповая — на вопрос «в каком контексте». Радиокнопке «Курьером» бессмысленно без вопроса «Способ доставки».

<fieldset>
  <legend>Способ доставки</legend>
  <input type="radio" id="d-courier" name="delivery" value="courier" checked>
  <label for="d-courier">Курьером, 400 ₽</label>
  <input type="radio" id="d-pickup" name="delivery" value="pickup">
  <label for="d-pickup">Самовывоз, бесплатно</label>
</fieldset>

Скринридер озвучит примерно так: «Способ доставки, группа. Курьером, 400 рублей, переключатель, отмечен, 1 из 2». Без <fieldset> человек услышит только «Курьером, 400 рублей, переключатель» — и не поймёт, о чём вопрос. Формально это 1.3.1 Info and Relationships (A): визуальная группировка рамкой обязана иметь программный эквивалент.

Что важно на практике:

  • <legend> — первый потомок <fieldset>. Иначе браузер не считает его подписью группы.
  • Не оборачивайте всю форму в один fieldset. Длинный legend в некоторых связках повторяется при чтении каждого поля. Группируйте то, что действительно группа: набор радиокнопок, чекбоксы на один вопрос, адрес, дата из трёх селектов.
  • Стилизовать fieldset неудобно — наследие старого CSS. Альтернатива с той же семантикой: <div role="group" aria-labelledby="addr-title"> с заголовком внутри, для радиокнопок — role="radiogroup".

Отдельный случай — один чекбокс-согласие. Его подпись должна быть самодостаточной: не «Согласен», а «Согласен с условиями обработки данных» со ссылкой внутри. Ссылка внутри <label> — законно, но по ней нельзя кликнуть без срабатывания чекбокса; надёжнее вынести ссылку за пределы подписи.

Тип поля, клавиатура и автозаполнение

Тип поля — самый дешёвый способ помочь всем сразу. На телефоне он меняет клавиатуру, в браузере включает нативную валидацию и подсказки, а скринридеру сообщает роль.

Задача Тег и тип Почему так
Почта type="email" autocomplete="email" клавиатура с @, нативная проверка формата
Телефон type="tel" autocomplete="tel" цифровая клавиатура, не режет + и скобки
Число, которое не считают (карта, СНИЛС, код) type="text" inputmode="numeric" type="number" съедает ведущие нули и крутится колесом мыши
Количество товара type="number" со step, min, max здесь спиннер уместен
Пароль type="password" autocomplete="current-password" менеджеры паролей и 3.3.8
Код из SMS inputmode="numeric" autocomplete="one-time-code" автоподстановка из уведомления
Поиск type="search" роль searchbox, кнопка очистки
Длинный текст <textarea> изменяемый размер, многострочность

Ключевой критерий здесь — 1.3.5 Identify Input Purpose (AA): если поле спрашивает данные о самом пользователе, его назначение должно быть указано машиночитаемо, то есть атрибутом autocomplete из фиксированного списка токенов HTML. Практический смысл шире формального: autocomplete — это то, что позволяет человеку с моторными нарушениями или дислексией не набирать адрес руками, а вставить его одним нажатием. Плюс некоторые расширения подставляют по этим токенам иконки-символы для пользователей с когнитивными особенностями.

Ходовые токены: given-name, family-name, email, tel, street-address, postal-code, cc-number, current-password для входа и new-password для регистрации и смены пароля. Три частые ошибки: autocomplete="off" на всей форме «ради безопасности» (это прямое нарушение 1.3.5 и антипаттерн — люди начнут вводить пароли руками или упрощать их); блокировка вставки в поле пароля или кода (3.3.8 Accessible Authentication (Minimum), AA — вход не должен требовать когнитивного теста, а «вспомни и набери руками» именно им и является); разбиение кода из SMS на шесть отдельных input — автоподстановка ломается, скринридер читает шесть безымянных полей, а Backspace ведёт себя непредсказуемо. Одно поле с autocomplete="one-time-code" работает лучше во всём.

Ещё одно правило про ввод — 3.2.2 On Input (A): изменение значения не должно само по себе менять контекст. Классическое нарушение — <select>, который при выборе сразу отправляет форму или перезагружает страницу: пользователь клавиатуры проходит стрелками по вариантам и «улетает» на первом же. Правильно — выбрал, нажал «Применить»; автопереход фокуса в следующее поле после последнего символа допустим, но Backspace и Shift+Tab обязаны возвращать назад. И новое в 2.2 — 3.3.7 Redundant Entry (A): если внутри одного процесса вы уже спросили адрес, не спрашивайте снова — дайте чекбокс «совпадает с адресом доставки» или подставьте значение; для многошаговой формы это означает ещё и сохранение введённого при переходе «назад».

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

Подсказка связывается с полем через aria-describedby. Атрибут принимает список id через пробел, и порядок в списке задаёт порядок чтения:

<label for="pwd">Новый пароль</label>
<input id="pwd" type="password" autocomplete="new-password"
       aria-describedby="pwd-err pwd-rules">
<p id="pwd-err" class="error"></p>
<ul id="pwd-rules">
  <li>Минимум 12 символов</li>
  <li>Хотя бы одна цифра</li>
</ul>

Три правила, которые экономят часы отладки. Инструкции ставьте до поля, а не после: в режиме форм скринридер не читает текст между контролами — человек прыгает клавишей Tab с поля на поле и слышит только их, поэтому не связанный через aria-describedby абзац для него не существует, пока он не выйдет в режим просмотра. Описание не должно быть длинным — оно читается при каждом входе в поле, и список из восьми правил пароля утомит; держите одну-две фразы, детали выносите в раскрывающийся блок. Битый id обнуляет описание молча — самая частая ARIA-ошибка в проде, ловится скриптом из раздела про ручную проверку.

Обязательность отмечают атрибутом required. Он делает сразу три вещи: включает нативную валидацию, ставит состояние required в дереве доступности и активирует CSS-псевдокласс :required. aria-required="true" нужен только там, где нативного атрибута нет — на самодельном комбобоксе из div.

<label for="name">Имя <span aria-hidden="true">*</span></label>
<input id="name" required autocomplete="given-name">

Звёздочку помечают aria-hidden="true", чтобы имя не превратилось в «Имя звёздочка»: состояние «обязательно» и так придёт из required. В начале формы обязательна легенда «Поля со звёздочкой обязательны» — по 3.3.2 Labels or Instructions (A) символ нужно расшифровать. Ещё лучше — писать «необязательно» у редких необязательных полей: когда обязательны 90% полей, звёздочки превращаются в визуальный шум.

И предупреждение про disabled: отключённая кнопка «Отправить» — популярный, но плохой паттерн: она не получает фокус, не читается скринридером, не объясняет, чего не хватает, и человек просто не понимает, почему ничего не происходит. Лучше держать кнопку активной и по нажатию показывать сводку ошибок. Если состояние «пока нельзя» действительно нужно — aria-disabled="true" плюс отмена действия в обработчике: элемент остаётся в таб-цепочке и может объяснить причину.

Валидация: когда проверять

Половина проблем с ошибками — не в разметке, а в моменте. Валидация «на каждый символ» превращает ввод почты в поток ругани: «недопустимое значение» после i, после iv, после iva… Для скринридера это буквально речь, перебивающая эхо-ввод; для человека с тревожностью — неприятный опыт; для остальных — шум. Рабочая стратегия описывается одним автоматом.

Три правила автомата: до первого blur ошибок не показываем (человек ещё не закончил); ошибку снимаем немедленно, как только значение стало корректным, не дожидаясь blur — эта асимметрия «показывать с задержкой, убирать мгновенно» воспринимается как помощь, а не как контроль; на submit проверяем всё, включая нетронутые поля, и показываем сводку.

В CSS этой логике почти точно соответствует пара :user-invalid / :user-valid — они срабатывают только после того, как пользователь реально взаимодействовал с полем, в отличие от :invalid, который красит пустое обязательное поле сразу при загрузке страницы.

input:user-invalid { border-color: #b3261e; }
input:user-invalid + .error-text { display: block; }
/* Не только цветом: добавляем толщину и иконку — WCAG 1.4.1 */
input:user-invalid { border-width: 2px; background-image: url("/icons/alert.svg"); }

Нативная валидация: что даёт браузер и где её граница

Constraint Validation API даёт бесплатно: проверку required, type="email", pattern, min/max/step, minlength/maxlength, состояние :invalid, объект validity и метод setCustomValidity. Границы у него две. Первая: всплывающие пузыри браузера недоступны — исчезают через несколько секунд, не связаны с полем через describedby, не стилизуются и озвучиваются по-разному. Вторая: браузер останавливается на первом невалидном поле, а нам нужна сводка по всем. Поэтому обычная схема — оставить нативные проверки источником истины, отключить браузерный UI атрибутом novalidate на <form> и нарисовать свой:

const form = document.getElementById("checkout");   // <form id="checkout" novalidate>
const labelText = (f) => f.labels?.[0]?.textContent.replace("*", "").trim() ?? f.name;

// Человекочитаемые тексты вместо «Please fill out this field»
const message = (f) => {
  const v = f.validity;
  if (v.valueMissing) return `Заполните поле «${labelText(f)}»`;
  if (v.typeMismatch && f.type === "email") return "Нужен адрес вида имя@домен";
  if (v.tooShort) return `Минимум ${f.minLength} символов, сейчас ${f.value.length}`;
  if (v.patternMismatch) return f.dataset.patternHint ?? "Значение в неверном формате";
  return f.validationMessage;  // запасной вариант от браузера
};

// Правила, которых нет в HTML. Сброс пустой строкой обязателен, иначе поле
// останется невалидным навсегда
confirm.addEventListener("input", () => {
  confirm.setCustomValidity(confirm.value === pwd.value ? "" : "Пароли не совпадают");
});

Ошибка у поля

Минимальный корректный контракт ошибки — три вещи одновременно:

<label for="email">Рабочая почта</label>
<!-- 1. состояние: aria-invalid; 2. связь с текстом: aria-describedby -->
<input id="email" type="email" required autocomplete="email"
       aria-invalid="true" aria-describedby="email-err email-hint">
<!-- 3. маркер помимо цвета: иконка плюс сам текст -->
<p id="email-err" class="error">
  <svg class="icon-alert" aria-hidden="true" focusable="false" viewBox="0 0 16 16"></svg>
  Нужен адрес вида имя@домен
</p>
<p id="email-hint">Используем только для чеков и писем о заказе.</p>

Детали, на которых спотыкаются:

  • aria-invalid ставят и снимают. Оставить "true" после исправления — распространённый баг, который делает форму «вечно сломанной» для скринридера; aria-invalid="false" равносилен отсутствию атрибута.
  • Не удаляйте параграф ошибки из DOM — переключайте hidden. Тогда aria-describedby не приходится пересобирать, а живая область успевает подхватить изменение.
  • Текст ошибки — в описание, а не в имя. Соблазн сделать aria-label="Почта — ошибка" велик, но тогда меняется имя поля и перестают работать голосовые команды.
  • aria-errormessage существует, но поддержка в связках браузер+скринридер до сих пор неровная, и он требует рядом aria-invalid="true". Практика: aria-describedby надёжнее, aria-errormessage — дополнительно, но не вместо.
  • Текст должен подсказывать, что делать — это 3.3.3 Error Suggestion (AA).
Плохо Хорошо
«Ошибка» «Нужен адрес вида имя@домен»
«Неверный формат» «Дата в формате 31.12.1990»
«Поле обязательно» «Заполните поле «Телефон» — по нему свяжется курьер»
«Пароль слабый» «Добавьте ещё 4 символа: сейчас 8, нужно 12»
Красная рамка без текста Рамка + иконка + текст рядом с полем

Сводка ошибок

Когда форма не отправилась, зрячий пользователь видит красное сверху и внизу; пользователь клавиатуры и скринридера не видит ничего — он нажал «Оформить», и наступила тишина. Сводка ошибок закрывает эту дыру и заодно решает задачу «в форме из 30 полей найти три сломанных».

Сводка ошибок: маршрут фокуса от кнопки отправки к списку ошибок и к конкретному полю

// Разметка: <div id="summary" tabindex="-1" hidden><h2>…<span id="summary-count">
//           </span></h2><ul id="summary-list"></ul></div>
form.addEventListener("submit", (event) => {
  const fields = [...form.elements].filter((el) => el.willValidate);
  const broken = fields.filter((el) => !el.checkValidity());
  fields.forEach((el) => setFieldError(el, broken.includes(el) ? message(el) : null));

  if (broken.length === 0) return;          // всё хорошо — отправляем как обычно
  event.preventDefault();

  // Порядок пунктов = порядок полей в DOM, а не порядок проверок
  summaryList.innerHTML = broken
    .map((el) => `<li><a href="#${el.id}">${labelText(el)}: ${message(el)}</a></li>`)
    .join("");
  summaryCount.textContent = plural(broken.length, "ошибка", "ошибки", "ошибок");
  summary.hidden = false;
  summary.focus();                          // фокус на контейнер: скринридер прочтёт заголовок и список
  summary.scrollIntoView({ block: "start", behavior: "smooth" });
});

// Контейнер ошибки существует в разметке заранее — меняем только текст и hidden
function setFieldError(field, text) {
  const box = document.getElementById(`${field.id}-err`);
  box.textContent = text ?? "";
  box.hidden = !text;
  if (text) field.setAttribute("aria-invalid", "true");
  else field.removeAttribute("aria-invalid");
}

Почему tabindex="-1" и focus(), а не role="alert" на сводке? Перевод фокуса делает две вещи сразу: озвучивает содержимое и физически переносит человека к месту проблемы, тогда как role="alert" только зачитает текст — курсор останется на кнопке, и список придётся искать руками. После явного действия пользователя фокус лучше; если же сводка обновляется сама (скажем, по blur поля), двигать фокус нельзя — тогда живая область. И важная деталь: ссылки в сводке ведут на id самого поля ввода, а не его подписи; тогда после Enter фокус оказывается в поле, и скринридер зачитывает имя, состояние «недопустимое значение» и текст ошибки из describedby — можно сразу исправлять.

Что происходит при отправке: полный маршрут

Ветка успеха — та, о которой забывают чаще всего. Если после отправки страница молча заменила форму на «Спасибо», человек со скринридером этого не услышит: фокус остался на исчезнувшей кнопке, то есть уехал на <body>. Правильно — либо перевести фокус на заголовок результата с tabindex="-1", либо (при переходе на новый URL) убедиться, что фокус переносится в начало нового экрана. Это же требование стоит за 4.1.3 Status Messages (AA).

Живые области: когда без фокуса

Фокус нельзя двигать без явного действия пользователя — для сообщений, которые появляются «сами», используют живые области.

Ситуация Инструмент Почему
Ошибка после submit focus() на сводку человек нажал кнопку, ждёт реакции
«Проверяем промокод…» → «Промокод применён» role="status" (aria-live="polite") не перебивает, не крадёт фокус
«Сессия истекает через минуту» role="alert" (aria-live="assertive") срочно, перебить допустимо
Счётчик «осталось 120 символов» aria-live="polite" + throttle иначе будет читаться на каждый символ
Асинхронная проверка «логин занят» role="status" рядом с полем + describedby ошибка догнала после blur

Три грабли живых областей: контейнер должен существовать в DOM до появления текста (пустой <p role="status"> в разметке); внутрь кладут только текст, менять hidden у самого контейнера ненадёжно; assertive перебивает всё, включая эхо набираемых символов, поэтому для валидации по ходу ввода не годится. Подробнее — в статьях про ARIA и скринридеры.

Как это слышно на самом деле

Практический момент, который меняет отношение ко всей главе: у скринридеров есть два режима. В режиме просмотра (browse/virtual) стрелки листают весь текст страницы; в режиме форм (focus/forms) стрелки попадают внутрь поля и печатают. Переключение обычно автоматическое — при попадании фокуса в input NVDA сам уходит в режим форм. Следствие: в режиме форм читается только то, что связано с полем. Заголовок над полем — да, если он <label> или aria-labelledby. Абзац с предупреждением между полями — нет. Красная плашка внизу формы — нет. Отсюда все правила выше.

Минимум клавиш, чтобы пройти форму сегодня (подробный разбор — в статье про скринридеры):

NVDA (Windows, бесплатно): Ctrl+Alt+N — запуск, Insert+Q — выход, Ctrl — заткнуть речь
  Tab — следующий контрол; F — следующее поле формы; Insert+F7 — список элементов;
  Insert+Пробел — переключить режим форм вручную
VoiceOver (macOS, встроен): Cmd+F5 — вкл/выкл, VO = Ctrl+Option
  Tab — следующий контрол; VO+Cmd+J — следующий контрол формы; VO+U — ротор с полями

Что слушать:

  1. При входе в каждое поле звучит осмысленное имя? Не «правка, пусто», не «текстовое поле».
  2. Звучит ли «обязательно» там, где это обязательное поле?
  3. После неудачной отправки — вы узнали об ошибке, не глядя на экран?
  4. Перейдя в сломанное поле, вы слышите что именно не так?
  5. После успешной отправки — вы поняли, что заказ прошёл?

Если хотя бы на один вопрос ответ «нет», форма непроходима без зрения — независимо от того, что показал автоматический сканер.

Особые случаи

  • Многошаговые формы. Прогресс не только цветом: «Шаг 2 из 4: доставка» текстом, в <h1> или рядом; при переходе фокус переводят на заголовок шага; данные предыдущих шагов сохраняются (3.3.7), и кнопка «Назад» не должна терять ввод — это самая частая жалоба.
  • Ограничение по времени. Сессия, истекающая на середине формы, — нарушение 2.2.1 Timing Adjustable (A), если её нельзя продлить. Минимум: предупредить заранее через role="alert", дать кнопку «Продлить», не потерять фокус после неё и сохранить черновик. Заполнение формы скринридером или свитчем занимает в разы больше времени — таймаут в три минуты гарантированно отсекает часть пользователей.
  • Ответственные действия. 3.3.4 Error Prevention (Legal, Financial, Data), AA требует для юридических обязательств, денежных операций и удаления данных одного из трёх: отменяемость, проверка с возможностью исправить или явное подтверждение. Шаг «проверьте заказ» — не UX-каприз, а критерий.
  • CAPTCHA. Картинка с искажённым текстом — барьер, который часто нельзя обойти, и ровно тот когнитивный тест, против которого написан 3.3.8. Современный подход: невидимые поведенческие проверки и токены, honeypot-поля, rate limiting; если капча всё же нужна — обязательна альтернатива в другой модальности. См. Inaccessibility of CAPTCHA.
  • Загрузка файлов. Нативный <input type="file"> доступен; кастомная зона drag-and-drop — нет, если перетаскивание единственный способ (2.5.7, AA). Прячьте input через .visually-hidden, но не display: none (иначе он выпадет из таб-цепочки), и показывайте список выбранных файлов и прогресс текстом в role="status".
  • Маски ввода. Маска, переставляющая курсор и подставляющая символы, конфликтует с эхо-вводом скринридера и автозаменой на мобильных: человек слышит не то, что печатает. Безопаснее принимать любой формат и нормализовать значение на blur, сообщив результат: «Принято как +7 903 000-00-00».
  • Кастомные селекты, автодополнение, датапикеры — самая дорогая часть форм, о ней следующая статья. Правило по умолчанию: пока не доказано обратное, нативные <select>, <input type="date"> и <datalist> доступнее любой самописной замены.

Проверка руками за пять минут

Порядок, который ловит подавляющее большинство проблем:

  1. Кликните по каждой подписи. Фокус прыгнул в поле, чекбокс переключился? Нет — подпись не связана.
  2. Пройдите форму табом. Все поля достижимы, кольцо фокуса видно, порядок совпадает с визуальным?
  3. Отправьте пустую форму. Вы узнали об ошибке без взгляда на экран? Куда ушёл фокус?
  4. Увеличьте до 200% и 400% и отключите цвет (DevTools → Rendering → Emulate vision deficiencies → Achromatopsia): подписи не наехали на значения, ошибочные поля всё ещё отличимы?
  5. Проверьте автозаполнение менеджером паролей или адресами браузера, а затем включите скринридер и пройдите пять вопросов из раздела выше.

Плюс скрипт в консоли — механические поломки он находит за секунду:

// 1. Поля без доступного имени; в колонке placeholder видно «мнимые» подписи
console.table([...document.querySelectorAll("input:not([type=hidden]),select,textarea")]
  .filter((el) => !el.labels?.length && !el.getAttribute("aria-label") && !el.getAttribute("aria-labelledby"))
  .map((el) => ({ tag: el.tagName, type: el.type, name: el.name, placeholder: el.placeholder, el })));

// 2. Битые ссылки в aria-describedby и aria-labelledby — молча обнуляют описание
for (const el of document.querySelectorAll("[aria-describedby],[aria-labelledby]"))
  for (const attr of ["aria-describedby", "aria-labelledby"])
    (el.getAttribute(attr) ?? "").split(/\s+/).filter(Boolean)
      .filter((id) => !document.getElementById(id))
      .forEach((id) => console.warn(`битый ${attr}="${id}"`, el));

// 3. Персональные поля без autocomplete (1.3.5) и незакрытый aria-invalid
[...document.querySelectorAll("input")]
  .filter((el) => /mail|phone|tel|name|address|city|zip|card/i.test(el.name + el.id) && !el.autocomplete)
  .forEach((el) => console.warn("нет autocomplete", el));
[...document.querySelectorAll('[aria-invalid="true"]')]
  .filter((el) => el.checkValidity?.()).forEach((el) => console.warn("aria-invalid не снят", el));

Что автоматизировать

Автопроверки закрывают механику: отсутствие подписи, битые и дублирующиеся id, aria-invalid на неподходящей роли. Они не проверяют осмысленность текста ошибки, момент валидации и перевод фокуса — это ручная работа и сценарные тесты.

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("после неудачной отправки фокус в сводке, а ошибки связаны с полями", async ({ page }) => {
  await page.goto("/checkout");
  await page.getByRole("button", { name: "Оформить" }).click();
  await expect(page.getByRole("heading", { name: /Форма не отправлена/ })).toBeVisible();
  await expect(page.locator("#summary")).toBeFocused();

  await page.getByRole("link", { name: /Рабочая почта/ }).click();     // ссылка ведёт в поле
  const email = page.getByRole("textbox", { name: "Рабочая почта" });
  await expect(email).toBeFocused();
  await expect(email).toHaveAttribute("aria-invalid", "true");
  await expect(email).toHaveAccessibleDescription(/Нужен адрес вида/); // текст ошибки доступен

  await email.fill("ivan@example.com");                                // исправили — состояние снялось
  await expect(email).not.toHaveAttribute("aria-invalid", "true");
  const { violations } = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag22aa"]).analyze();
  expect(violations).toEqual([]);
});

Дополняют картину eslint-plugin-jsx-a11y с правилом label-has-associated-control (ловит проблему до коммита), @testing-library с запросом getByLabelText (не находит тест поле по подписи — не найдёт и скринридер; пожалуй, лучший побочный эффект тестирования по ролям) и jest-axe на уровне компонента. Как встроить это в процесс — в статье про тестирование и процесс, про E2E в целом — E2E и UI-тесты и тестирование фронтенда.

Четыре места, где связывание ломается в компонентных фреймворках

const id = useId();                                    // 1. стабильный id, безопасный при SSR
const [errorId, hintId] = [`${id}-err`, `${id}-hint`];
<label htmlFor={id}>Рабочая почта</label>
<input id={id} type="email" autoComplete="email" required
  onBlur={() => setTouched(true)}                      // 2. ошибку показываем после ухода из поля
  aria-invalid={error ? true : undefined}              // 3. undefined, а не false
  aria-describedby={[error && errorId, hintId].filter(Boolean).join(" ") || undefined} />
<p id={errorId} className="error" role="status">{error}</p>  {/* 4. контейнер есть всегда */}
<p id={hintId}>Используем только для чеков и писем о заказе.</p>

useId — вместо самодельных счётчиков: иначе id разъедутся между сервером и клиентом и describedby укажет в никуда. Список в aria-describedby собирается с фильтром: пустая строка в атрибуте тоже считается битой ссылкой. Библиотеки форм (React Hook Form, Formik, TanStack Form) дают состояние поля, но связывание с описанием почти всегда остаётся на вас — проверяйте руками. Инженерная сторона валидации разобрана в «Формы и валидация».

Типичные ошибки — сводка

Симптом Причина Что делать
Скринридер говорит «правка, пусто» нет <label>, подпись только в плейсхолдере <label for>
Голосовая команда не находит поле aria-label расходится с видимым текстом убрать aria-label (2.5.3)
«Что за да/нет?» на радиокнопках нет fieldset/legend группировать вопрос
Ошибка есть, но не слышна нет aria-describedby и aria-invalid связать текст с полем
Ошибка озвучена, но фокус не там нет перевода фокуса на сводку tabindex="-1" и focus()
Речь захлёбывается при вводе валидация на каждый символ валидировать на blur
Поле «вечно сломано» не снят aria-invalid="true" снимать при исправлении
Описание молчит опечатка в id скрипт-проверка describedby
Кнопка неактивна, причина неизвестна disabled на «Отправить» активная кнопка и сводка
Пароль приходится вводить руками запрет вставки, autocomplete="off" разрешить (3.3.8)
Форма улетела при выборе в селекте сабмит на change кнопка «Применить» (3.2.2)
Данные пропали при «назад», сессия истекла нет сохранения шага, жёсткий таймаут хранить состояние (3.3.7), продлевать (2.2.1)
«Спасибо» никто не услышал фокус не переведён после успеха фокус на заголовок результата

Мини-итог

  • Поле обязано иметь имя, роль, значение и состояние; всё, что выражено цветом, рамкой или положением, для ассистивной технологии не существует.
  • <label for> — основной инструмент подписи; placeholder подписью не бывает, aria-label — крайняя мера и риск нарушить 2.5.3. Группа полей — fieldset/legend либо role="group" с aria-labelledby, иначе вопрос теряется и остаются одни ответы.
  • autocomplete — не удобство, а критерий 1.3.5 и способ не заставлять человека набирать адрес руками; блокировать вставку в пароль нельзя (3.3.8).
  • Подсказка связывается через aria-describedby, инструкции ставятся до поля: в режиме форм несвязанный текст не читается.
  • Ошибку показывают после blur, снимают немедленно, а на submit проверяют всё сразу.
  • Ошибка = aria-invalid плюс текст, связанный describedby, плюс визуальный маркер помимо цвета; формулировка обязана подсказывать решение (3.3.1, 3.3.3, 1.4.1).
  • Сводка ошибок с переводом фокуса — единственный способ сообщить о провале отправки всем сразу; ссылки в ней ведут прямо в поля. Успех объявляют так же явно: фокус на заголовок результата или живая область (4.1.3).
  • Пять минут с клавиатурой и скринридером находят больше, чем любой автоматический сканер.

Источники

Что дальше

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

Доступные компоненты: модалки, меню, табы, автодополнение

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов