Симптом в форме регистрации выглядит мелким: после submit у поля почты появляется красная рамка. Мышью её замечают, вводят значение и идут дальше. С клавиатурой фокус остаётся на кнопке, текст ошибки не появляется или не связан с input, а значение ожидаемого формата приходится угадывать. Цена — сорванная операция и повторная правка общего компонента формы, когда похожая ошибка уже разъехалась по нескольким страницам.
Разбирать такой случай полезнее как контракт, а не как выбор цвета. Валидация должна назвать ошибку текстом, control должен получить актуальное состояние, пользователь должен понять, где продолжить путь, а браузер должен показать эти отношения в живой странице. Нельзя заменить эту цепочку одной проверкой DOM: в нём может лежать правильный p с сообщением, пока он hidden, а фокус и вычисленное описание остаются другими.
Картина до правки: четыре вопроса к одной ошибке
Возьмём поле «Почта» в регистрации. Серверная проверка и формат могут быть шире, чем type=email, но для клиентского примера достаточно пустого или заведомо неверного значения. Важнее не валидатор, а последствия его результата. После неудачной отправки нужно ответить: какое поле неверно, есть ли объяснение в тексте, как это отражено в состоянии control и какой следующий клавиатурный шаг ожидается.
Красный border отвечает только на часть вопроса «есть ли проблема». Он не называет поле в тексте, не объясняет формат и может исчезнуть в пользовательской теме. WCAG 2.1 для автоматически определённой input error требует идентифицировать ошибочный элемент и описать ошибку пользователю текстом. Поэтому первый технический артефакт — не палитра, а связанная строка «Укажите почту в формате name@example.com».
| Наблюдение | Причина разрыва | Проверка в браузере | Исправление |
|---|---|---|---|
| Красная рамка есть, текста нет | состояние выражено только стилем | после submit найти видимое текстовое сообщение рядом с полем | добавить короткое описание ошибки и не прятать его, пока поле не исправлено |
| Текст существует, но control не отмечен | ошибка рендерится отдельным компонентом без синхронизации состояния | у активного input нет invalid state в Accessibility tree | менять aria-invalid в той же функции, что выводит текст |
| Фокус остаётся на submit | валидация не определила следующий шаг для клавиатуры | после неудачного submit записать document.activeElement и видимый индикатор | согласованно перевести фокус к первому ошибочному control или к error summary |
| Подсказка и ошибка спорят друг с другом | aria-describedby ссылается не на те ID либо текст заменён без контекста | сверить name, description и текущий текст рядом с полем | оставить понятную подсказку и добавить конкретную ошибку без дублирования |
Таблица не диктует единственный UX. На длинной форме команда может выбрать error summary в начале, на короткой — первое ошибочное поле. В обоих вариантах нужно зафиксировать маршрут и не менять фокус при каждом символе. Фокус после submit — реакция на завершённую операцию; во время набора внезапный прыжок мешает исправлять значение.
Один владелец состояния ошибки
Ниже валидация и представление ошибки собраны в одной функции. Она принимает текст, выводит его, меняет hidden и синхронизирует aria-invalid. При ошибке submit фокус возвращается в поле. Такой код не решает серверную валидацию и не доказывает озвучивание сообщения, но не оставляет четыре независимые точки, которые могут разойтись при следующем изменении компонента.
<form id="registration" novalidate>
<label for="registration-email">Почта</label>
<input id="registration-email" type="email" required
aria-describedby="registration-email-hint registration-email-error"
aria-errormessage="registration-email-error" aria-invalid="false" />
<p id="registration-email-hint">Ссылка для входа придёт на этот адрес.</p>
<p id="registration-email-error" hidden></p>
<button type="submit">Создать аккаунт</button>
</form>
const input = document.querySelector("#registration-email");
const message = document.querySelector("#registration-email-error");
function renderEmailError(text) {
const hasError = Boolean(text);
input.setAttribute("aria-invalid", String(hasError));
message.hidden = !hasError;
message.textContent = text;
}
document.querySelector("#registration").addEventListener("submit", (event) => {
event.preventDefault();
const text = input.validity.valid ? "" : "Укажите почту в формате name@example.com";
renderEmailError(text);
if (text) input.focus();
});
В этом примере aria-describedby соединяет field с подсказкой и контейнером ошибки. WAI-ARIA описывает его как ID reference list для полного description. aria-errormessage дополнительно указывает на custom error message и используется вместе с aria-invalid. При валидном значении сообщения нет в действии; при ошибке текст должен стать доступен пользователю. Эти отношения стоит проверять вместе, а не искать в JSX отдельные атрибуты.
Тип сообщения тоже имеет значение. «Неверно» не даёт следующего шага. «Укажите почту в формате name@example.com» называет поле и ожидаемое исправление. Если формат определяется сервером или бизнес-правилом, текст не должен обещать больше, чем реально проверяет клиент. В тикете удобно отдельно записать: клиент ловит пустое значение и базовый формат, сервер остаётся источником окончательного решения.
Фокус после submit: не прятать выбор в обработчике
Обработчик submit уже знает, что пользователь попытался завершить форму. В этот момент допустимо выбрать следующий фокусируемый объект и сделать его видимым. Но выбор должен быть частью контракта страницы. Если фокус идёт на первое ошибочное поле, там должен быть виден индикатор и доступен текст причины. Если фокус идёт на summary, summary должна содержать ссылки или понятный порядок к ошибкам.
Не стоит автоматически фокусировать каждое поле в момент изменения value. Такая правка обрывает набор, особенно когда валидатор срабатывает на input. Сначала определяем событие: submit, blur или явная проверка. Затем для каждого события задаём предсказуемое поведение. В нашем сценарии проверяем именно submit: его легко повторить с клавиатуры и сравнить до/после.
Ручной сценарий клавиатуры и план дерева доступности
Ниже не отчёт о выполненном браузерном или assistive-technology прогоне. Это безопасный сценарий, который команда запускает на локальном стенде до релиза. Он не требует отправлять персональные данные: используется тестовый адрес, а submit может быть перехвачен. Результат нужно хранить как пару «ожидание — факт» с браузером и версией.
- Открыть форму в исходном состоянии, перейти Tab до поля почты и удостовериться, что его фокус различим. В DevTools записать role, name и description этого control, не подменяя запись скриншотом исходного HTML.
- Оставить поле пустым или ввести тестовое неверное значение. Перейти Tab до submit и активировать его с клавиатуры, не касаясь мыши.
- Проверить, что неверное поле имеет видимый текст ошибки, не только изменение border. Сверить, что текст называет исправление, а не просто сообщает о неуспехе.
- Записать активный элемент после submit. Если продукт выбрал фокус на первом неверном поле, он должен быть там; если выбран summary, следующий Tab должен вести к понятному способу исправления.
- Открыть дерево доступности для ошибочного input и сверить name, description, invalid state и видимость связанного сообщения в этом браузере. Если вывод отличается от ожидания, приложить факт, а не угадывать причину.
- Запланировать отдельный повтор со screen reader из поддерживаемой матрицы. Только после выполненного прогона можно фиксировать, как динамическое изменение воспринимается выбранной технологией.
Почему дерево браузера нужно вместе с клавиатурой
Клавиатура показывает порядок и видимый фокус. Accessibility tree показывает, какую семантику браузер собрал для активного элемента. По отдельности проверки неполны: можно увидеть хорошую рамку, но пустое name; можно увидеть aria-invalid в панели, но не заметить, что Tab после submit оставил пользователя на кнопке. Сценарий связывает эти два факта одной операцией.
Для базовой формы не нужно строить собственную систему ролей. Нативные label, input и button уже дают браузеру значительную часть поведения. Работы ARIA здесь ровно столько, сколько нужно для отношений с подсказкой и error message. Чем меньше кастомной интерактивности вокруг обязательного поля, тем проще сопоставить код, фокус и представление браузера при регрессии.
Проверка перед выпуском и известные границы
После правки полезно добавить короткий регрессионный тест на DOM-состояние: при неудачном submit поле получает aria-invalid=true, текст ошибки перестаёт быть hidden, а обработчик выбирает ожидаемый фокус. Такой тест не заменит живой browser route, но не даст случайной правке удалить ID-связь или поменять текстовую ошибку на одну рамку. Ручной сценарий остаётся выпускной проверкой для фокуса и дерева браузера.
- Статья не утверждает соответствие всей страницы WCAG 2.1. Проверяется один контракт ошибки в одной форме; контраст, язык, масштабирование, сложные виджеты и остальные экраны требуют своих сценариев.
- aria-errormessage и aria-invalid не заменяют видимый текст. Если error element скрыт или сообщение не объясняет исправление, формальная ссылка не помогает пользователю.
- Автор не запускал в этом пакете browser automation, screen reader или реальное серверное API. Утверждения о них заменены воспроизводимым планом проверки.
- Если в продукте есть asynchronous validation, нужно отдельно описать отмену старого ответа, состояние ожидания и момент, когда ошибка становится актуальной. Этот базовый пример намеренно не имитирует сеть.
Итог: ошибка становится частью маршрута
В форме доступная ошибка начинается не с ARIA и не с красного класса. Сначала определяем пользовательский сбой, затем делаем текст ошибки видимым, связываем его с control, обновляем invalid state и выбираем предсказуемый фокус после submit. После этого идём по странице клавиатурой и смотрим конкретный input в дереве браузера. Такой контракт можно повторить на следующей форме и проверить без догадок.
Проверяемые источники
- WCAG 2.1: 2.4.3 Focus Order — при последовательной навигации порядок фокуса должен сохранять смысл и работоспособность операции
- WCAG 2.1: 2.4.7 Focus Visible — для управляемого с клавиатуры интерфейса нужен режим с видимым индикатором фокуса
- WCAG 2.1: 3.3.1 Error Identification — если ошибка ввода определяется автоматически, ошибочный элемент и текст ошибки должны быть определены для пользователя
- WCAG 2.1: 4.1.2 Name, Role, Value — компонентам интерфейса требуются программно определимые имя, роль и доступные состояния или значения
- HTML Standard: label element — label связывается с form control через for/id или вложением самого control
- WAI-ARIA 1.1: aria-describedby — список ID связывает элемент с полной текстовой description
- WAI-ARIA 1.1: aria-errormessage — ссылка на пользовательское сообщение об ошибке используется вместе с aria-invalid; актуальный текст ошибки не должен быть скрыт