{ "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 и ухудшает навигацию.

Разделите черновик, commit и сообщение

Самая частая логическая ошибка — записать выбор в сохранённое состояние в обработчике 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 и сетевой ошибкой.

Маршрут фокуса модальной панели уведомлений: trigger ведёт внутрь к Email, ошибка возвращает к radio, сохранение возвращает фокус на trigger.
Схема показывает ожидаемый маршрут данных и фокуса. Она не является снимком accessibility tree и не доказывает, какую фразу произнесёт конкретная программа чтения с экрана.

Не смешивайте native radio и ARIA-паттерн

У нативной группы radio браузер уже связывает fieldset и legend, а выбранный control получает значение checked. Для custom-группы нужно реализовать другой контракт: контейнер с именем и ролью radiogroup, элементы с ролью radio, согласованный aria-checked и клавиатурные переходы. Нельзя добавить роль к обычному div и считать работу законченной.

Для radio-группы вне toolbar Tab входит в группу и выходит из неё, а стрелки перемещают фокус и выбирают вариант по правилам выбранного паттерна. Для native HTML детали перехода между браузерами стоит проверять отдельно. Если группе нужен toolbar, его клавиатурные правила меняются: стрелка может перемещать фокус, не меняя выбранное значение. Поэтому сначала выберите паттерн, затем реализуйте его целиком и внесите его в тестовый сценарий.

Порядок проверки до выпуска

  1. Запишите симптом, цену ошибки и нормальный результат: например, после открытия панели пользователь теряет точку фокуса и не может подтвердить канал.
  2. Назовите trigger, диалог, заголовок, группу, варианты, кнопку сохранения, отмену и status region. Для каждого укажите доступное имя, роль и источник значения.
  3. Разделите draft и saved. Зафиксируйте, на каком событии происходит commit и какие ветки его запрещают.
  4. Проверьте разметку: видимый label, legend, имя dialog, связь ошибки через aria-describedby, видимый focus indicator и корректный тип кнопок.
  5. Пройдите нормальный путь только клавиатурой: открыть, войти в группу, выбрать, сохранить, увидеть status и вернуться на trigger. Запишите фактический activeElement.
  6. Пройдите отрицательные ветки: неизвестное значение, ошибка сервера, отмена без сохранения, Escape и исчезнувший trigger. Зафиксируйте сохранённое значение, сообщение и target для каждой ветки.
  7. Сопоставьте visual state, DOM properties и accessibility tree после выбора и после ошибки. Не заменяйте наблюдение в браузере результатом unit-теста модели.
  8. Проверьте одну согласованную связку браузера и assistive technology, запишите версии и фактический результат объявления. Автоматический аудит помогает найти нарушения, но не подтверждает удобство сценария.
  9. Повторите маршрут после исправления и сохраните регрессионный тест для стабильных переходов состояния.

Ограничения и критерий готовности

Контракт панели не описывает весь продукт. Он не проверяет контраст, масштабирование, порядок заголовков, touch target, локализацию, виртуальный курсор, тайм-ауты, конфликт горячих клавиш и работу при нестабильной сети. Он также не заменяет тестирование людьми, которые используют разные способы ввода и разные assistive technology.

Нативный элемент уменьшает объём собственной логики, но не отменяет проверку: CSS может скрыть focus indicator, сервер может вернуть устаревшую ошибку, а асинхронный ответ может прийти после закрытия панели. Если сохранение идёт по сети, заранее решите, остаётся ли черновик для retry и куда возвращается фокус. Локальный commit в модели не является подтверждением серверной записи.

Сценарий готов к следующему этапу, когда команда может воспроизвести один маршрут: открыть panel, назвать control, выбрать значение, получить ошибку без изменения saved state, исправить её, успешно сохранить и вернуть фокус на объявленный target. Для каждого перехода есть наблюдаемое свойство DOM или state, а для status есть проверка выбранной связки. Фраза «должно работать» без шага и результата остаётся незакрытой частью контракта.

Проверяемые источники

" }