Многие не пользуются мышью: одни работают с клавиатуры, другие - через переключатель или диктор, третьи временно, со сломанной рукой. Для них фокус - это курсор: невидимая точка, куда браузер шлёт нажатия, ведь Enter или пробел получит сфокусированный элемент. На странице товара - выбор размера, кнопка в корзину, галерея, отзывы. Узел, недостижимый с клавиатуры, для части людей не существует.
Наивный вывод разумен: раз браузер сам обходит по Tab, достаточно расставить узлы. Верно, но лишь пока разметка семантична. Tab обходит только фокусируемые по спецификации: ссылки с href, кнопки, поля ввода, select. Нативная кнопка приносит фокусируемость, реакцию на Enter и пробел, роль для диктора - всё бесплатно.
Ломается это на самодельных виджетах. Дизайнер просит красивую кнопку - разработчик вешает onclick на div. Визуально не отличить, но div вне порядка обхода, глух к клавиатуре и без роли. Мышь добавит товар в корзину, клавиатура - нет. То же с выпадающим списком на span и галереей на голых картинках: мышь работает, клавиатура молчит.
Второй слой проблемы - видимость: достижимый элемент бесполезен, если непонятно, где фокус. Браузер рисует индикатор активного элемента, но дизайнеры часто убирают его через outline: none ради эстетики. Правильный инструмент - :focus-visible: рамка видна при навигации с клавиатуры и скрыта при клике мышью - конфликт снят. WCAG 2.2 требует, чтобы индикатор был виден (2.4.7) и не перекрывался липкими шапками (2.4.11).
Порядок обхода должен совпадать с визуальным и логическим порядком чтения (2.4.3): прыжки фокуса дезориентируют. Управлять им лучше порядком в DOM, а не положительными tabindex, ломающими последовательность. Отдельная боль - меню и шапка перед контентом: каждый раз проходить их утомительно. Решение - ссылка пропуска, спрятанная до фокуса и ведущая к содержимому.
В классическом сайте переход по ссылке перезагружает страницу: браузер сам сбрасывает фокус и объявляет новый заголовок. В одностраничном приложении, на котором построен магазин, перехода нет - меняется лишь часть DOM, и браузер молчит. После смены маршрута нужно вручную обновить title, объявить новую страницу и перевести фокус к началу содержания - к h1 карточки, а не на исчезнувшую ссылку.
Отдельный жанр - модальное окно: выбор размера открывается поверх страницы. По паттерну WAI-ARIA APG фокус должен войти внутрь, не покидать окно, пока открыто, закрываться по Escape и вернуться на инициатор. Это и есть ловушка фокуса - в диалоге обязательная, в остальных случаях запрещённая (2.1.2). Окну ставят role dialog, aria-modal true и связывают с заголовком через aria-labelledby.
Плата - дисциплина. Ручное управление фокусом легко забыть при рефакторинге, а автотесты редко ловят потерянный фокус. Соблазн - навесить tabindex и aria на всё подряд, но лишние фокусные точки и роли вредят не меньше их отсутствия: диктор читает мусор. Дешевле держаться нативных элементов, а ручное управление добавлять лишь где не обойтись - в диалогах и при клиентской навигации.
Типичный провал: пользователь открывает окно выбора размера, Tab уводит фокус за спину диалога - на кнопки под ним. Визуально окно поверх, но фокус блуждает по невидимому, Enter срабатывает не там, Escape не закрывает. Человек с клавиатуры заперт в интерфейсе, безупречном для мыши. Поэтому фокус проверяют так же строго, как вёрстку: пройти весь путь покупки одной клавиатурой.
:focus-visible {
outline: 3px solid CanvasText;
outline-offset: 3px;
}
@media (forced-colors: active) {
.button { border: 1px solid ButtonText; }
}<a class="skip-link" href="#main">
Перейти к содержимому
</a>const dialog = document.querySelector('#size-dialog');
const trigger = document.querySelector('#choose-size');
trigger.addEventListener('click', () => {
dialog.showModal(); // нативная ловушка фокуса и закрытие по Escape
});
dialog.addEventListener('close', () => {
trigger.focus(); // вернуть фокус инициатору
});