diff --git a/editorial/agent-rewrites/207.json b/editorial/agent-rewrites/207.json index 1d59c86..119a1e5 100644 --- a/editorial/agent-rewrites/207.json +++ b/editorial/agent-rewrites/207.json @@ -3,5 +3,5 @@ "slug": "editorial-2022-04-practice-accessible-interface", "title": "Доступный интерфейс начинается с маршрута фокуса", "excerpt": "Как связать имя, роль, состояние и фокус в одном интерактивном сценарии и проверить ошибку до того, как пользователь потеряет управление формой.", - "contentHtml": "

Панель настроек открывается по клику, но после нажатия Tab фокус уходит в неизвестное место. Клавиатурный пользователь не понимает, что панель появилась, а после сохранения фокус исчезает вместе с удалённым узлом. Другая частая ошибка выглядит тише: выбранный канал отмечен цветом и галочкой, но его имя и состояние не попадают в семантический слой. Цена ошибки — потерянное действие, повторный ввод и невозможность понять, сохранились ли данные.

Доступный интерфейс нужно проверять как маршрут состояния. Для каждого шага задайте имя элемента, его роль, текущее значение, точку фокуса и результат действия. Если одно из этих свойств живёт отдельно, компонент может выглядеть исправным и всё равно ломаться без мыши.

Тезис: у действия есть контракт

Интерактивный компонент не сводится к разметке и стилям. Его контракт описывает, что человек видит и что получает в ответ. Кнопка открытия имеет понятное имя и принимает фокус. Панель получает доступное имя. Группа вариантов сообщает выбранное значение. Ошибка оставляет прежнее сохранённое значение и даёт понятную точку исправления. После успешного закрытия фокус возвращается на кнопку, которая открыла панель, если сценарий не требует другой точки.

Этот порядок связывает четыре слоя: семантику, состояние, клавиатурное поведение и визуальный сигнал. Атрибут role не добавляет обработчик клавиши, не рисует focus ring и не возвращает фокус после удаления элемента. Если проект заменяет нативный button на div, он берёт на себя весь недостающий контракт. Поэтому первый выбор — сохранить нативный элемент. Custom control нужен только тогда, когда его поведение описано и проверено целиком.

Сценарий панели уведомлений

Рассмотрим небольшую панель «Настроить уведомления». На странице есть кнопка открытия. В панели пользователь выбирает один канал: Email или SMS. Кнопка «Сохранить» подтверждает выбор. Внутри нужно различать черновое значение и сохранённое значение. Черновик меняется при выборе. Сохранённое значение меняется только после подтверждения.

При открытии фокус переходит на заголовок или первый пригодный для действия элемент — выбор зависит от паттерна и размера панели. Для этой панели выберем первый вариант Email. При недопустимом выборе, например carrier-pigeon, система не меняет сохранённый канал. Ошибка связывается с полем и оставляет фокус на месте исправления. После сохранения панель закрывается, фокус возвращается на notifications-trigger, а статус сообщает о результате в доступной форме.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
После открытия Tab продолжает идти по страницеПанель не получила точку входа, а фокус остался на trigger или ушёл в фонОткрыть панель клавиатурой и назвать первый ожидаемый focus targetЗадать маршрут входа и удерживать фокус в модальной области, если она действительно modal
Вариант отмечен цветом, но выбор не читаетсяVisual state и semantic state вычисляются из разных источниковСравнить значение в state с aria-checked или нативным checkedВывести оба сигнала из одного значения
Ошибка видна рядом с полем, но её не находят без мышиСообщение передаётся только цветом или не связано с controlПроверить label, связь ошибки и маршрут к invalid valueСохранить старое значение, назвать ошибку и вернуть фокус к исправлению
После сохранения клавиатура теряет позициюFocused node удалили вместе с панельюЗафиксировать activeElement до открытия и после закрытияВернуть фокус на trigger либо на логичную следующую точку
Статус есть в DOM, но результат неясенТекст статуса не связан с изменением состояния или заявлен без проверкиПроверить момент обновления и содержимое status regionОбновлять короткое сообщение после результата и отдельно проверить поддерживаемую связку браузера с assistive technology

Минимальная семантическая разметка

Нативная форма уже даёт много нужного поведения. Подпись связана с полем через for и id. Кнопка имеет явный тип. Группа вариантов имеет видимый заголовок. Не добавляйте ARIA ради похожего HTML: сначала проверьте, что нативный элемент выражает требуемый смысл.

<fieldset>\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"></p>\n</fieldset>\n<button type="submit">Сохранить</button>

Это учебный фрагмент. Он показывает связь подписи, группы и ошибки, но не доказывает поведение конкретного приложения. В рабочем коде нужно проверить порядок фокуса, стили состояния, локализацию, отправку формы и поведение при асинхронном сохранении. Если панель является модальным диалогом, добавьте корректные границы диалога, название и обработку закрытия; не называйте обычный раскрывающийся блок modal только ради атрибута.

Граница между состоянием и сообщением

Компоненту полезно хранить состояния, которые нельзя смешивать. channel — текущий выбор. savedChannel — подтверждённое значение. focusTarget — ожидаемая точка маршрута. statusText — объявленный результат. Если обработчик сразу записывает выбор в savedChannel, отрицательная ветка становится опасной: ошибка или отмена уже меняет данные.

Ниже учебная модель. Она не создаёт DOM, не отправляет события клавиатуры и не утверждает, что screen reader произнёс строку. Её задача — показать инвариант: недопустимое значение не меняет сохранённое, а успешное закрытие имеет явный return target.

const state = {\n  channel: 'email',\n  savedChannel: 'email',\n  focusTarget: 'notifications-trigger',\n  statusText: ''\n};\n\nfunction chooseChannel(value) {\n  if (!['email', 'sms'].includes(value)) {\n    state.statusText = 'Выберите Email или SMS';\n    state.focusTarget = 'channel-email';\n    return { ok: false, savedChannel: state.savedChannel };\n  }\n  state.channel = value;\n  state.focusTarget = 'save-notifications';\n  return { ok: true, savedChannel: state.savedChannel };\n}\n\nfunction save() {\n  state.savedChannel = state.channel;\n  state.focusTarget = 'notifications-trigger';\n  state.statusText = 'Канал уведомлений сохранён';\n  return { ok: true, returnFocus: state.focusTarget };\n}

Модель ограничена намеренно. Она проверяет переходы данных, но не browser accessibility tree. Реальная проверка должна подтвердить, что DOM отражает эти переходы: selected state меняется на том же шаге, ошибка связана с нужным control, а activeElement получает ожидаемую точку после открытия и закрытия.

Маршрут фокуса панели уведомлений: кнопка открытия ведёт к Email, ошибка возвращает к выбору, сохранение возвращает на кнопку.
Маршрут показывает две ветки: исправление ошибочного выбора и возврат после успешного закрытия. Это схема ожидаемого поведения, а не запись работы screen reader.

Порядок проверки

  1. Запишите наблюдаемый симптом и цену ошибки. Например: после открытия панели клавиатура продолжает обходить фон, а пользователь не может понять, где находится.
  2. Назовите каждый интерактивный элемент. Для него укажите доступное имя, роль, значение и действие. Если имя нельзя сформулировать одним предложением, остановите проверку разметки.
  3. Разделите черновое и сохранённое состояние. Проверьте, что invalid input и отмена не меняют подтверждённое значение.
  4. Опишите маршрут фокуса для входа, выбора, ошибки, сохранения и закрытия. Заранее назовите activeElement после каждого перехода.
  5. Пройдите сценарий только клавиатурой: Tab, Shift+Tab, Enter и Space там, где они предусмотрены выбранным паттерном. Убедитесь, что видимый focus indicator не закрывает контент и не исчезает.
  6. Проверьте DOM и accessibility tree в поддерживаемом браузере. Сверьте name, role, state, label, error и status с записанным контрактом.
  7. Проверьте отрицательный путь: неизвестное значение, пустой ввод, ошибка сохранения и закрытие без подтверждения. Зафиксируйте, что остаётся неизменным и куда возвращается фокус.
  8. Проверьте одну-две целевые связки браузера и ассистивной технологии. Запишите версии и фактический результат; объявленный текст в state не заменяет это наблюдение.
  9. После исправления повторите тот же маршрут и добавьте автоматическую проверку для стабильных переходов состояния. Автотест не должен выдавать проверку DOM за исследование пользовательского опыта.

Ограничения

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

Нативный элемент снижает объём собственной логики, но не отменяет проверку. Можно скрыть focus ring стилями, дать кнопке пустое имя, обновить текст без изменения состояния или закрыть панель раньше завершения сохранения. ARIA помогает передать семантику, но не сообщает, что UX удобен и что реальная программа чтения экрана произнесёт ожидаемую фразу.

Учебный код выше не является production-результатом. Он не измеряет долю ошибок, скорость исправления и совместимость со всеми браузерами. Не подставляйте такие примеры в отчёт как доказательство качества. Production-критерий должен опираться на фактический DOM, клавиатурный проход и зафиксированную комбинацию браузера с assistive technology.

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

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

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

" + "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 есть проверка выбранной связки. Фраза «должно работать» без шага и результата остаётся незакрытой частью контракта.

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

" }