awesome-everything EN
↑ Обратно к восхождению

Архитектура фронтенда

Доступные формы: путь с клавиатуры и есть настоящая спецификация

Суть Форма, удобная с мышью, может быть полностью непригодной с клавиатуры или скринридера. Метки, объявление ошибок и фокус на первом невалидном поле — вот что проходит WCAG 2.2 AA, и вот что большинство команд выкатывает сломанным.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 17 min

Форма оформления заказа проходит 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. 1 Запустить валидацию на submit и собрать невалидные поля
  2. 2 Выставить aria-invalid="true" на каждом невалидном поле
  3. 3 Вписать каждое сообщение об ошибке в заранее существующую live-область / элемент, на который указывает aria-describedby поля
  4. 4 Переместить фокус клавиатуры на первое невалидное поле (firstInvalid.focus())
  5. 5 Дать скринридеру зачитать метку + invalid + текст ошибки теперь, когда фокус на поле
Вспомните перед уходом
  1. 01
    Пользователь скринридера отправляет форму, три поля невалидны, и он не слышит ничего. Пройди, чего не хватает и как закрыть каждый пробел.
  2. 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 только когда нативный элемент по-настоящему не справляется. Сделай это правильно — и форма, которая «работает с мышью», наконец заработает для всех.

Продолжить восхождение ↑Доступные формы: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.