{ "index": 205, "slug": "editorial-2022-04-field-accessible-interface", "title": "Доступный control после правки: как не потерять фокус, состояние и обратный путь", "excerpt": "Визуально исправный control может потерять смысл для клавиатуры и screen reader. Разбираем расхождение состояний на примере панели настроек и задаём проверяемый путь исправления.", "contentHtml": "
Кнопка открывает панель настроек. Пользователь выбирает SMS, нажимает «Сохранить», панель закрывается. Глаз видит галочку и новый текст. Но фокус исчезает, имя группы не читается, а повторное открытие показывает старое значение. Другой вариант хуже: ошибка подсвечивается, но уже изменила сохранённую настройку. Такой дефект легко пропустить при проверке мышью. Цена ошибки — потерянный путь навигации, неверное действие и повторная работа пользователя. Для критичного интерфейса это может остановить операцию целиком.
\nТезис простой: доступность control — это не набор атрибутов рядом с CSS. Это согласованный контракт состояния, имени, роли, фокуса и результата. Один источник состояния должен объяснять, что сейчас выбрано. Каждая ветка должна иметь понятный следующий фокус. Успех и ошибка должны менять разные данные. Если эти условия не записаны, визуальный патч легко создаёт недоступный интерфейс.
\nПанель выбора имеет как минимум два значения. draftChannel показывает выбор внутри открытой панели. savedChannel отражает настройку, которая уже принята продуктом. Пока пользователь не подтвердил выбор, эти значения могут различаться. Ошибка валидации не должна менять savedChannel. Успешное сохранение сначала запоминает старое значение, затем коммитит новое и только после этого закрывает панель.
У control есть и другие части контракта: имя и роль группы, состояние выбранной опции, trigger, target для ошибки, target для возврата после закрытия и сообщение о результате. Их не нужно хранить в шести независимых переменных. Они должны выводиться из одной модели переходов. Тогда проверка отвечает на конкретный вопрос: какое действие изменило состояние и куда попал фокус после этого действия.
\nconst 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После клика пользователь может увидеть новую галочку и всё равно получить старое состояние в accessibility tree. Так происходит, когда CSS меняет класс, а aria-checked получает значение из другой переменной. Фокус может исчезнуть не из-за screen reader, а потому, что компонент удалили до того, как выбрали return target. Сообщение может быть видно, но не быть доступным для assistive technology без перевода фокуса. Поэтому диагностика должна начинаться с последовательности переходов, а не с перебора атрибутов.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После закрытия фокус неясен | Панель удаляется до выбора return target | Записать активный элемент до и после close | Выбрать существующий target до unmount и проверить его клавиатурой |
| Галочка видна, но состояние старое | CSS и семантика используют разные источники | Сравнить visual value и declared value после одного выбора | Вывести оба значения из одного state owner |
| Ошибка есть только для мыши | Сообщение не связано с полем и фокусом | Проверить имя, описание, target ошибки и порядок перехода | Связать ошибку с control и направить фокус на исправление |
| Старое значение изменилось при ошибке | Draft и committed state склеены | Выполнить invalid branch и сравнить saved value | Остановить commit до успешной проверки |
| Статус виден, но результат непредсказуем | Текст создаётся отдельно от result state | Проверить семантику status message и порядок обновления | Сформировать статус в контракте, затем проверить platform output |
Рассмотрим панель «Канал уведомлений». Trigger имеет имя «Настроить уведомления» и сообщает, открыта ли панель. Группа выбора получает имя «Канал уведомлений». В ней есть две radio options: Email и SMS. Кнопка «Сохранить» подтверждает draft. При успехе панель закрывается, focus возвращается на trigger, а статус сообщает о принятом действии. Если панель модальная, при её открытии фокус должен оказаться внутри, а после закрытия обычно вернуться на элемент, который её вызвал; для другого return target нужна причина из сценария. Если результат не меняет контекст, status message должен быть программно определимым, но его конкретную озвучку всё равно проверяют в выбранной связке браузера и assistive technology.
\nОтрицательная ветка начинается с невозможного или неподдерживаемого значения. Панель остаётся открытой. savedChannel не меняется. Сообщение об ошибке связано с местом исправления. Фокус получает понятный target. Пользователь может изменить draft и повторить действие. Если обработчик сначала записывает значение, а потом проверяет его, последующее сообщение уже не исправит неверное состояние.
Учебная модель не симулирует браузер. Поле focusTarget не равно вызову HTMLElement.focus(). Строка status не доказывает конкретную фразу, которую услышит пользователь. Unit-тест переходов не проверяет tab order соседних компонентов, видимый focus indicator, порталы, конфликт сочетаний клавиш или поведение нескольких вкладок.
Undo для такой панели тоже ограничен. Он может хранить одно предыдущее значение, но не решает конфликт серверных изменений, offline, retry и прав доступа. Если сохранение идёт по сети, нужно отдельно решить, отменяется ли запрос или создаётся compensating change. Нельзя называть локальную отмену глобальным rollback.
\nNative control часто даёт готовую семантику и клавиатурное поведение. Custom widget нужен только при ясной причине. Если его оставляют, keyboard contract, focus route и state mapping должны быть частью того же изменения. Атрибут role без соответствующего поведения не делает элемент доступным.
Изменение готово, когда одна и та же проверяемая последовательность проходит для двух веток. При ошибке сохранённое значение остаётся прежним, панель остаётся доступной для исправления, сообщение связано с control, а фокус имеет существующий target. При успехе выбранное значение становится сохранённым, старое значение доступно для предусмотренной отмены, панель закрывается предсказуемо, а фокус возвращается на заранее выбранный target или на явно обоснованный результат. В согласованной среде проверки DOM и keyboard path подтверждают этот контракт. Если проверена только модель или только визуальный экран, готовность ещё не доказана.
\n