8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 207,
|
||
"slug": "editorial-2022-04-practice-accessible-interface",
|
||
"title": "Доступный интерфейс начинается с маршрута фокуса",
|
||
"excerpt": "Как связать имя, роль, состояние и фокус в одном интерактивном сценарии и проверить ошибку до того, как пользователь потеряет управление формой.",
|
||
"contentHtml": "<p>Панель настроек открывается по клику, но после нажатия Tab фокус уходит в фон страницы. Клавиатурный пользователь не понимает, что панель появилась, а после сохранения фокус исчезает вместе с удалённым узлом. Другая ошибка выглядит тише: канал отмечен цветом и галочкой, но его имя или выбранное состояние не попадает в accessibility tree. Цена одинакова — потерянное действие, повторный ввод и неясный результат сохранения.</p><p>Доступность такого сценария проверяется не отдельным атрибутом, а маршрутом состояния. На каждом шаге должны совпасть четыре наблюдаемых свойства: имя и роль control, его значение, допустимое клавиатурное действие и следующий focus target. Если один слой живёт отдельно, интерфейс может выглядеть исправным и ломаться уже при первом действии без мыши.</p><h2>Сначала зафиксируйте контракт действия</h2><p>Начните с конкретного сценария: кнопка <code>notifications-trigger</code> открывает модальную панель «Настроить уведомления», внутри выбирается Email или SMS, кнопка «Сохранить» отправляет изменение. У сценария есть два значения. <code>channel</code> — черновой выбор в открытой панели. <code>savedChannel</code> — последнее подтверждённое значение. Ошибка валидации и отмена не имеют права изменить второе.</p><p>Контракт также описывает фокус. При открытии он переходит внутрь панели. При ошибке остаётся на исправляемой группе или возвращается к ней. При успешном закрытии возвращается на вызывающую кнопку, если она всё ещё существует и это соответствует потоку работы. Это не универсальная формула: для большой панели начальным target может быть заголовок, чтобы сначала прочитать структуру. Решение нужно записать до реализации и проверить на фактическом DOM.</p><p>Роль сама по себе не создаёт поведение. <code>role='button'</code> не добавляет обработчики Enter и Space, видимый focus indicator или возврат фокуса после удаления узла. Если нативный <code>button</code> решает задачу, он оставляет браузеру часть контракта. Custom control оправдан только тогда, когда вместе с ним описаны семантика, клавиши, переходы состояния и тест.</p><h2>Один сценарий: модальная панель уведомлений</h2><p>В этом примере панель действительно модальная: пока она открыта, пользователь не взаимодействует с содержимым за её пределами. Это важное условие, а не декоративное значение <code>aria-modal</code>. Для немодального раскрывающегося блока нужен другой маршрут: не удерживайте фокус внутри него и не заявляйте технологиям, что фон недоступен.</p><p>При открытии выберем первый radio, потому что панель короткая и пользователь сразу должен выбрать канал. В другом диалоге уместнее сначала сфокусировать заголовок с <code>tabindex='-1'</code>, если длинный текст иначе прокрутится за пределы окна. После ошибки показываем сообщение, связываем его с группой и не трогаем <code>savedChannel</code>. После успешного ответа закрываем панель и возвращаем фокус на <code>notifications-trigger</code>.</p><div class='table-scroll'><table><caption>Диагностика маршрута: симптом, причина, проверка и действие</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>После открытия Tab уходит в фон</td><td>Панель не получила начальный focus target или modal-граница не работает</td><td>Открыть клавиатурой и записать фактический <code>activeElement</code> после каждого Tab</td><td>Выбрать вход внутрь панели; для modal реализовать полный циклический маршрут</td></tr><tr><td>Выбранный канал виден, но читается старый</td><td>CSS-класс и semantic state вычисляются из разных копий</td><td>Сравнить визуальный selected, native <code>checked</code> и доступное состояние</td><td>Выводить представления из одного владельца <code>channel</code></td></tr><tr><td>Ошибка видна рядом, но не связана с группой</td><td>У сообщения нет связи с control или оно передаётся только цветом</td><td>Проверить <code>fieldset</code>, <code>legend</code>, <code>aria-describedby</code> и текст ошибки</td><td>Связать ошибку с группой, сообщить invalid state и вернуть фокус к исправлению</td></tr><tr><td>После сохранения фокус исчезает</td><td>Сфокусированный узел удалили вместе с панелью</td><td>Сохранить вызывающий узел до открытия и проверить <code>document.activeElement</code> после закрытия</td><td>Вернуть фокус на существующий trigger или явно обоснованную следующую точку</td></tr><tr><td>Статус есть в DOM, но результат не объявляется</td><td>Текст обновлён не в момент результата или region не имеет подходящей семантики</td><td>Проверить изменение status после ответа и его доступность в целевой связке</td><td>Обновлять короткое сообщение после успеха или ошибки и отдельно проверить assistive technology</td></tr></tbody></table></div><h2>Разметка должна показывать тот же контракт</h2><p>Нативная форма уже даёт подписи, группировку radio и клавиатурное поведение. Ниже показаны также границы диалога и связи сообщений. Атрибут <code>hidden</code> у ошибки снимается только при ошибке; пустой контейнер не является доказательством, что пользователь её услышит. У каждого control остаётся видимый текстовый label, а у диалога — имя через заголовок.</p><pre><code><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></code></pre><p>Это минимальная семантическая схема, а не готовый dialog manager. JavaScript должен открыть панель, установить начальный фокус, удерживать его внутри modal, обработать Escape и вернуть фокус после закрытия. При удалённом или перемещённом trigger нужен другой target. Не ставьте <code>aria-modal='true'</code> на блок, за пределами которого реально можно кликать и перемещать фокус: такое расхождение скрывает фон от некоторых assistive technology и ухудшает навигацию.</p><h2>Разделите черновик, commit и сообщение</h2><p>Самая частая логическая ошибка — записать выбор в сохранённое состояние в обработчике radio. Тогда пользователь ещё не нажал «Сохранить», а отмена уже изменила данные. Разведите операции: выбор меняет только <code>draft</code>, проверка решает, допустим ли переход, сохранение выполняет commit, а ошибка меняет сообщение и focus target.</p><p>Следующий фрагмент — исполняемая модель без DOM. Сохраните его в файл <code>focus-contract.mjs</code> и запустите командой <code>node focus-contract.mjs</code>. Он не измеряет accessibility tree и не заменяет браузерный проход. Его польза в другом: отрицательный путь становится проверяемым до подключения UI и сети.</p><pre><code>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');</code></pre><p>В модели <code>carrier-pigeon</code> — учебное значение вне allow-list. Оно не имитирует серверный ответ и не доказывает поведение screen reader. Проверка доказывает только инварианты данных: неверный выбор не коммитится, допустимый выбор становится черновиком, сохранение делает один commit, а отмена возвращает прежний результат. Для реального интерфейса те же инварианты нужно связать с DOM и сетевой ошибкой.</p><figure><img src='/assets/editorial/2022/accessible-interface-focus-route-2022.svg' alt='Маршрут фокуса модальной панели уведомлений: trigger ведёт внутрь к Email, ошибка возвращает к radio, сохранение возвращает фокус на trigger.' loading='lazy' /><figcaption>Схема показывает ожидаемый маршрут данных и фокуса. Она не является снимком accessibility tree и не доказывает, какую фразу произнесёт конкретная программа чтения с экрана.</figcaption></figure><h2>Не смешивайте native radio и ARIA-паттерн</h2><p>У нативной группы radio браузер уже связывает <code>fieldset</code> и <code>legend</code>, а выбранный control получает значение <code>checked</code>. Для custom-группы нужно реализовать другой контракт: контейнер с именем и ролью <code>radiogroup</code>, элементы с ролью <code>radio</code>, согласованный <code>aria-checked</code> и клавиатурные переходы. Нельзя добавить роль к обычному div и считать работу законченной.</p><p>Для radio-группы вне toolbar Tab входит в группу и выходит из неё, а стрелки перемещают фокус и выбирают вариант по правилам выбранного паттерна. Для native HTML детали перехода между браузерами стоит проверять отдельно. Если группе нужен toolbar, его клавиатурные правила меняются: стрелка может перемещать фокус, не меняя выбранное значение. Поэтому сначала выберите паттерн, затем реализуйте его целиком и внесите его в тестовый сценарий.</p><h2>Порядок проверки до выпуска</h2><ol><li>Запишите симптом, цену ошибки и нормальный результат: например, после открытия панели пользователь теряет точку фокуса и не может подтвердить канал.</li><li>Назовите trigger, диалог, заголовок, группу, варианты, кнопку сохранения, отмену и status region. Для каждого укажите доступное имя, роль и источник значения.</li><li>Разделите <code>draft</code> и <code>saved</code>. Зафиксируйте, на каком событии происходит commit и какие ветки его запрещают.</li><li>Проверьте разметку: видимый label, <code>legend</code>, имя dialog, связь ошибки через <code>aria-describedby</code>, видимый focus indicator и корректный тип кнопок.</li><li>Пройдите нормальный путь только клавиатурой: открыть, войти в группу, выбрать, сохранить, увидеть status и вернуться на trigger. Запишите фактический <code>activeElement</code>.</li><li>Пройдите отрицательные ветки: неизвестное значение, ошибка сервера, отмена без сохранения, Escape и исчезнувший trigger. Зафиксируйте сохранённое значение, сообщение и target для каждой ветки.</li><li>Сопоставьте visual state, DOM properties и accessibility tree после выбора и после ошибки. Не заменяйте наблюдение в браузере результатом unit-теста модели.</li><li>Проверьте одну согласованную связку браузера и assistive technology, запишите версии и фактический результат объявления. Автоматический аудит помогает найти нарушения, но не подтверждает удобство сценария.</li><li>Повторите маршрут после исправления и сохраните регрессионный тест для стабильных переходов состояния.</li></ol><h2>Ограничения и критерий готовности</h2><p>Контракт панели не описывает весь продукт. Он не проверяет контраст, масштабирование, порядок заголовков, touch target, локализацию, виртуальный курсор, тайм-ауты, конфликт горячих клавиш и работу при нестабильной сети. Он также не заменяет тестирование людьми, которые используют разные способы ввода и разные assistive technology.</p><p>Нативный элемент уменьшает объём собственной логики, но не отменяет проверку: CSS может скрыть focus indicator, сервер может вернуть устаревшую ошибку, а асинхронный ответ может прийти после закрытия панели. Если сохранение идёт по сети, заранее решите, остаётся ли черновик для retry и куда возвращается фокус. Локальный commit в модели не является подтверждением серверной записи.</p><p>Сценарий готов к следующему этапу, когда команда может воспроизвести один маршрут: открыть panel, назвать control, выбрать значение, получить ошибку без изменения saved state, исправить её, успешно сохранить и вернуть фокус на объявленный target. Для каждого перехода есть наблюдаемое свойство DOM или state, а для status есть проверка выбранной связки. Фраза «должно работать» без шага и результата остаётся незакрытой частью контракта.</p><h2>Проверяемые источники</h2><ul><li><a href='https://www.w3.org/TR/2018/REC-WCAG21-20180605/' target='_blank' rel='noopener noreferrer'>W3C, Web Content Accessibility Guidelines (WCAG) 2.1, Recommendation от 5 июня 2018 года</a> — стабильная редакция 2018 года для клавиатуры, фокуса, имени, роли, значения и status messages.</li><li><a href='https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/' target='_blank' rel='noopener noreferrer'>W3C WAI-ARIA Authoring Practices, Dialog (Modal) Pattern</a> — начальный фокус, циклический Tab, Escape, имя диалога и возврат фокуса; modal следует объявлять только при реальной инертности фона.</li><li><a href='https://www.w3.org/WAI/ARIA/apg/patterns/radio/' target='_blank' rel='noopener noreferrer'>W3C WAI-ARIA Authoring Practices, Radio Group Pattern</a> — различие keyboard contract для radio-группы вне toolbar и внутри toolbar, роли, имена и <code>aria-checked</code>.</li></ul>"
|
||
}
|