{ "index": 205, "slug": "editorial-2022-04-field-accessible-interface", "title": "Доступный control после правки: как не потерять фокус, состояние и обратный путь", "excerpt": "Визуально исправный control может потерять смысл для клавиатуры и screen reader. Разбираем расхождение состояний на примере панели настроек и задаём проверяемый путь исправления.", "contentHtml": "

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

\n

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

\n

Сначала разделите черновик и сохранённое значение

\n

Панель выбора имеет как минимум два значения. draftChannel показывает выбор внутри открытой панели. savedChannel отражает настройку, которая уже принята продуктом. Пока пользователь не подтвердил выбор, эти значения могут различаться. Ошибка валидации не должна менять savedChannel. Успешное сохранение сначала запоминает старое значение, затем коммитит новое и только после этого закрывает панель.

\n

У control есть и другие части контракта: имя и роль группы, состояние выбранной опции, trigger, target для ошибки, target для возврата после закрытия и сообщение о результате. Их не нужно хранить в шести независимых переменных. Они должны выводиться из одной модели переходов. Тогда проверка отвечает на конкретный вопрос: какое действие изменило состояние и куда попал фокус после этого действия.

\n
const state = {\n  open: true,\n  draftChannel: 'sms',\n  savedChannel: 'email',\n  previousChannel: null,\n  focusTarget: 'channel-sms',\n  status: ''\n};\n\nfunction save(state) {\n  if (!['email', 'sms'].includes(state.draftChannel)) {\n    return { ...state, focusTarget: 'channel-error', status: 'Выберите канал' };\n  }\n\n  return {\n    ...state,\n    open: false,\n    previousChannel: state.savedChannel,\n    savedChannel: state.draftChannel,\n    focusTarget: 'preferences-trigger',\n    status: 'Канал сохранён'\n  };\n}
\n

Это учебный пример переходов. Он не создаёт DOM, не отправляет события клавиатуры и не доказывает, что конкретная технология озвучит сообщение. В нём важна граница: невалидная ветка не коммитит значение, а успешная ветка сохраняет предыдущее значение до закрытия панели. В реальном компоненте эти переходы нужно связать с семантической разметкой и проверить в выбранных браузерах и assistive technology.

\n

Почему одинаковый симптом скрывает разные причины

\n

После клика пользователь может увидеть новую галочку и всё равно получить старое состояние в accessibility tree. Так происходит, когда CSS меняет класс, а aria-checked получает значение из другой переменной. Фокус может исчезнуть не из-за screen reader, а потому, что компонент удалили до того, как выбрали return target. Сообщение может быть видно, но не иметь программно определяемого статуса. Поэтому диагностика должна начинаться с последовательности переходов, а не с перебора атрибутов.

\n
Диагностика разрыва контракта control
СимптомПричинаПроверкаДействие
После закрытия фокус неясенПанель удаляется до выбора return targetЗаписать активный элемент до и после closeВыбрать существующий target до unmount и проверить его клавиатурой
Галочка видна, но состояние староеCSS и семантика используют разные источникиСравнить visual value и declared value после одного выбораВывести оба значения из одного state owner
Ошибка есть только для мышиСообщение не связано с полем и фокусомПроверить имя, описание, target ошибки и порядок переходаСвязать ошибку с control и направить фокус на исправление
Старое значение изменилось при ошибкеDraft и committed state склееныВыполнить invalid branch и сравнить saved valueОстановить commit до успешной проверки
Toast виден, но результат непредсказуемТекст создаётся отдельно от result stateНайти место формирования статуса и его семантическую границуСформировать статус в контракте, затем проверить платформенный output
\n

Один пример и отрицательный путь

\n

Рассмотрим панель «Канал уведомлений». Trigger имеет имя «Настроить уведомления» и сообщает, открыта ли панель. Группа выбора получает имя «Канал уведомлений». В ней есть две radio options: Email и SMS. Кнопка «Сохранить» подтверждает draft. При успехе панель закрывается, focus возвращается на trigger, а статус сообщает о принятом действии. Это не единственный возможный маршрут. Если после сохранения появляется отдельный результат, продукт может направить фокус туда. Важно принять решение до удаления панели.

\n

Отрицательная ветка начинается с невозможного или неподдерживаемого значения. Панель остаётся открытой. savedChannel не меняется. Сообщение об ошибке связано с местом исправления. Фокус получает понятный target. Пользователь может изменить draft и повторить действие. Если обработчик сначала записывает значение, а потом проверяет его, последующее сообщение уже не исправит неверное состояние.

\n
\"Схема
Учебная схема разделяет invalid branch и valid save. Она показывает границы состояния, фокуса и отмены; фактический DOM и речь screen reader нужно проверять отдельно.
\n

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

\n
  1. Опишите наблюдаемый сбой. Запишите один симптом: фокус потерян, состояние не совпало с визуальным выбором или ошибка не дала следующего шага. Не называйте причину до проверки.
  2. Назовите контракт. Зафиксируйте имя, роль, выбранное состояние, draft value, committed value, trigger, target ошибки и return target.
  3. Проследите переход. Найдите в коде места, где меняются CSS, семантическое состояние, сохранённое значение, фокус и статус. Проверьте порядок операций.
  4. Проверьте отрицательную ветку. Передайте невалидный draft. Убедитесь, что committed value не изменился, панель не закрылась, а фокус получил исправляемый target.
  5. Проверьте успешную ветку. Сохраните новый выбор. Убедитесь, что старое значение сохранено для предусмотренной отмены, панель закрылась, а return target существует.
  6. Проверьте реальный интерфейс. Пройдите сценарий только клавиатурой. Затем проверьте DOM, доступное имя, роль, состояние, порядок фокуса и статус в согласованной комбинации браузера и assistive technology.
  7. Оставьте обратимый diff. Вносите одно изменение за раз. После каждого изменения повторяйте обе ветки. Если результат ухудшился, верните прежнюю разметку или state transition и сохраните причину отката.
\n

Что этот подход не доказывает

\n

Учебная модель не симулирует браузер. Поле focusTarget не равно вызову HTMLElement.focus(). Строка status не доказывает конкретную фразу, которую услышит пользователь. Unit-тест переходов не проверяет tab order соседних компонентов, видимый focus indicator, порталы, конфликт сочетаний клавиш или поведение нескольких вкладок.

\n

Undo для такой панели тоже ограничен. Он может хранить одно предыдущее значение, но не решает конфликт серверных изменений, offline, retry и прав доступа. Если сохранение идёт по сети, нужно отдельно решить, отменяется ли запрос или создаётся compensating change. Нельзя называть локальную отмену глобальным rollback.

\n

Native control часто даёт готовую семантику и клавиатурное поведение. Custom widget нужен только при ясной причине. Если его оставляют, keyboard contract, focus route и state mapping должны быть частью того же изменения. Атрибут role без соответствующего поведения не делает элемент доступным.

\n

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

\n

Изменение готово, когда одна и та же проверяемая последовательность проходит для двух веток. При ошибке сохранённое значение остаётся прежним, панель остаётся доступной для исправления, сообщение связано с control, а фокус имеет существующий target. При успехе выбранное значение становится сохранённым, старое значение доступно для предусмотренной отмены, панель закрывается предсказуемо, а фокус возвращается на заранее выбранный target или на явно обоснованный результат. В согласованной среде проверки DOM и keyboard path подтверждают этот контракт. Если проверена только модель или только визуальный экран, готовность ещё не доказана.

\n

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

" }