{ "index": 207, "slug": "editorial-2022-04-practice-accessible-interface", "title": "Доступный интерфейс начинается с маршрута фокуса", "excerpt": "Как связать имя, роль, состояние и фокус в одном интерактивном сценарии и проверить ошибку до того, как пользователь потеряет управление формой.", "contentHtml": "
Панель настроек открывается по клику, но после нажатия Tab фокус уходит в фон страницы. Клавиатурный пользователь не понимает, что панель появилась, а после сохранения фокус исчезает вместе с удалённым узлом. Другая ошибка выглядит тише: канал отмечен цветом и галочкой, но его имя или выбранное состояние не попадает в accessibility tree. Цена одинакова — потерянное действие, повторный ввод и неясный результат сохранения.
Доступность такого сценария проверяется не отдельным атрибутом, а маршрутом состояния. На каждом шаге должны совпасть четыре наблюдаемых свойства: имя и роль control, его значение, допустимое клавиатурное действие и следующий focus target. Если один слой живёт отдельно, интерфейс может выглядеть исправным и ломаться уже при первом действии без мыши.
Начните с конкретного сценария: кнопка notifications-trigger открывает модальную панель «Настроить уведомления», внутри выбирается Email или SMS, кнопка «Сохранить» отправляет изменение. У сценария есть два значения. channel — черновой выбор в открытой панели. savedChannel — последнее подтверждённое значение. Ошибка валидации и отмена не имеют права изменить второе.
Контракт также описывает фокус. При открытии он переходит внутрь панели. При ошибке остаётся на исправляемой группе или возвращается к ней. При успешном закрытии возвращается на вызывающую кнопку, если она всё ещё существует и это соответствует потоку работы. Это не универсальная формула: для большой панели начальным target может быть заголовок, чтобы сначала прочитать структуру. Решение нужно записать до реализации и проверить на фактическом DOM.
Роль сама по себе не создаёт поведение. role='button' не добавляет обработчики Enter и Space, видимый focus indicator или возврат фокуса после удаления узла. Если нативный button решает задачу, он оставляет браузеру часть контракта. Custom control оправдан только тогда, когда вместе с ним описаны семантика, клавиши, переходы состояния и тест.
В этом примере панель действительно модальная: пока она открыта, пользователь не взаимодействует с содержимым за её пределами. Это важное условие, а не декоративное значение aria-modal. Для немодального раскрывающегося блока нужен другой маршрут: не удерживайте фокус внутри него и не заявляйте технологиям, что фон недоступен.
При открытии выберем первый radio, потому что панель короткая и пользователь сразу должен выбрать канал. В другом диалоге уместнее сначала сфокусировать заголовок с tabindex='-1', если длинный текст иначе прокрутится за пределы окна. После ошибки показываем сообщение, связываем его с группой и не трогаем savedChannel. После успешного ответа закрываем панель и возвращаем фокус на notifications-trigger.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После открытия Tab уходит в фон | Панель не получила начальный focus target или modal-граница не работает | Открыть клавиатурой и записать фактический activeElement после каждого Tab | Выбрать вход внутрь панели; для modal реализовать полный циклический маршрут |
| Выбранный канал виден, но читается старый | CSS-класс и semantic state вычисляются из разных копий | Сравнить визуальный selected, native checked и доступное состояние | Выводить представления из одного владельца channel |
| Ошибка видна рядом, но не связана с группой | У сообщения нет связи с control или оно передаётся только цветом | Проверить fieldset, legend, aria-describedby и текст ошибки | Связать ошибку с группой, сообщить invalid state и вернуть фокус к исправлению |
| После сохранения фокус исчезает | Сфокусированный узел удалили вместе с панелью | Сохранить вызывающий узел до открытия и проверить document.activeElement после закрытия | Вернуть фокус на существующий trigger или явно обоснованную следующую точку |
| Статус есть в DOM, но результат не объявляется | Текст обновлён не в момент результата или region не имеет подходящей семантики | Проверить изменение status после ответа и его доступность в целевой связке | Обновлять короткое сообщение после успеха или ошибки и отдельно проверить assistive technology |
Нативная форма уже даёт подписи, группировку radio и клавиатурное поведение. Ниже показаны также границы диалога и связи сообщений. Атрибут hidden у ошибки снимается только при ошибке; пустой контейнер не является доказательством, что пользователь её услышит. У каждого control остаётся видимый текстовый label, а у диалога — имя через заголовок.
<button id="notifications-trigger" type="button">\n Настроить уведомления\n</button>\n\n<div id="notifications-dialog" role="dialog" aria-modal="true"\n aria-labelledby="notifications-title" aria-describedby="notifications-help"\n hidden>\n <h2 id="notifications-title">Настроить уведомления</h2>\n <p id="notifications-help">Выберите один канал для сообщений.</p>\n <form>\n <fieldset aria-describedby="channel-error">\n <legend>Канал уведомлений</legend>\n <label>\n <input type="radio" name="channel" value="email" checked>\n Email\n </label>\n <label>\n <input type="radio" name="channel" value="sms">\n SMS\n </label>\n <p id="channel-error" role="alert" hidden></p>\n </fieldset>\n <p id="save-status" role="status" aria-live="polite"></p>\n <button id="notifications-save" type="submit">Сохранить</button>\n <button type="button">Отмена</button>\n </form>\n</div>Это минимальная семантическая схема, а не готовый dialog manager. JavaScript должен открыть панель, установить начальный фокус, удерживать его внутри modal, обработать Escape и вернуть фокус после закрытия. При удалённом или перемещённом trigger нужен другой target. Не ставьте aria-modal='true' на блок, за пределами которого реально можно кликать и перемещать фокус: такое расхождение скрывает фон от некоторых assistive technology и ухудшает навигацию.
Самая частая логическая ошибка — записать выбор в сохранённое состояние в обработчике radio. Тогда пользователь ещё не нажал «Сохранить», а отмена уже изменила данные. Разведите операции: выбор меняет только draft, проверка решает, допустим ли переход, сохранение выполняет commit, а ошибка меняет сообщение и focus target.
Следующий фрагмент — исполняемая модель без DOM. Сохраните его в файл focus-contract.mjs и запустите командой node focus-contract.mjs. Он не измеряет accessibility tree и не заменяет браузерный проход. Его польза в другом: отрицательный путь становится проверяемым до подключения UI и сети.
import assert from 'node:assert/strict';\n\nconst initial = {\n draft: 'email',\n saved: 'email',\n focusTarget: 'notifications-trigger',\n error: '',\n status: ''\n};\nconst channels = new Set(['email', 'sms']);\n\nfunction choose(state, next) {\n if (!channels.has(next)) {\n return {\n ...state,\n error: 'Выберите Email или SMS',\n focusTarget: 'channel-email'\n };\n }\n return { ...state, draft: next, error: '', focusTarget: 'notifications-save' };\n}\n\nfunction cancel(state) {\n return { ...state, draft: state.saved, error: '', focusTarget: 'notifications-trigger' };\n}\n\nfunction save(state) {\n if (state.error) {\n return { ...state, focusTarget: 'channel-email', status: 'Исправьте выбор' };\n }\n return {\n ...state,\n saved: state.draft,\n focusTarget: 'notifications-trigger',\n status: 'Канал уведомлений сохранён'\n };\n}\n\nconst invalid = choose(initial, 'carrier-pigeon');\nassert.equal(invalid.saved, 'email');\nassert.equal(invalid.draft, 'email');\nassert.equal(invalid.focusTarget, 'channel-email');\n\nconst selected = choose(initial, 'sms');\nassert.equal(selected.saved, 'email');\nassert.equal(selected.draft, 'sms');\nassert.equal(save(selected).saved, 'sms');\nassert.equal(cancel(selected).saved, 'email');\nconsole.log('focus and state contract: ok');В модели carrier-pigeon — учебное значение вне allow-list. Оно не имитирует серверный ответ и не доказывает поведение screen reader. Проверка доказывает только инварианты данных: неверный выбор не коммитится, допустимый выбор становится черновиком, сохранение делает один commit, а отмена возвращает прежний результат. Для реального интерфейса те же инварианты нужно связать с DOM и сетевой ошибкой.
У нативной группы radio браузер уже связывает fieldset и legend, а выбранный control получает значение checked. Для custom-группы нужно реализовать другой контракт: контейнер с именем и ролью radiogroup, элементы с ролью radio, согласованный aria-checked и клавиатурные переходы. Нельзя добавить роль к обычному div и считать работу законченной.
Для radio-группы вне toolbar Tab входит в группу и выходит из неё, а стрелки перемещают фокус и выбирают вариант по правилам выбранного паттерна. Для native HTML детали перехода между браузерами стоит проверять отдельно. Если группе нужен toolbar, его клавиатурные правила меняются: стрелка может перемещать фокус, не меняя выбранное значение. Поэтому сначала выберите паттерн, затем реализуйте его целиком и внесите его в тестовый сценарий.
draft и saved. Зафиксируйте, на каком событии происходит commit и какие ветки его запрещают.legend, имя dialog, связь ошибки через aria-describedby, видимый focus indicator и корректный тип кнопок.activeElement.Контракт панели не описывает весь продукт. Он не проверяет контраст, масштабирование, порядок заголовков, touch target, локализацию, виртуальный курсор, тайм-ауты, конфликт горячих клавиш и работу при нестабильной сети. Он также не заменяет тестирование людьми, которые используют разные способы ввода и разные assistive technology.
Нативный элемент уменьшает объём собственной логики, но не отменяет проверку: CSS может скрыть focus indicator, сервер может вернуть устаревшую ошибку, а асинхронный ответ может прийти после закрытия панели. Если сохранение идёт по сети, заранее решите, остаётся ли черновик для retry и куда возвращается фокус. Локальный commit в модели не является подтверждением серверной записи.
Сценарий готов к следующему этапу, когда команда может воспроизвести один маршрут: открыть panel, назвать control, выбрать значение, получить ошибку без изменения saved state, исправить её, успешно сохранить и вернуть фокус на объявленный target. Для каждого перехода есть наблюдаемое свойство DOM или state, а для status есть проверка выбранной связки. Фраза «должно работать» без шага и результата остаётся незакрытой частью контракта.
aria-checked.