{ "index": 206, "slug": "editorial-2022-04-mechanism-accessible-interface", "title": "Контракт доступного control: имя, состояние и фокус должны меняться вместе", "excerpt": "Почему role сам по себе не исправляет custom control и как связать name, state, focus route и declared status в одном контракте без заявления о реальном accessibility tree.", "contentHtml": "
Симптом часто выглядит как спор между фронтендом и дизайном: «кнопка уже есть, только добавим aria-атрибут». После этого в markup появляется role, но клавиатурный путь не появляется, визуальное selected не совпадает с declared state, а после ошибки фокус остаётся неизвестным. Цена такой частичной правки — компонент получает убедительную разметку и непредсказуемое поведение. Ошибку сложнее увидеть, потому что она скрыта за правильным словом button.
Причина в раздельном владении. Визуальная ветка знает class и иконку, обработчик знает payload, а semantic state приклеен последним эффектом. Для интерактивного элемента этого недостаточно: имя, роль, текущее состояние, допустимое действие и маршрут фокуса образуют один контракт. В статье это контракт одной панели уведомлений. Он не описывает полноценную a11y-платформу, не строит реальное дерево доступности и не приписывает автору проведённый user study; он показывает, что можно проверить до таких работ.
\nARIA role сообщает потребительскому слою, какой вид взаимодействия заявлен. Она не добавляет его автоматически. Если non-native элемент объявлен кнопкой, автор кода отвечает за доступность с клавиатуры, фокус и активацию. Если элемент объявлен radiogroup, одного цвета недостаточно: нужен понятный group name и согласованное выбранное состояние. Именно поэтому нативная разметка обычно безопаснее как старт: браузер уже реализует часть стандартного поведения, а компоненту остаётся не сломать связь со своим state.
\nНе стоит делать из этого правило «ARIA нельзя использовать». Паттерны нужны, когда нативной семантики не хватает. Но тогда изменение принимается как единый diff: DOM shape, обработчик, state transition, keyboard route, визуальный focus indicator и тест. Если PR содержит только role=\"button\", reviewer ещё не знает, как элемент получает фокус, что делает Space, когда меняется state и куда вернётся человек после закрытия связанной панели.
| Часть | Проектное значение в fixture | Разрыв | Что проверить отдельно |
|---|---|---|---|
| Name + role | «Настроить уведомления», button | иконка видна, текстового имени нет | семантику native host или явное доступное имя |
| State | email/sms, declared checked value | class active обновился, state нет | один source of truth для visual и semantic state |
| Focus route | trigger → email → apply → trigger | закрытие unmount удаляет focused node | точки входа, ошибки и return target |
| Status | declared text после save/undo | сообщение только меняет цвет | что разметка передаёт состояние; фактическое объявление проверяется вне модели |
В панели два похожих значения: channel — текущий выбор внутри открытой панели, savedChannel — последний подтверждённый выбор. Если их склеить, invalid action или закрытие без save может изменить то, что интерфейс считает сохранённым. В учебной модели invalid carrier-pigeon не проходит список допустимых значений, получает named focus notification-email и не трогает savedChannel. Это не browser validation. Это маленький инвариант владения state.
После правильного выбора sms focus route переносится на apply. Save переносит прежний saved value в pendingUndo, закрывает панель и возвращает маршруту trigger. Такое разделение полезно не потому, что каждый control обязан иметь undo. Оно даёт проверяемый ответ на вопрос «что именно откатится, если действие оказалось неверным?». Если команда не может назвать prior state, она не сможет честно реализовать ни отмену, ни безопасный retry.
const panel = {\n open: true,\n channel: 'email',\n savedChannel: 'email',\n pendingUndo: null,\n focus: 'notification-email',\n status: ''\n};\n\nfunction chooseChannel(next) {\n if (!panel.open) return { ok: false, kind: 'panel-closed' };\n if (!['email', 'sms'].includes(next)) {\n panel.focus = 'notification-email';\n panel.status = 'Выберите канал: Email или SMS.';\n return { ok: false, kind: 'invalid-channel' };\n }\n panel.channel = next;\n panel.focus = 'notification-apply';\n return { ok: true, kind: 'channel-selected' };\n}\n\nfunction save() {\n if (!panel.open) return { ok: false, kind: 'panel-closed' };\n if (!['email', 'sms'].includes(panel.channel)) {\n panel.focus = 'notification-email';\n return { ok: false, kind: 'invalid-state' };\n }\n const previous = panel.savedChannel;\n panel.savedChannel = panel.channel;\n panel.pendingUndo = previous;\n panel.open = false;\n panel.focus = 'preferences-trigger';\n panel.status = 'Канал уведомлений сохранён: ' + panel.savedChannel + '.';\n return { ok: true, kind: 'saved' };\n}\n\nfunction undoLastSave() {\n if (panel.pendingUndo === null) return { ok: false, kind: 'nothing-to-undo' };\n const restored = panel.pendingUndo;\n panel.savedChannel = restored;\n panel.channel = restored;\n panel.pendingUndo = null;\n panel.focus = 'preferences-trigger';\n return { ok: true, kind: 'undo-saved-change' };\n}\n\nconst invalid = chooseChannel('carrier-pigeon');\nconst savedAfterInvalid = panel.savedChannel;\nconst chosen = chooseChannel('sms');\nconst saved = save();\nconst undone = undoLastSave();\nconst secondUndo = undoLastSave();\n\nconsole.assert(invalid.kind === 'invalid-channel' && savedAfterInvalid === 'email');\nconsole.assert(chosen.kind === 'channel-selected' && saved.kind === 'saved');\nconsole.assert(undone.kind === 'undo-saved-change' && panel.savedChannel === 'email');\nconsole.assert(secondUndo.kind === 'nothing-to-undo');\nconsole.log(panel);\nЭтот фрагмент можно запустить в Node.js: он оставляет сохранённое значение прежним после недопустимого ввода, коммитит sms, один раз восстанавливает email и отклоняет второй undo. Это модель переходов, а не готовый browser-компонент: она не создаёт DOM, не отправляет клавиши, не строит live region и не наблюдает accessibility tree.
Пример можно прочитать как псевдокод границы, но не как copy-paste для browser. У panel.state.focus строковое значение; вызова HTMLElement.focus() нет. У declaredAnnouncement текст; live region не создан. За счёт этого тест не делает ложного вывода «клавиатура прошла путь». Он проверяет, что выбранный проектом путь не противоречит state transition. Реальный компонент получает второй слой тестов там, где есть выбранная платформа и конкретная разметка.
Видимый toast часто полезен, но он не гарантирует, что изменение стало понятным любому способу взаимодействия. Обратная ошибка — поставить role=\"status\" и считать задачу закрытой. W3C-критерий Status Messages говорит о программно определяемых сообщениях, но не обещает одну и ту же речь во всех сочетаниях. На результат влияют разметка, порядок изменения, язык, пользовательские настройки и конкретная технология. Поэтому в проектном контракте полезно разделить «какое сообщение сформировано» и «какой результат наблюдён в среде проверки».
В fixture первое значение хранится как declaredAnnouncement, а второе намеренно отсутствует: assistiveTechnology: not-observed. Такое отсутствие — не дырка. Оно не даёт коду подменить факт. Когда у команды появится реальная проверка, к контракту можно добавить её протокол: браузер, версия, технология, язык, сценарий, результат и известное ограничение. Пока этих входов нет, они не должны появляться в статье как будто уже собраны.
Unit fixture хорошо ловит ошибки порядка и rollback: invalid value не должна сохраняться, save должен вернуть declared route, undo не должен сработать второй раз. Она также делает документацию состояния исполнимой: после будущей оптимизации нельзя случайно вынести update savedChannel раньше проверки. Но unit fixture не умеет доказать focus-visible CSS, tab order среди соседних компонентов, поведение портала, конфликт shortcut или реальный output screen reader.
Поэтому разумно держать два артефакта. Первый — маленькая модель контракта рядом с компонентом. Второй — платформенная проверка, где есть DOM и где запись результата содержит условия. Не обязательно строить огромный framework, чтобы сделать это честно. Для одного критичного диалога достаточно зафиксировать сценарий, поддерживаемые среды и те проверки, которые команда реально может повторить. Главное — не выдавать один слой за другой.
\nПаттерн панели не переносится автоматически на меню, grid, combobox или сложный редактор: у них другая модель фокуса и другие keyboard conventions. Даже в этой панели решение о том, оставлять ли disabled action в tab sequence, зависит от продукта и должно быть принято осознанно. Модель также не отвечает на вопрос, кто сформулировал понятное имя на всех языках. Она специально уже, чем настоящий интерфейс, чтобы не скрыть эти открытые вопросы словом «универсальный».
\nСледующий шаг — завести для одного control ownership table: где хранится draft, где committed value, кто формирует status, где находится return target и какой test защищает invalid branch. После этого можно вынести общую helper-функцию только там, где два компонента действительно имеют один и тот же contract. Раннее «обобщение a11y» часто создаёт новую копию состояния; сначала полезнее закрепить одну понятную границу.
\nНормативная опора здесь — WCAG 2.1 Recommendation 2018 года; практические keyboard patterns — WAI-ARIA Authoring Practices 1.2, Group Note от 29 ноября 2021 года. Неизменяемый WAI-ARIA 1.2 Candidate Recommendation Draft от 8 декабря 2021 года задаёт исторический статус спецификации. Поэтому текст говорит о доступных тогда критериях и guidance, а не переносит назад будущие версии рекомендаций или современные инструменты аудита.
\n