Доступные формы: подписи, подсказки, ошибки, валидация
Форма — это место, где интерфейс перестаёт быть текстом и становится сделкой. Здесь оформляют заказ, записываются к врачу, подают заявление, переводят деньги. Именно поэтому недоступная форма стоит дороже недоступной статьи: непрочитанную статью можно обойти, невыполненный платёж — нет. И именно поэтому в 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): человек говорит «нажать Телефон», а система ищет контрол с именем «Введите номер мобильного» и не находит.
или ячейка --> E["aria-labelledby на этот текст"] B -- нет --> F{"Можно показать подпись?"} F -- да --> G["Показать; нет места —
visually-hidden внутри <label>"] F -- нет, только иконка --> H["aria-label — и следить,
чтобы не расходился с UI"] D & E & G & H --> I{"Нужно уточнение:
формат, требования, ошибка?"} I -- да --> J["aria-describedby на текст уточнения"] I -- нет --> K["Готово"]
Плейсхолдер — не подпись
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 — ротор с полями
Что слушать:
- При входе в каждое поле звучит осмысленное имя? Не «правка, пусто», не «текстовое поле».
- Звучит ли «обязательно» там, где это обязательное поле?
- После неудачной отправки — вы узнали об ошибке, не глядя на экран?
- Перейдя в сломанное поле, вы слышите что именно не так?
- После успешной отправки — вы поняли, что заказ прошёл?
Если хотя бы на один вопрос ответ «нет», форма непроходима без зрения — независимо от того, что показал автоматический сканер.
Особые случаи
- Многошаговые формы. Прогресс не только цветом: «Шаг 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>доступнее любой самописной замены.
Проверка руками за пять минут
Порядок, который ловит подавляющее большинство проблем:
- Кликните по каждой подписи. Фокус прыгнул в поле, чекбокс переключился? Нет — подпись не связана.
- Пройдите форму табом. Все поля достижимы, кольцо фокуса видно, порядок совпадает с визуальным?
- Отправьте пустую форму. Вы узнали об ошибке без взгляда на экран? Куда ушёл фокус?
- Увеличьте до 200% и 400% и отключите цвет (DevTools → Rendering → Emulate vision deficiencies → Achromatopsia): подписи не наехали на значения, ошибочные поля всё ещё отличимы?
- Проверьте автозаполнение менеджером паролей или адресами браузера, а затем включите скринридер и пройдите пять вопросов из раздела выше.
Плюс скрипт в консоли — механические поломки он находит за секунду:
// 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).
- Пять минут с клавиатурой и скринридером находят больше, чем любой автоматический сканер.
Источники
- WCAG 2.2 и Understanding WCAG 2.2 — критерии 1.3.1, 1.3.5, 1.4.1, 2.2.1, 2.5.3, 2.5.7, 3.2.2, 3.3.1–3.3.4, 3.3.7, 3.3.8, 4.1.2, 4.1.3.
- W3C WAI Tutorials: Forms — самый практичный официальный материал по формам, с примерами разметки.
- WAI-ARIA Authoring Practices — паттерны для компонентов, которых нет в HTML; Accessible Name and Description Computation — как считается имя контрола.
- MDN:
<label>, Client-side form validation,autocomplete,aria-describedby,:user-invalid. - HTML Standard: Autofill — полный список токенов
autocomplete. - GOV.UK Design System: Error summary и Error message — эталонная, многократно протестированная реализация паттерна сводки.
- WebAIM: Creating Accessible Forms, WebAIM Million и Inaccessibility of CAPTCHA от W3C.
Что дальше
Формы состоят не только из input. Как только в них появляется выпадающий список с поиском, выбор даты или модальное окно подтверждения — начинается территория, где нативных тегов не хватает и приходится собирать поведение руками. Дальше разберём именно её: модалки, меню, табы и автодополнение — с клавиатурными контрактами, ролями и типовыми ошибками реализации.