From 72dc9c430cc79c2e6cd041805ea6dc133e8312de Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 19:50:28 +0300 Subject: [PATCH] =?UTF-8?q?EDITORIAL-206:=20=D0=B2=D1=8B=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D1=8C=D1=8E=20?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editorial/agent-rewrites/206.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/editorial/agent-rewrites/206.json b/editorial/agent-rewrites/206.json index 5190aad..1e4aa9c 100644 --- a/editorial/agent-rewrites/206.json +++ b/editorial/agent-rewrites/206.json @@ -1,7 +1,7 @@ { "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" + "title": "Контракт доступного control: имя, состояние и фокус должны меняться вместе", + "excerpt": "Почему role сам по себе не исправляет custom control и как связать name, state, focus route и declared status в одном контракте без заявления о реальном accessibility tree.", + "contentHtml": "

Симптом часто выглядит как спор между фронтендом и дизайном: «кнопка уже есть, только добавим aria-атрибут». После этого в markup появляется role, но клавиатурный путь не появляется, визуальное selected не совпадает с declared state, а после ошибки фокус остаётся неизвестным. Цена такой частичной правки — компонент получает убедительную разметку и непредсказуемое поведение. Ошибку сложнее увидеть, потому что она скрыта за правильным словом button.

\n

Причина в раздельном владении. Визуальная ветка знает class и иконку, обработчик знает payload, а semantic state приклеен последним эффектом. Для интерактивного элемента этого недостаточно: имя, роль, текущее состояние, допустимое действие и маршрут фокуса образуют один контракт. В статье это контракт одной панели уведомлений. Он не описывает полноценную a11y-платформу, не строит реальное дерево доступности и не приписывает автору проведённый user study; он показывает, что можно проверить до таких работ.

\n

Роль — обещание поведения

\n

ARIA role сообщает потребительскому слою, какой вид взаимодействия заявлен. Она не добавляет его автоматически. Если non-native элемент объявлен кнопкой, автор кода отвечает за доступность с клавиатуры, фокус и активацию. Если элемент объявлен radiogroup, одного цвета недостаточно: нужен понятный group name и согласованное выбранное состояние. Именно поэтому нативная разметка обычно безопаснее как старт: браузер уже реализует часть стандартного поведения, а компоненту остаётся не сломать связь со своим state.

\n

Не стоит делать из этого правило «ARIA нельзя использовать». Паттерны нужны, когда нативной семантики не хватает. Но тогда изменение принимается как единый diff: DOM shape, обработчик, state transition, keyboard route, визуальный focus indicator и тест. Если PR содержит только role=\"button\", reviewer ещё не знает, как элемент получает фокус, что делает Space, когда меняется state и куда вернётся человек после закрытия связанной панели.

\n
Четыре части контракта и типичный разрыв
ЧастьПроектное значение в fixtureРазрывЧто проверить отдельно
Name + role«Настроить уведомления», buttonиконка видна, текстового имени нетсемантику native host или явное доступное имя
Stateemail/sms, declared checked valueclass active обновился, state нетодин source of truth для visual и semantic state
Focus routetrigger → email → apply → triggerзакрытие unmount удаляет focused nodeточки входа, ошибки и return target
Statusdeclared text после save/undoсообщение только меняет цветчто разметка передаёт состояние; фактическое объявление проверяется вне модели
\n

Граница state: черновик не равен сохранённому значению

\n

В панели два похожих значения: channel — текущий выбор внутри открытой панели, savedChannel — последний подтверждённый выбор. Если их склеить, invalid action или закрытие без save может изменить то, что интерфейс считает сохранённым. В учебной модели invalid carrier-pigeon не проходит список допустимых значений, получает named focus notification-email и не трогает savedChannel. Это не browser validation. Это маленький инвариант владения state.

\n

После правильного выбора sms focus route переносится на apply. Save переносит прежний saved value в pendingUndo, закрывает панель и возвращает маршруту trigger. Такое разделение полезно не потому, что каждый control обязан иметь undo. Оно даёт проверяемый ответ на вопрос «что именно откатится, если действие оказалось неверным?». Если команда не может назвать prior state, она не сможет честно реализовать ни отмену, ни безопасный retry.

\n
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.

\n\n

Пример можно прочитать как псевдокод границы, но не как copy-paste для browser. У panel.state.focus строковое значение; вызова HTMLElement.focus() нет. У declaredAnnouncement текст; live region не создан. За счёт этого тест не делает ложного вывода «клавиатура прошла путь». Он проверяет, что выбранный проектом путь не противоречит state transition. Реальный компонент получает второй слой тестов там, где есть выбранная платформа и конкретная разметка.

\n

Семантический contract как схема

\n
\"Схема
Схема не заменяет accessibility tree. Она помогает reviewer увидеть, какие данные должны измениться вместе, прежде чем начинать проверку конкретного браузера.
\n

Почему status нельзя сводить к всплывающему тексту

\n

Видимый toast часто полезен, но он не гарантирует, что изменение стало понятным любому способу взаимодействия. Обратная ошибка — поставить role=\"status\" и считать задачу закрытой. W3C-критерий Status Messages говорит о программно определяемых сообщениях, но не обещает одну и ту же речь во всех сочетаниях. На результат влияют разметка, порядок изменения, язык, пользовательские настройки и конкретная технология. Поэтому в проектном контракте полезно разделить «какое сообщение сформировано» и «какой результат наблюдён в среде проверки».

\n

В fixture первое значение хранится как declaredAnnouncement, а второе намеренно отсутствует: assistiveTechnology: not-observed. Такое отсутствие — не дырка. Оно не даёт коду подменить факт. Когда у команды появится реальная проверка, к контракту можно добавить её протокол: браузер, версия, технология, язык, сценарий, результат и известное ограничение. Пока этих входов нет, они не должны появляться в статье как будто уже собраны.

\n

Маршрут изменения без большой переделки

\n
  1. Симптом. Custom control визуально реагирует, но keyboard path, selected state или сообщение о результате не определены.
  2. Причина. CSS, event handler и accessibility properties владеют разными копиями состояния либо роль добавлена без поведения.
  3. Проверка модели. Назовите trigger, group, options, apply и status. Для каждого запишите name, role, state owner и переход focus.
  4. Проверка разрыва. Измените один option в локальном тесте: visual state, declared checked value и saved value должны либо меняться согласованно, либо явно оставаться разными до save.
  5. Действие. Верните native host, если дизайн не требует custom widget. Иначе оформите keyboard contract и focus route в том же изменении, а не в следующем тикете.
  6. Проверка реализации. На реальной странице отдельно проверьте семантику, keyboard sequence и заявленное status-поведение в согласованной матрице окружений; fixture даёт только основу для такого теста.
\n

Где граница между contract и тестом

\n

Unit fixture хорошо ловит ошибки порядка и rollback: invalid value не должна сохраняться, save должен вернуть declared route, undo не должен сработать второй раз. Она также делает документацию состояния исполнимой: после будущей оптимизации нельзя случайно вынести update savedChannel раньше проверки. Но unit fixture не умеет доказать focus-visible CSS, tab order среди соседних компонентов, поведение портала, конфликт shortcut или реальный output screen reader.

\n

Поэтому разумно держать два артефакта. Первый — маленькая модель контракта рядом с компонентом. Второй — платформенная проверка, где есть DOM и где запись результата содержит условия. Не обязательно строить огромный framework, чтобы сделать это честно. Для одного критичного диалога достаточно зафиксировать сценарий, поддерживаемые среды и те проверки, которые команда реально может повторить. Главное — не выдавать один слой за другой.

\n

Ограничение и следующий шаг

\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

Историческая граница апреля 2022

\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

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

\n" }