{ "index": 206, "slug": "editorial-2022-04-mechanism-accessible-interface", "title": "Доступный control начинается с контракта состояния, имени и фокуса", "excerpt": "Как связать семантику, состояние и маршрут клавиатуры в одном интерактивном компоненте и не принять роль ARIA или учебный тест за доказательство доступности.", "contentHtml": "

Компонент может выглядеть исправным и ломаться уже на первом действии без мыши. Кнопка открывает панель, но после закрытия фокус исчезает. Выбор подсвечивается, но его доступное состояние не меняется. Ошибка в поле видна цветом, но сохранённое значение уже перезаписано. Человек теряет точку продолжения и не понимает, что произошло. Команда получает дефект, который трудно воспроизвести: глазами интерфейс выглядит готовым.

\n

Цена ошибки растёт на каждом слое. Пользователь повторяет действие или покидает сценарий. Тест проверяет только текст и пропускает потерянный фокус. Следующая правка добавляет ещё один эффект и ещё одну копию состояния. Разметка содержит role=\"button\", но не содержит поведения, которое это слово обещает.

\n

Тезис статьи простой: доступный control нужно проектировать как единый контракт. Контракт отвечает на пять вопросов: какое имя услышит пользователь, какую роль и состояние получит технология, кто владеет значением, какие клавиши меняют его и куда переходит фокус после каждого исхода. Если ответ существует только в CSS или в отдельном обработчике, интерфейс уже имеет разрыв.

\n

Сначала семантика, затем внешний вид

\n

Нативный button обычно безопаснее произвольного div. Браузер уже знает, что элемент участвует в фокусе и активируется клавиатурой. Нативные input, label, fieldset и legend также дают связи, которые иначе придётся собрать вручную. Это не отменяет проверку имени, порядка и состояния. Это уменьшает число обязанностей автора.

\n

ARIA не превращает произвольный элемент в рабочий виджет. role=\"button\" сообщает семантику, но не добавляет обработку Enter и Space, видимый фокус, управление tabindex или возврат фокуса. role=\"radio\" не выбирает соседний вариант и не синхронизирует aria-checked с моделью. Поэтому custom control принимают целиком: разметка, обработчики, переходы состояния, клавиатурный маршрут и проверка входят в один набор изменений.

\n

Для группы каналов уведомлений достаточно сначала проверить, нужен ли custom widget. Если обычный fieldset с двумя radio подходит дизайну, он снимает часть риска. Если компонент обязан использовать ARIA-паттерн, его группа получает имя, каждый вариант получает имя и согласованное состояние, а клавиши следуют выбранному паттерну. Нельзя смешать поведение обычной группы и toolbar только потому, что так удобнее обработчику.

\n

Механизм: одно состояние, несколько представлений

\n

У панели есть два разных значения. channel — черновой выбор внутри открытой панели. savedChannel — последнее подтверждённое значение. Пока пользователь не нажал «Сохранить», черновик может меняться, а сохранённое значение должно оставаться прежним. Это правило защищает отрицательный путь: неверный выбор, закрытие без сохранения и отмена не должны менять результат.

\n

Отдельно хранится маршрут фокуса. В учебной модели это строка, например preferences-trigger или notification-email. Она не вызывает HTMLElement.focus() и не делает утверждений о браузере. Она фиксирует решение компонента: после открытия входом служит первый control, после ошибки — конкретное поле, после успешного закрытия — исходная кнопка. Реальный DOM и реальный accessibility tree требуют отдельной проверки.

\n
const panel = {\n  open: false,\n  channel: 'email',\n  savedChannel: 'email',\n  focus: 'preferences-trigger',\n  status: ''\n};\n\nfunction choose(next) {\n  if (!['email', 'sms'].includes(next)) {\n    panel.focus = 'notification-email';\n    panel.status = 'Выберите доступный канал';\n    return false;\n  }\n\n  panel.channel = next;\n  panel.focus = 'notification-apply';\n  return true;\n}\n\nfunction save() {\n  panel.savedChannel = panel.channel;\n  panel.open = false;\n  panel.focus = 'preferences-trigger';\n  panel.status = `Канал сохранён: ${panel.savedChannel}`;\n}
\n

Код показывает границу, а не готовый компонент. В нём нет DOM, таймеров, live region, браузера и screen reader. Он проверяет только порядок: недопустимое значение не коммитится, допустимое значение переводит маршрут к сохранению, а успешное сохранение возвращает пользователя к trigger. Если заменить panel.savedChannel = panel.channel на обновление до валидации, учебная проверка должна зафиксировать нарушение.

\n

В production обновление состояния и разметки должно иметь один источник истины. Визуальный selected, aria-checked, текст статуса и доступность кнопки сохранения не должны вычисляться из разных копий. Если UI показывает SMS, а семантическое состояние всё ещё говорит Email, проблема не в недостающем атрибуте. Проблема в том, что переход состояния разделили между владельцами.

\n

Симптом → причина → проверка → действие

\n
Диагностика разрыва контракта интерактивного компонента
СимптомПричинаПроверкаДействие
Tab пропускает вход или фокус исчезает после закрытияCustom host не имеет полного keyboard contract; return target не сохранён до unmountПройти Tab и Shift+Tab; записать вход, ошибку, закрытие и фактический focused elementВернуть native host либо определить keyboard path и явный return target
Активный вариант виден, но технология получает старое значениеCSS-класс и semantic state читают разные источникиСравнить visual state, accessible tree и значение state после одного выбораВыводить оба представления из одного state owner
Неверный ввод меняет сохранённые настройкиЧерновик и committed value склееныПодать значение вне allow-list и проверить savedChannel до и после ветки ошибкиВалидировать до commit; вернуть фокус к исправляемому control
Toast виден, но результат не понятен без зренияСообщение существует только как цвет или визуальный popupПроверить программно определяемый status в выбранной связке браузера и assistive technologyСвязать сообщение с изменением состояния и зафиксировать среду проверки
Роль добавили, но Enter и Space ведут себя по-разномуСемантика скопирована без поведения паттернаСопоставить обработчики и порядок клавиш с выбранным APG-паттерномИспользовать native control или реализовать весь паттерн, включая фокус
\n

Иллюстрация маршрута

\n
\"Схема
Схема показывает, какие данные должны идти вместе: имя и роль control, выбранное состояние, результат сохранения и маршрут фокуса. Иллюстрация учебная. Она не является снимком accessibility tree и не доказывает, что screen reader произнесёт статус.
\n

Схему удобно читать слева направо. Trigger открывает панель. Группа получает имя «Канал уведомлений». Вариант меняет черновик и semantic state. Ошибка возвращает маршрут к control, который нужно исправить. Успешное сохранение обновляет committed value, закрывает панель и возвращает фокус к trigger. Если в коде нет одной из этих стрелок, её нельзя считать «само собой разумеющейся».

\n

Порядок действий

\n
  1. Опишите сценарий. Назовите trigger, группу, варианты, действие сохранения, статус и точку возврата. Запишите нормальный и отрицательный путь.
  2. Выберите host. Проверьте, закрывает ли нативный элемент задачу. Не заменяйте его на div ради удобного CSS без оценки новой ответственности.
  3. Разведите состояния. Отделите draft от committed value. Опишите момент commit и условия, при которых он не происходит.
  4. Соберите semantic contract. Для каждого control зафиксируйте accessible name, role, state и связь с группой. Для статуса укажите, какое сообщение формируется.
  5. Опишите keyboard route. Укажите результат Tab, Shift+Tab, Enter, Space и стрелок только для тех ролей, которым они нужны. Укажите фокус после открытия, ошибки, отмены и сохранения.
  6. Защитите отрицательный путь. Подайте недопустимое значение, закройте панель без save и повторите действие undo, если оно предусмотрено. Сохранённое значение не должно измениться случайно.
  7. Проверьте реализацию. Сначала осмотрите DOM и доступное дерево выбранного браузера. Затем пройдите клавиатурный сценарий. После этого проверьте статус в согласованной связке браузера и assistive technology.
  8. Запишите границы. Укажите браузер, версию технологии, язык, сценарий и ограничения результата. Учебная модель может подсказать инвариант, но не заменяет эту запись.
\n

Ограничения и отрицательный путь

\n

Контракт одной панели не описывает весь продукт. Он не проверяет контраст, размер цели касания, локализацию, виртуальный курсор, модальные окна, сложную таблицу или конфликт глобальных горячих клавиш. Паттерн radio group нельзя автоматически перенести на combobox, menu или grid: у них другие роли, клавиши и правила фокуса.

\n

Даже правильный aria-label не исправляет неясное действие. Даже пройденный автоматический аудит не доказывает удобство сценария. APG даёт практический паттерн, но не является единственным доказательством для конкретной реализации. Нельзя заявлять production-результат по учебному коду или по наличию атрибута.

\n

Отрицательный путь важнее красивого happy path. Если save падает, сервер отвечает с ошибкой или компонент размонтируется, команда должна знать, останется ли draft, что увидит пользователь и куда уйдёт фокус. Если ответа нет, это не «крайний случай». Это незаписанная часть контракта. Её лучше определить до интеграции с сетью и до широкого рефакторинга.

\n

Критерий готовности

\n

Компонент можно считать готовым к следующей проверке, когда для каждого интерактивного control записаны имя и роль, state имеет одного владельца, draft не меняет committed value до commit, а keyboard route покрывает вход, ошибку, отмену и успешное завершение. На реальной странице тест должен подтвердить ожидаемый порядок фокуса и согласованность visual state с доступным состоянием.

\n

Проверяемый результат выглядит так: недопустимое действие оставляет сохранённое значение прежним; допустимое действие меняет его ровно один раз; после закрытия фокус оказывается на объявленной точке; status существует в программно определяемой форме; повторный undo не откатывает новое состояние. Для каждого пункта есть шаг, наблюдаемый результат и среда. Если есть только фраза «компонент доступен», проверка ещё не закончена.

\n

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

\n" }