Files
progcode/editorial/agent-rewrites/207.json
T

8 lines
22 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>&lt;button id=&quot;notifications-trigger&quot; type=&quot;button&quot;&gt;\n Настроить уведомления\n&lt;/button&gt;\n\n&lt;div id=&quot;notifications-dialog&quot; role=&quot;dialog&quot; aria-modal=&quot;true&quot;\n aria-labelledby=&quot;notifications-title&quot; aria-describedby=&quot;notifications-help&quot;\n hidden&gt;\n &lt;h2 id=&quot;notifications-title&quot;&gt;Настроить уведомления&lt;/h2&gt;\n &lt;p id=&quot;notifications-help&quot;&gt;Выберите один канал для сообщений.&lt;/p&gt;\n &lt;form&gt;\n &lt;fieldset aria-describedby=&quot;channel-error&quot;&gt;\n &lt;legend&gt;Канал уведомлений&lt;/legend&gt;\n &lt;label&gt;\n &lt;input type=&quot;radio&quot; name=&quot;channel&quot; value=&quot;email&quot; checked&gt;\n Email\n &lt;/label&gt;\n &lt;label&gt;\n &lt;input type=&quot;radio&quot; name=&quot;channel&quot; value=&quot;sms&quot;&gt;\n SMS\n &lt;/label&gt;\n &lt;p id=&quot;channel-error&quot; role=&quot;alert&quot; hidden&gt;&lt;/p&gt;\n &lt;/fieldset&gt;\n &lt;p id=&quot;save-status&quot; role=&quot;status&quot; aria-live=&quot;polite&quot;&gt;&lt;/p&gt;\n &lt;button id=&quot;notifications-save&quot; type=&quot;submit&quot;&gt;Сохранить&lt;/button&gt;\n &lt;button type=&quot;button&quot;&gt;Отмена&lt;/button&gt;\n &lt;/form&gt;\n&lt;/div&gt;</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>"
}