{ "index": 298, "slug": "editorial-2019-09-field-accessibility-basics", "title": "Ошибка поля формы: текст, состояние и фокус должны быть одной системой", "excerpt": "Красная рамка не объясняет ошибку и не возвращает пользователя к месту исправления. Разбираем контракт поля после submit: label, текст ошибки, aria-invalid, фокус и проверка в браузере.", "contentHtml": "
После отправки формы у поля появляется красная рамка, но фокус остаётся на кнопке. Пользователь клавиатуры не знает, какое поле исправлять. При этом текст ошибки может быть в DOM, но скрыт, не связан с input или повторяет только слово «Ошибка». Цена такого дефекта — сорванная регистрация, повторный ввод и новая переделка общего компонента формы.
\nТезис простой: ошибка поля — это не цвет, а согласованный маршрут. Валидатор определяет сбой, интерфейс называет его текстом, поле получает состояние, а фокус указывает следующий шаг. Если одна часть цепочки отстаёт, мышь ещё может скрыть проблему, но клавиатурный путь и вспомогательная технология теряют контекст.
\nВозьмём поле «Почта». До отправки у него есть доступное имя из связанного label, подсказка о формате и обычное состояние. После неудачного submit приложение добавляет конкретное сообщение, ставит aria-invalid=\"true\" и выбирает точку фокуса. Эти изменения должны произойти из одного результата валидации.
HTML связывает label и control через уникальную пару for/id или через вложение input в label. Текст рядом с полем без этой связи остаётся визуальной подписью. Placeholder тоже не заменяет label: он исчезает при вводе и не должен нести имя control.
aria-describedby связывает input с подсказкой и сообщением об ошибке по их идентификаторам. Список ссылок должен указывать на существующие элементы с актуальным текстом. aria-invalid помечает значение как невалидное. Если вместо aria-describedby выбран aria-errormessage, его применяют вместе с aria-invalid=\"true\" и оставляют сообщение доступным пользователю. Ни один из этих атрибутов не заменяет видимый текст.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Есть только красная рамка | Состояние выражено цветом | Отключить CSS и найти текстовое сообщение | Вывести объяснение и способ исправления |
| Текст есть, input его не получает | Нет связи по ID или ссылка устарела | Сверить aria-describedby с ID видимого сообщения | Синхронизировать атрибут и рендер сообщения |
| Фокус остаётся на submit | Обработчик не выбрал следующий шаг | Записать document.activeElement сразу после submit | Перевести фокус на первое ошибочное поле или summary |
| Поле объявлено ошибочным слишком рано | Валидатор срабатывает на каждый символ | Открыть пустую форму и проверить начальное состояние | Ставить aria-invalid после заданного события, например submit |
| Ошибка исчезла, но старый ID остался | Текст и состояние обновляются разными ветками | Исправить значение и проверить DOM и Accessibility tree | Удалить сообщение, ссылку и invalid state одной операцией |
Таблица описывает диагностику, а не готовый UX для любой формы. Для длинной анкеты можно сначала сфокусировать summary со ссылками на ошибки. Для короткой формы быстрее отправить фокус в первое неверное поле. Решение нужно выбрать один раз и проверить на всей странице. Фокус не следует перемещать при каждом нажатии клавиши: это прерывает ввод.
\nНиже учебный пример для локальной формы. Он не проверяет серверный API и не доказывает озвучивание сообщения конкретным screen reader. Его задача — показать, как связать валидатор, текст, состояние и фокус. Значения формата и выбор первого поля — проектные решения.
\nconst form = document.querySelector('[data-signup]');\nconst email = document.querySelector('#email');\nconst error = document.querySelector('#email-error');\n\nfunction setEmailError(message) {\n const invalid = Boolean(message);\n\n error.textContent = message || '';\n error.hidden = !invalid;\n email.setAttribute('aria-invalid', String(invalid));\n email.setAttribute(\n 'aria-describedby',\n invalid ? 'email-hint email-error' : 'email-hint',\n );\n}\n\nform.addEventListener('submit', (event) => {\n event.preventDefault();\n const value = email.value.trim();\n\n if (!value || !value.includes('@')) {\n setEmailError('Укажите почту в формате name@example.com.');\n email.focus();\n return;\n }\n\n setEmailError('');\n // Учебный пример: здесь могла бы начаться отправка формы.\n});\nРазметка для этого обработчика должна содержать уникальный ID и связанную подпись:
\n<form data-signup novalidate>\n <label for=\"email\">Почта</label>\n <input id=\"email\" name=\"email\" type=\"email\"\n aria-describedby=\"email-hint\">\n <p id=\"email-hint\">Например, name@example.com</p>\n <p id=\"email-error\" hidden></p>\n <button type=\"submit\">Продолжить</button>\n</form>\nАтрибут novalidate здесь отключает встроенный браузерный экран ошибки, чтобы весь учебный сценарий проходил через один обработчик. Это не рекомендация отказаться от нативной constraint validation: в рабочей форме нужно отдельно решить, какой слой владеет проверкой и как серверные ошибки попадают в тот же контракт.
Валидация здесь намеренно грубая. Проверка символа @ не является полной проверкой адреса, а сервер всё равно остаётся источником окончательного решения. В рабочем коде нельзя считать запрос успешным только потому, что клиентская ветка прошла. Серверная ошибка должна попасть в тот же контракт: текст, состояние поля и предсказуемый фокус.
В примере нет role=\"alert\": после ошибки фокус переходит на input, а aria-describedby уже включает видимый текст. Так у пользователя есть один основной маршрут объявления. Если продукт оставляет фокус на summary или поле не получает фокус, может понадобиться live region, но это решение нужно проверить в поддерживаемых браузерах и screen reader, чтобы не объявлять одну ошибку дважды.
Схема показывает направление данных, а не результат реального прогона. В браузере нужно отдельно подтвердить, что сообщение видно, поле сохраняет фокус и его вычисленные имя, роль и описание соответствуют ожиданию.
\nDOM-проверка находит атрибуты, но не отвечает на весь вопрос. Клавиатура показывает маршрут и видимый фокус. Accessibility tree показывает представление control, которое собрал браузер. Проверяйте оба слоя на собранной странице, а не только JSX или шаблон.
\naria-invalid сброшен, ссылка на error удалена или обновлена, а фокус не прыгает без причины.Этот пример покрывает одно текстовое поле и одну ошибку после submit. Он не решает групповые ошибки, радиокнопки, сложные маски, вложенные формы, ошибки сети и серверные сообщения, которые приходят после смены страницы. Для каждого такого случая нужен отдельный маршрут.
\nЕсли сервер отвечает после того, как пользователь уже исправил значение, старый ответ нельзя безусловно применять. Сравните запрос с актуальным значением или отмените устаревший запрос. Иначе исправленное поле снова станет ошибочным. Во время ожидания покажите состояние загрузки и не меняйте фокус без действия, которое пользователь может объяснить.
\nНе добавляйте положительный tabindex, чтобы скрыть неверный порядок DOM. Сначала расставьте элементы в логическом порядке. Не заменяйте нативный input кастомным div с role без причины: вместе с ролью придётся восстановить фокус, клавиатурные команды, значение и состояния.
Нельзя объявлять всю страницу соответствующей WCAG после проверки одной формы. Здесь проверяются идентификация ошибки, видимый фокус, имя поля и маршрут исправления. Контраст, масштабирование, язык, автозаполнение и остальные экраны требуют своих проверок.
\nКонтракт готов, если тестировщик без мыши повторяет один и тот же сценарий и получает четыре наблюдаемые результата: ошибочное поле названо текстом, сообщение связано с input, invalid state отражён в браузере, а фокус ведёт к выбранной точке исправления. После корректного значения все четыре состояния возвращаются в валидный вид. Эти условия можно закрепить DOM-тестом и ручным проходом в поддерживаемом браузере.
\naria-invalid, aria-describedby и aria-errormessage; атрибуты нужно проверять в конкретном браузере и вспомогательной технологии.