Архитектура фронтенда
Доступные формы: путь с клавиатуры и есть настоящая спецификация
Форма оформления заказа проходит QA. Работает безупречно — с мышью. Потом пользователь скринридера заводит тикет: он табается на поле email, слышит лишь «edit text», заполняет всю форму вслепую, жмёт стильную «Оплатить» на <div> — и Enter ничего не делает, потому что div — не кнопка. Он отправляет случайно, три поля заполнены неверно, а красный текст ошибки, который «явно» появился, никогда не объявляется. Форма не упала. Она просто непригодна для всех, кто не наводит и не кликает. Это провал WCAG 2.2 AA, и на 1 000 000 главных страниц он самый частый из всех.
Метка — это имя поля, и у трети веба её нет
Каждому полю нужна программно связанная метка — не плейсхолдер, не визуально соседний текст, а реальная связь, которую читает дерево доступности. Отчёт WebAIM Million 2025 просканировал топ-миллион главных страниц и нашёл, что 34,2% всех полей форм не были корректно помечены. При среднем 6,3 поля на страницу это самая частая категория провалов в вебе. Скринридер, попав на непомеченное поле, объявляет «edit text» и больше ничего — пользователь не понимает, что вводить.
Есть две корректные связи, и разница важна:
- Явная:
<label for="email">Email</label>плюс<input id="email">. Атрибутfor(в React —htmlFor) совпадает сidполя. Это надёжный дефолт — метка может стоять где угодно в DOM. - Неявная: обернуть input в label —
<label>Email <input></label>. id не нужен, но input должен быть прямым потомком, и часть старых AT и стилевых раскладок обрабатывает это менее надёжно.
Плейсхолдеры — не метки. Они исчезают при фокусе, не проходят минимум контраста, а несколько скринридеров игнорируют их вовсе. «Плавающая метка», которая анимирует плейсхолдер в метку, годится только если под ней есть настоящий связанный <label>. Вся эта область — WCAG 1.3.1 Info and Relationships (Level A) плюс 4.1.2 Name, Role, Value: связь метки и контрола должна быть выставлена ассистивным технологиям, а не просто видна на экране.
| Подход | Доступное имя? | Вердикт |
|---|---|---|
<label for=“x”> + <input id=“x”> | Да — явное | Дефолт; метка может жить где угодно в DOM |
<label>Email <input></label> | Да — неявное | Работает, но input должен быть прямым потомком |
только placeholder=“Email” | Нет | Исчезает при фокусе, низкий контраст, часто игнорируется AT |
aria-label=“Email” на input | Да — но без видимой метки | Крайняя мера (поля-иконки); зрячим не достаётся ничего |
Ошибки, которых никто не слышит
Ты валидируешь на submit, красишь невалидные поля красным, кладёшь сообщение под каждым. Зрячему пользователю с мышью — идеально. Пользователю скринридера — тишина: визуальное изменение DOM не объявляет ничего. Фикс состоит из двух частей, и обе легко сделать тонко неправильно.
Во-первых, объяви ошибку. Сообщение, вставленное в контейнер с role="alert" (или область aria-live="assertive"), зачитывается в момент появления — это WCAG 4.1.3 Status Messages (Level AA): пользователь узнаёт об изменении статуса, не перемещая фокус в его поисках. Ловушка: live-область должна уже существовать в DOM до того, как ты впишешь в неё текст. Если смонтировать <div role="alert"> и его сообщение в одном рендере, многие скринридеры пропустят изменение. Рендери пустую live-область заранее; впрыскивай текст только при ошибке.
Во-вторых, свяжи сообщение с его полем через aria-describedby, указывающий на id сообщения, и пометь поле aria-invalid="true". Теперь, когда пользователь попадёт на это поле, скринридер зачитает метку, значение, «invalid» и текст ошибки вместе. Это WCAG 3.3.1 Error Identification (Level A). Сделай ещё лучше — скажи, как исправить («введите дату как ММ/ДД/ГГГГ»), а не просто «invalid» — и ты удовлетворишь 3.3.3 Error Suggestion (Level AA).
Почему это работает
role="alert" — это aria-live="assertive" плюс aria-atomic="true": он прерывает скринридер, чтобы немедленно зачитать всю область. Используй для ошибок. Для несрочного статуса («Сохранено», «3 результата») предпочитай role="status" (polite): он ждёт паузы, а не вламывается. Две assertive-области, сработавшие разом, затрут друг друга, поэтому резервируй assertive для того, что пользователь должен услышать сейчас.
Фокус — это разница между пригодным и нет
Валидация провалилась. Зрячий пользователь видит красные поля и кликает первое. Пользователь клавиатуры или скринридера на submit всё ещё сфокусирован на кнопке отправки — внизу формы. Он понятия не имеет, что что-то провалилось, и даже если страница прокрутилась, ему пришлось бы табаться назад через всю форму в поисках сломанного поля. Фикс — одна строка намерения: при ошибке submit перемести фокус на первое невалидное поле (firstInvalid.focus()). Пользователя бросают ровно туда, где работа, и поскольку у этого поля есть aria-describedby + aria-invalid, скринридер немедленно зачитывает, что не так и как исправить.
Это поведение браузер даёт бесплатно при нативной constraint-валидации — required, type="email", pattern, min/max — когда браузер фокусирует и прокручивает к первому невалидному контролу на submit и показывает встроенный пузырёк. В тот момент, когда ты перехватываешь через e.preventDefault() и пишешь свою валидацию (а так делают большинство реальных приложений — ради своих сообщений и асинхронных проверок), ты наследуешь эту ответственность за фокус. Забудешь — и ты построил классическую форму «работает с мышью, мертва с клавиатуры».
Кастомная JS-валидация на submit находит три невалидных поля. Какой ход сеньора для пользователей клавиатуры/AT?
Кастомные виджеты — место, где всё тихо умирает
Самые глубокие провалы — не пропущенные метки, а переизобретённые контролы. Дизайнер хочет стилизованный дропдаун, и инженер строит <div class="select"> с обработчиками клика. Выглядит идентично. Для ассистивных технологий он невидим как контрол: у div нет роли, его нет в порядке табуляции, у него нет клавиатурного поведения. Скринридер читает его как обычный текст; пользователь клавиатуры не может до него добраться, открыть, выбрать вариант. Та же ловушка ловит «кнопки» на <div onClick>: доки MDN про роль button прямо говорят, что чтобы div вёл себя как кнопка, нужно добавить role="button", tabindex="0", обработчик keydown на оба — Enter (keyCode 13) и Space (32) — с preventDefault, плюс семантику disabled и фокуса, которую <button> давал бесплатно. Люди помнят про обработчик клика и забывают каждый из этих пунктов.
Правило сеньора прямое: используй нативный элемент. <button>, <input>, <select>, <a href> поставляются с ролью, фокусируемостью, клавиатурной обработкой и интеграцией с формой на уровне ОС, которую ты не воспроизведёшь полностью и не будешь поддерживать. Тянись к виджетам на ARIA только когда нативный элемент по-настоящему не справляется (настоящий combobox с фильтрацией, multiselect) — и тогда следуй полному клавиатурному паттерну APG, потому что недостроенный role="combobox" хуже, чем простой <select>, который ты заменил.
Дизайн даёт кастомно-стилизованный дропдаун, который нативный <select> не повторяет визуально. Выбери реализацию.
Расставь шаги доступной обработки проваленного submit:
- 1 Запустить валидацию на submit и собрать невалидные поля
- 2 Выставить aria-invalid="true" на каждом невалидном поле
- 3 Вписать каждое сообщение об ошибке в заранее существующую live-область / элемент, на который указывает aria-describedby поля
- 4 Переместить фокус клавиатуры на первое невалидное поле (firstInvalid.focus())
- 5 Дать скринридеру зачитать метку + invalid + текст ошибки теперь, когда фокус на поле
- 01Пользователь скринридера отправляет форму, три поля невалидны, и он не слышит ничего. Пройди, чего не хватает и как закрыть каждый пробел.
- 02Почему дропдаун на <div role="button"> — баг хуже пропущенной метки, и что реально даёт «используй нативный элемент»?
Доступные формы решаются путём с клавиатуры и скринридера, а не путём мыши. Каждому полю нужна программно связанная метка (явная htmlFor/id или неявная обёртка) — плейсхолдеры не считаются, и 34,2% полей форм в вебе валят это. Ошибки должны объявляться через live-область (role=“alert”/aria-live), которая уже существует в DOM, быть связанными с полями через aria-invalid + aria-describedby и формулироваться как подсказки, а не просто «invalid» — покрывая WCAG 1.3.1, 3.3.1, 3.3.3 и 4.1.3. При проваленном submit нужно переместить фокус на первое невалидное поле; нативная constraint-валидация делает это бесплатно, но в момент preventDefault это становится твоей задачей. И самая глубокая ловушка — переизобретённый контрол: стилизованный div невидим ассистивным технологиям, поэтому тянись сначала к нативным <button>/<select>/<input> и перестраивай на полный паттерн ARIA только когда нативный элемент по-настоящему не справляется. Сделай это правильно — и форма, которая «работает с мышью», наконец заработает для всех.