Клавиатура и фокус: порядок обхода, ловушки фокуса, skip-links
Клавиатура — тот слой доступности, который либо есть, либо нет: промежуточных состояний почти не бывает. Если до кнопки нельзя добраться табом, её не существует для человека, который не пользуется мышью. Если после закрытия модального окна фокус улетел в начало документа, человек теряет место в интерфейсе и начинает путь заново. Никакие ARIA-атрибуты и подписи не спасут интерфейс, по которому нельзя пройти.
Хорошая новость: клавиатурная доступность — самая проверяемая часть a11y. Её нельзя «оценить на глаз», зато можно за пять минут пройти страницу табом и увидеть все проблемы своими глазами, без скринридера и специального оборудования. Плохая новость — именно эту часть ломают чаще всего, потому что мышью разработчик пользуется каждый день, а клавиатурой почти никогда.
Если вы ещё не читали вводную, начните с Гайда по Accessibility и карты трека. Здесь предполагается, что вы уже понимаете, почему семантика важнее ARIA и что ARIA ничего не добавляет к поведению — она меняет только представление элемента в дереве доступности.
Кто ходит по интерфейсу клавиатурой
Стереотип «клавиатурная навигация = незрячие пользователи» неверен и мешает проектировать. Реальная аудитория шире.
- Люди с нарушениями моторики. Тремор, спастика, ампутация, артрит, последствия травмы. Мышь требует точного позиционирования на площади в несколько пикселей, клавиатура — нет. Часть людей пользуется не обычной клавиатурой, а эргономичной, головной указкой, ротовым стилусом или switch-устройством — одной-двумя большими кнопками, эмулирующими Tab и Enter. Для switch-навигации каждый лишний табстоп — дополнительное физическое усилие.
- Люди с нарушениями зрения, которые пользуются скринридером: у него есть собственный режим чтения, но интерактивные элементы он обходит тем же механизмом фокуса. Подробности — в статье про скринридеры.
- Пользователи голосового управления (Dragon, Voice Control). Команда «click Отправить» ищет доступное имя элемента и передаёт ему фокус: если элемент недоступен с клавиатуры, голосом до него тоже не добраться.
- Люди с временными и ситуативными ограничениями (рука в гипсе, ребёнок на руках, сломанный тачпад в поезде) и все, кто работает быстро: заполнение формы табом быстрее, чем мышью, поэтому клавиатурная доступность окупается в метриках у бухгалтеров, операторов и саппорта — тех, кто проводит в интерфейсе восемь часов подряд.
Практический вывод: клавиатурная навигация — не «режим для отдельной категории», а базовый способ управления, который у HTML был с самого начала. Задача разработчика — не «добавить поддержку клавиатуры», а не отобрать её.
Модель фокуса: одна точка ввода на весь документ
В документе в каждый момент времени есть ровно один сфокусированный элемент — точка, куда браузер направляет события клавиатуры. Получить её можно через document.activeElement, который всегда что-то возвращает. Ключевые факты о модели:
- Если ничего не сфокусировано,
activeElement— это<body>. Это состояние «фокус потерян»: следующее нажатие Tab начнёт обход с начала документа. - Фокус — свойство документа, а не окна. Внутри
<iframe>своя точка ввода; таб-цепочка родительского документа входит в iframe и выходит обратно. А в Shadow DOMdocument.activeElementвернёт хост-элемент, а не реальный внутренний, — чтобы добраться до истины, надо спускаться поshadowRoot.activeElement. - События фокуса:
focusin/focusoutвсплывают,focus/blur— нет, поэтому для делегирования на контейнере используйтеfocusin. Порядок при переходе A → B:blur(A) →focusout(A) →focus(B) →focusin(B). МеждуfocusoutиfocusactiveElementкратковременно равенbody— из-за этого наивные проверки «фокус ушёл из моего компонента» ложно срабатывают. Лечится черезrelatedTargetуfocusoutлибо проверкой вsetTimeout(…, 0).
скринридер молчит, фокус «в пустоту»
Отсюда два правила, объясняющих половину багов: фокус — единственный надёжный способ сказать скринридеру «смотри сюда», и фокус на элементе без доступного имени звучит как «кнопка» без пояснения.
Что фокусируется по умолчанию
| Элемент | В таб-цепочке | Комментарий |
|---|---|---|
<a href="..."> |
да | без href — обычный текст, не фокусируется |
<button> |
да | кроме disabled |
<input>, <select>, <textarea> |
да | кроме disabled и type="hidden" |
<summary> в <details>, <iframe>, <object>, <embed> |
да | summary активируется Enter и Space; в iframe своя цепочка |
<audio controls>, <video controls>, [contenteditable] |
да | нативные контролы плеера обходятся стрелками |
<div>, <span>, <li>, <svg> |
нет | пока не добавите tabindex |
Плюс правила-исключения: элементы внутри display: none, visibility: hidden, hidden, свёрнутого <details>, закрытого <dialog>, поддерева с атрибутом inert или content-visibility: hidden из цепочки выпадают. А вот opacity: 0, clip-path, нулевой размер и transform: translateX(-9999px) фокусируемость не отключают — отсюда классический баг «Tab уходит в невидимое выпадающее меню».
Отдельная группа — радиокнопки. Группа <input type="radio" name="x"> — это один табстоп: Tab приводит к выбранной кнопке (или к первой, если ничего не выбрано), а стрелки перемещают фокус и одновременно меняют выбор. Это нативный пример roving tabindex.
tabindex: три значения и одна ошибка
<div role="button" tabindex="0">Своя кнопка</div> <!-- 0: встаёт в таб-цепочку в порядке DOM -->
<h1 id="page-title" tabindex="-1">Заголовок</h1> <!-- -1: только через .focus(), Tab не находит -->
<input tabindex="3"> <!-- положительный: почти всегда ошибка -->
Браузер обходит цепочку в два прохода: сначала все элементы с положительным tabindex по возрастанию значения (при равенстве — в порядке DOM), и только потом всё с tabindex="0" и нативно фокусируемое в порядке DOM. Достаточно одного tabindex="1" в шапке, чтобы порядок всей страницы перестал совпадать с визуальным. Хуже того, положительный tabindex внутри компонента библиотеки «отравляет» любую страницу, куда его вставят: значения пришлось бы координировать глобально, чего в реальном проекте не бывает. Правило: в продакшн-коде допустимы только 0 и -1, линтер должен падать на всём остальном.
Ещё тонкость: tabindex="0" на <div> делает элемент фокусируемым, но не делает его кнопкой. Enter и Space придётся обрабатывать руками, роль и состояние объявлять через ARIA, disabled эмулировать через aria-disabled. Отсюда правило из статьи об ARIA: если поведение уже есть у нативного элемента — берите нативный.
Порядок обхода: DOM решает, CSS обманывает
Последовательность табстопов вычисляется по дереву документа, а не по картинке на экране. CSS умеет переставлять пиксели (order, flex-direction: row-reverse, grid-area, grid-auto-flow: dense, position: absolute, float), но ленту фокуса он не трогает. Как только визуальный порядок расходится с DOM-порядком, пользователь клавиатуры получает телепортацию: фокус прыгает по экрану непредсказуемо.
Это описано в WCAG 2.4.3 Focus Order (уровень A): последовательность фокуса должна сохранять смысл и работоспособность (понимание критерия). Формально критерий говорит о «смысле», а не о совпадении с визуальным порядком, но безопасное практическое правило одно:
Порядок элементов в DOM = порядок, в котором их читает человек глазами. Всё остальное — исключение, которое нужно защищать на ревью.
Типичные места, где порядок ломается: раскладка через order (на десктопе сайдбар справа, на мобильном снизу, а в DOM он первый — на узком экране фокус из шапки прыгает в футер-сайдбар и только потом в контент); модалка, отрендеренная в конец <body> через портал (визуально поверх страницы, в DOM в конце — без управления фокусом Tab уводит на страницу под ней); абсолютно спозиционированный выпадающий список, лежащий в DOM далеко от своего триггера; sticky-панель действий, прибитая внизу экрана, но описанная в начале формы.
Проверка на расхождение делается за минуту: медленно нажимайте Tab и смотрите, движется ли кольцо фокуса примерно так же, как двигался бы взгляд при чтении, — каждый прыжок назад или вбок кандидат в баги.
Видимый фокус
Фокус, который не видно, эквивалентен отсутствию фокуса. WCAG 2.4.7 Focus Visible (AA) требует видимый индикатор режима фокуса. Историческая причина, по которой пишут outline: none, — браузеры показывали кольцо и при клике мышью. Эта проблема решена псевдоклассом :focus-visible: он включает индикатор только тогда, когда браузер по эвристике считает, что пользователь работает с клавиатуры.
:focus:not(:focus-visible) { outline: none; } /* мышь — кольца нет */
:focus-visible {
outline: 3px solid #1d6ef5;
outline-offset: 2px; /* кольцо не липнет к границе и не съедается overflow */
border-radius: inherit; /* повторяет форму элемента */
}
.card:has(:focus-visible) { box-shadow: 0 0 0 2px #1d6ef5; } /* подсветка контейнера */
Чего делать нельзя: *:focus { outline: none } без замены (самый распространённый способ сломать доступность одной строкой); индикатор только цветом фона при низком контрасте; кольцо тоньше 2 CSS-пикселей; outline внутри родителя с overflow: hidden — оно обрежется, там нужен box-shadow.
WCAG 2.4.13 Focus Appearance (AAA) формализует «достаточность»: площадь индикатора не меньше периметра компонента толщиной 2 CSS-пикселя, контраст между сфокусированным и несфокусированным состоянием этой площади не ниже 3:1. Даже если вы не идёте на AAA, это хороший ориентир для дизайн-системы. Про контрасты — в статье о визуальной доступности.
Фокус, который уехал под шапку
WCAG 2.2 добавил 2.4.11 Focus Not Obscured (Minimum), уровень AA: элемент, получивший фокус, не должен быть полностью скрыт контентом, добавленным автором. Уровень AAA (2.4.12) требует, чтобы не была скрыта даже часть. Нарушение выглядит буднично: липкая шапка высотой 72 пикселя, браузер прокручивает страницу так, чтобы сфокусированный элемент оказался у верхнего края вьюпорта, и элемент оказывается ровно под шапкой.
:root {
--sticky-header: 4.5rem;
scroll-padding-block-start: var(--sticky-header);
scroll-padding-block-end: 3rem; /* липкий футер или панель cookie */
}
[id] { scroll-margin-block-start: var(--sticky-header); } /* точечно для целей перехода */
scroll-padding на скролл-контейнере (обычно :root) — самый надёжный способ: он влияет и на нативную прокрутку к фокусу, и на scrollIntoView, и на переходы по якорям. Дополнительно: любая закрывающаяся панель (cookie, промо, чат) обязана закрываться с клавиатуры и не перехватывать фокус, пока её не открыли.
Клавиатурные соглашения по типам компонентов
Клавиатура — конвенция, а не творчество. Пользователь приходит с ожиданиями, сформированными операционной системой: Enter активирует, Space нажимает кнопку и прокручивает страницу, Esc отменяет, стрелки двигают внутри группы. Ломать эти ожидания — то же самое, что менять местами «Сохранить» и «Удалить».
Разница между Enter и Space неочевидна, но важна: у нативной ссылки работает только Enter, у нативной кнопки — и Enter, и Space. Делаете role="button" на <div> — обрабатывайте обе клавиши; делаете role="link" — только Enter. Полные таблицы соглашений по каждому паттерну есть в WAI-ARIA Authoring Practices и разделе Developing a Keyboard Interface.
Один табстоп на композитный виджет
Идею пропускают чаще всего: набор однотипных элементов должен быть одним табстопом. Список из 40 табов, тулбар из 15 кнопок, дерево из 200 узлов — если каждому дать tabindex="0", пользователю switch-устройства придётся сделать 200 нажатий, чтобы просто пройти мимо компонента. Вместо этого Tab заводит фокус в компонент и выводит из него, а внутри работают стрелки.
с таким поведением?"} B -- "да" --> C["button, ссылка с href, input, select, details:
фокус и клавиатура бесплатно"] B -- "нет" --> D{"Один самостоятельный контрол
или группа однотипных?"} D -- "один контрол" --> E["tabindex=0 + role + Enter и Space
+ aria-disabled вместо disabled"] D -- "группа" --> F{"Группой управляет
отдельное поле ввода?"} F -- "нет: табы, тулбар, меню, дерево" --> G["Roving tabindex: ровно один элемент
с tabindex=0, остальные -1,
стрелки двигают фокус"] F -- "да: комбобокс, автодополнение" --> H["aria-activedescendant: фокус остаётся
в поле, активная опция помечается по id"] C --> Z["Проверить Tab, Shift+Tab, Enter, Space, Esc,
стрелки, Home, End и сверить с паттерном APG"] E --> Z G --> Z H --> Z
Roving tabindex — рабочая реализация
В группе ровно один элемент имеет tabindex="0", остальные -1. Стрелки переносят и tabindex="0", и фактический фокус; уйдя и вернувшись табом, пользователь попадает туда, где был.
/** Roving tabindex для табов, тулбара, кастомной радиогруппы, списка опций. */
export function rovingTabindex(root: HTMLElement, sel: string, vertical = false) {
const items = () => Array.from(root.querySelectorAll<HTMLElement>(sel))
.filter((el) => el.offsetParent !== null && el.getAttribute("aria-hidden") !== "true");
const setStop = (list: HTMLElement[], i: number) => {
const next = list[(i + list.length) % list.length]; // закольцовываем
list.forEach((el) => el.setAttribute("tabindex", el === next ? "0" : "-1"));
next.focus({ preventScroll: true }); // не дёргаем страницу
next.scrollIntoView({ block: "nearest", inline: "nearest" }); // прокручиваем сами
};
// старт: единственный табстоп — активный элемент или первый
const start = items();
const active = Math.max(0, start.findIndex((el) => el.dataset.active === "true"));
start.forEach((el, i) => el.setAttribute("tabindex", i === active ? "0" : "-1"));
const onKeydown = (e: KeyboardEvent) => {
const list = items();
const cur = list.indexOf(document.activeElement as HTMLElement);
if (cur === -1) return;
switch (e.key) {
case vertical ? "ArrowDown" : "ArrowRight": setStop(list, cur + 1); break;
case vertical ? "ArrowUp" : "ArrowLeft": setStop(list, cur - 1); break;
case "Home": setStop(list, 0); break;
case "End": setStop(list, list.length - 1); break;
default: return; // чужие клавиши не трогаем и не глушим
}
e.preventDefault(); // preventDefault только для обработанных клавиш
};
root.addEventListener("keydown", onKeydown);
return () => root.removeEventListener("keydown", onKeydown);
}
Детали, которые обычно забывают. Глобальный preventDefault на keydown отбирает у пользователя прокрутку стрелками и системные сочетания — гасите только то, что реально обработали. Закольцовывание принято для табов и меню, а для деревьев и списков APG чаще предписывает остановку на краю — сверяйтесь с конкретным паттерном. focus({ preventScroll: true }) вместе с ручным scrollIntoView({ block: "nearest" }) избавляет от рывков прокрутки. Логически выключенный элемент лучше помечать aria-disabled="true" и оставлять фокусируемым: с disabled пользователь просто не узнает, что кнопка существует, — а обработчик должен игнорировать активацию.
В комбобоксе и автодополнении фокус физически остаётся в <input> (иначе не работает ввод), но «активной» считается опция в списке: DOM-фокус не двигают, а ставят aria-activedescendant="option-3" на поле. Разбор — в статье о доступных компонентах.
Ловушки фокуса: полезная и вредная
Одно и то же явление — «фокус не может покинуть область» — бывает нужным и катастрофическим.
Вредная ловушка нарушает WCAG 2.1.2 No Keyboard Trap (уровень A): пользователь вошёл в компонент и не может выйти стандартными клавишами. Источники: встроенный редактор кода или таблица, где Tab вставляет табуляцию (лечится клавишей выхода — так Monaco переключает захват Tab по Ctrl+M, о чём нужно сообщить текстом или через aria-describedby); виджеты третьих сторон в <iframe> — плееры, карты, чаты, реклама (проверяйте клавиатурой до подключения, вы отвечаете за страницу целиком); самодельный trap, включённый при открытии выпадашки и не выключенный при закрытии; обработчик, который на focusout безусловно возвращает фокус назад.
Полезная ловушка — модальный диалог: пока он открыт, остальная страница не должна быть доступна ни мышью, ни клавиатурой, ни скринридером. Это не прихоть — если фокус уходит на страницу под модалкой, незрячий пользователь читает контент, который визуально закрыт затемнением, и не понимает, почему клики не работают.
Правильный путь: <dialog> и inert
<button id="open" onclick="dlg.showModal()">Изменить адрес</button> <!-- именно showModal, не show -->
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">Изменение адреса</h2>
<form method="dialog"> <!-- закрывает диалог и отдаёт returnValue -->
<label for="city">Город</label>
<input id="city" name="city" autofocus> <!-- срабатывает при каждом открытии -->
<button value="cancel">Отмена</button>
<button value="save">Сохранить</button>
</form>
</dialog>
Что showModal() даёт бесплатно: диалог переносится в top layer и оказывается поверх всего независимо от z-index и overflow; остальное содержимое становится инертным — недоступно для мыши, Tab и скринридера; Esc закрывает окно (событие cancel); фокус ставится внутрь по autofocus или на первый пригодный элемент и возвращается на элемент, сфокусированный до открытия; доступен ::backdrop. Немодальный dialog.show() не делает ничего из этого — вам почти всегда нужен showModal().
Если диалог рисуется руками (анимации, вложенные слои, требования дизайн-системы), изолируйте фон атрибутом inert:
const backdrop = () => Array.from(document.body.children).filter((el) => el.id !== "modal-root");
export function openModal(modal: HTMLElement) {
const trigger = document.activeElement as HTMLElement | null; // запоминаем, куда вернуться
backdrop().forEach((el) => el.setAttribute("inert", ""));
modal.hidden = false;
const first = modal.querySelector<HTMLElement>("[autofocus]")
?? modal.querySelector<HTMLElement>("button, [href], input, select, textarea");
(first ?? modal).focus({ preventScroll: true }); // у контейнера — tabindex="-1"
return function close() {
modal.hidden = true;
backdrop().forEach((el) => el.removeAttribute("inert"));
if (trigger?.isConnected) trigger.focus();
else document.querySelector<HTMLElement>("main h1")?.focus(); // триггер исчез — не бросаем на body
};
}
inert делает ровно то, что нужно: убирает поддерево из таб-цепочки, блокирует клики и скрывает содержимое от вспомогательных технологий. Он заменяет старую связку «aria-hidden="true" на фон + ручной перебор tabindex», которая регулярно рассыпалась (в частности, aria-hidden на предке сфокусированного элемента — ошибка, её ловит axe).
Ручная ловушка, если inert недоступен
const FOCUSABLE = [
"a[href]", "button:not([disabled])", "input:not([disabled]):not([type=hidden])",
"select:not([disabled])", "textarea:not([disabled])", "summary", "iframe",
"audio[controls]", "video[controls]", '[contenteditable]:not([contenteditable="false"])',
'[tabindex]:not([tabindex^="-"])'].join(",");
const tabbable = (root: HTMLElement) =>
Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) =>
!el.closest("[inert]") &&
el.getClientRects().length > 0 && // отсекает display:none и нулевой размер
getComputedStyle(el).visibility !== "hidden");
export function trapFocus(box: HTMLElement) {
const onKeydown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const items = tabbable(box);
if (!items.length) { e.preventDefault(); box.focus(); return; } // пустой диалог
const [first, last] = [items[0], items.at(-1)!];
if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
};
box.addEventListener("keydown", onKeydown);
return () => box.removeEventListener("keydown", onKeydown);
}
Слабое место — список селекторов: он не покрывает Shadow DOM, не учитывает content-visibility и динамически добавленные отрицательные tabindex. Поэтому в проде берут проверенные реализации (focus-trap / tabbable, Radix UI, React Aria) или <dialog> + inert. Правило: ловушка допустима только там, где пользователь сам её открыл и может закрыть одним Esc; всё остальное — нарушение 2.1.2.
Skip-links: как пропустить повторяющиеся блоки
На типовой странице перед основным контентом стоит 20–40 табстопов: логотип, меню, подменю, поиск, переключатель языка, кнопка входа. Пользователю мыши это не мешает — он смотрит в центр экрана. Пользователю клавиатуры приходится проходить их на каждой странице заново.
WCAG 2.4.1 Bypass Blocks (уровень A) требует механизм обхода. Формально критерий удовлетворяется тремя способами: ссылкой пропуска, корректными лендмарками или структурой заголовков. Нюанс в том, что лендмарки и заголовки помогают только пользователям скринридеров — у них есть горячие клавиши для перехода по регионам. Зрячий человек, который пользуется только клавиатурой, навигации по лендмаркам не имеет. Поэтому skip-link на практике обязателен, даже если аудит формально «зелёный».
<body>
<a class="skip-link" href="#main">Перейти к основному содержимому</a>
<header>…</header>
<nav aria-label="Основная навигация">…</nav>
<main id="main" tabindex="-1"><h1>Заголовок страницы</h1></main>
</body>
.skip-link {
position: absolute; inset-block-start: 0; inset-inline-start: 0;
z-index: 1000; /* выше липкой шапки */
padding: 0.75rem 1.25rem;
background: #101828; color: #fff;
transform: translateY(-120%); /* прячем визуально, НО оставляем в таб-цепочке */
transition: transform 120ms ease-in-out;
}
.skip-link:focus { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .skip-link { transition: none; } }
Три ошибки превращают skip-link в декорацию. Первая — display: none или visibility: hidden: такой элемент выпадает из таб-цепочки и ссылка не сработает никогда, прятать нужно transform, clip-path или классом .visually-hidden с clip-path: inset(50%). Вторая — ссылка есть, а цели нет: href="#main" при отсутствии id="main" (это ловит любой автоматический аудит). Третья — не описан :focus: ссылка остаётся за экраном даже в фокусе, пользователь жмёт Tab, ничего не видит и не догадывается, что первый табстоп существует.
Зачем цели tabindex="-1". Переход по якорю прокручивает страницу, но исторически не всегда переносил фокус: Safari и старые браузеры оставляли activeElement на ссылке, и следующий Tab возвращал пользователя в меню. Современные браузеры устанавливают «точку начала последовательной навигации» на цель, но если цель фокусируема, поведение предсказуемее во всех движках. tabindex="-1" делает <main> программно фокусируемым, не добавляя лишний табстоп; чтобы у контейнера не появлялось кольцо при клике, добавьте main:focus { outline: none } — только для контейнера-цели, не для контролов.
На сложных страницах бывает полезно несколько ссылок: «к содержимому», «к навигации», «к поиску», «к фильтрам». Их держат одним блоком, который целиком показывается при фокусе на первой ссылке; не переусердствуйте — пять skip-links это пять новых табстопов в самом начале. Skip-link не отменяет корректных лендмарков <header>, <nav>, <main>, <aside>, <footer>, <search>: они дают навигацию скринридеру и структурируют страницу для всех (разбор — в семантике и HTML-семантике фронтенд-трека).
Управление фокусом в динамике
Статическая страница — простой случай. Настоящие проблемы начинаются, когда DOM меняется.
Переход между «страницами» в SPA
При клике по ссылке в классическом сайте браузер загружает новый документ: фокус сбрасывается на начало, скринридер объявляет заголовок. В SPA этого не происходит — URL меняется, контент подменяется, а фокус остаётся на ссылке, которой на новой странице уже нет. Для пользователя скринридера это выглядит так, будто ничего не случилось.
/** Вызывается после того, как новый маршрут отрисован. */
export function announceRouteChange(selector = "h1") {
const heading = document.querySelector<HTMLElement>(selector);
if (!heading) return;
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true }); // скринридер прочитает заголовок новой страницы
window.scrollTo({ top: 0 });
heading.addEventListener("blur", () => heading.removeAttribute("tabindex"), { once: true });
}
Альтернатива — «объявлятор маршрута»: скрытый регион aria-live="polite", куда после навигации записывают заголовок новой страницы (так делает Next.js). Комбинировать оба подхода не нужно — получите двойное озвучивание. Приятная деталь: программный .focus() на элементе с tabindex="-1" в большинстве браузеров не активирует :focus-visible, поэтому кольцо на заголовке не появится, а скринридер всё равно прочитает.
Элемент с фокусом удалили
Самый частый и самый незаметный баг. Пользователь нажимает «Удалить» в строке таблицы, строка исчезает вместе с кнопкой, activeElement становится <body>, и следующий Tab начинает обход страницы с самого начала. Для человека, у которого до таблицы 200 табстопов, это конец работы.
function removeRow(row: HTMLTableRowElement) {
const hadFocus = row.contains(document.activeElement);
const sibling = (row.nextElementSibling ?? row.previousElementSibling) as HTMLElement | null;
row.remove();
if (!hadFocus) return;
(sibling?.querySelector<HTMLElement>("button, [href]") // фокус на соседнюю строку,
?? document.querySelector<HTMLElement>("#table-caption") // а если строк не осталось —
)?.focus(); // на заголовок таблицы
}
Правило: перед удалением узла решите, куда уйдёт фокус. Кандидаты по убыванию предпочтительности: следующий однотипный элемент → предыдущий → контейнер списка с tabindex="-1" → заголовок секции. Сообщение «Строка удалена» отдайте в регион aria-live="polite".
Раскрытие, сворачивание, подсказки
- Аккордеон / disclosure. Фокус остаётся на кнопке, меняется
aria-expanded; переносить фокус в раскрытую панель не нужно — содержимое идёт сразу после кнопки, пользователь дойдёт следующим Tab. А вот в выпадающем меню фокус переносится в меню (первый пункт), Esc возвращает на кнопку, Tab закрывает меню и уходит дальше по странице. - Tooltip. Показывается по
focusиhover, скрывается поblur,mouseleaveи Esc (прямое требование WCAG 1.4.13 Content on Hover or Focus), сам фокус не перехватывает. - Скрытые части формы. Поле, появляющееся после чекбокса, вставляйте сразу после него в DOM, иначе порядок обхода развалится. Разбор — в статье о доступных формах.
autofocus и критерий «фокус ничего не запускает»
autofocus уместен там, где ввод — единственная цель экрана: страница входа, поисковая главная, поле в только что открытом диалоге. На обычной странице он вреден: телепортирует пользователя вниз, ломает чтение с начала и сбивает скринридер. Отдельное требование — WCAG 3.2.1 On Focus (уровень A): получение фокуса не должно само по себе менять контекст. Нельзя отправлять форму по фокусу, открывать модалку, переходить на другую страницу. Автоперескок на следующее поле после ввода последнего символа (коды из SMS) допустим, но возврат по Backspace и Shift+Tab обязан работать.
Ручная проверка за пять минут
Не нужен скринридер и специальные инструменты. Уберите руку с мыши и пройдите чек-лист.
- Ctrl/Cmd+L → Tab. Первый табстоп — skip-link? Он виден?
- Пройдите всю страницу табом до футера. Видно ли кольцо фокуса на каждом шаге? Кольцо пропало, а страница не прокрутилась — фокус ушёл в скрытое меню или за экран.
- Совпадает ли порядок с визуальным? Прыжки назад и вбок записывайте. Затем Shift+Tab по всему пути обратно — обратный проход ломается чаще прямого.
- Откройте каждый диалог. Фокус зашёл внутрь? Tab циклится? Esc закрывает? Фокус вернулся на кнопку, которая открыла окно?
- Активируйте всё кликабельное с Enter и Space (реагирует только на мышь — баг), и повторите проверку при масштабе 200% и на узком экране: липкие панели начинают перекрывать фокус именно там (2.4.11).
- Попробуйте drag-and-drop без мыши. Если перетаскивание — единственный способ выполнить действие, это нарушение WCAG 2.5.7 Dragging Movements (AA, новое в 2.2). Нужна альтернатива: кнопки «вверх/вниз», меню «Переместить в…».
- Проверьте одиночные горячие клавиши. Если
j,/илиsвне поля ввода что-то делают, по WCAG 2.1.4 Character Key Shortcuts (A) это должно отключаться, переназначаться или работать только при фокусе на компоненте — иначе пользователи голосового ввода активируют команды случайно, речью.
Особенность macOS: в Safari по умолчанию Tab может не останавливаться на ссылках и кнопках, пока в системных настройках не включена клавиатурная навигация («Использовать навигацию с клавиатуры», в самом Safari — «Нажатие Tab выделяет каждый объект на веб-странице»). Прежде чем заводить баг «в Safari не работает Tab», проверьте эту настройку.
// 1. Куда уходит фокус, включая Shadow DOM
const deepActive = (r = document) =>
r.activeElement?.shadowRoot ? deepActive(r.activeElement.shadowRoot) : r.activeElement;
document.addEventListener("focusin", () => console.log("focus →", deepActive()));
// 2. Положительный tabindex — потенциальное разрушение порядка
console.table([...document.querySelectorAll("[tabindex]")]
.filter((el) => Number(el.getAttribute("tabindex")) > 0));
// 3. Подсветить весь маршрут обхода прямо на странице
document.querySelectorAll("a[href],button,input,select,textarea,[tabindex]:not([tabindex^='-'])")
.forEach((el) => { el.style.outline = "2px dashed hotpink"; });
Рядом полезны расширение Accessibility Insights for Web с режимом Tab Stops, рисующим пронумерованный маршрут фокуса поверх страницы, и вкладка Accessibility в DevTools для проверки имени и роли сфокусированного элемента.
Что можно автоматизировать
Честная граница: автотесты не находят проблем порядка фокуса, потому что «логичность порядка» — вопрос смысла, — но механические нарушения они ловят отлично.
import { test, expect } from "@playwright/test";
test("skip-link ведёт в main и виден при фокусе", async ({ page }) => {
await page.goto("/");
await page.keyboard.press("Tab");
const skip = page.getByRole("link", { name: /основному содержимому/i });
await expect(skip).toBeFocused();
await expect(skip).toBeInViewport(); // не спрятан за экраном
await page.keyboard.press("Enter");
await expect(page.locator("main")).toBeFocused();
});
test("модалка ловит фокус и возвращает его на триггер", async ({ page }) => {
await page.goto("/profile");
const trigger = page.getByRole("button", { name: "Изменить адрес" });
await trigger.click();
for (let i = 0; i < 20; i++) await page.keyboard.press("Tab"); // 20 табов не выводят наружу
await expect(page.getByRole("dialog").locator(":focus")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused(); // фокус вернулся на кнопку
});
axe-core (@axe-core/playwright, jest-axe) ловит положительный tabindex, aria-hidden на предке сфокусированного элемента, отсутствие цели у skip-link, интерактивные элементы без доступного имени — но не порядок обхода. eslint-plugin-jsx-a11y на этапе кода ловит onClick без onKeyDown и tabindex на неинтерактивных элементах. @testing-library/user-event даёт await user.tab() — лучший способ покрыть клавиатурные сценарии компонента юнит-тестом. Как встроить это в процесс — в статье о тестировании и процессе; про E2E в целом — E2E и UI-тесты и тестирование фронтенда.
Типичные ошибки — сводка
| Симптом | Причина | Что делать |
|---|---|---|
| Фокус не виден | outline: none в reset-стилях |
:focus-visible с контрастным кольцом и outline-offset |
| Кольцо обрезано | overflow: hidden у родителя |
box-shadow вместо outline или убрать обрезку |
| Tab прыгает через экран | order/grid-area/портал переставили порядок |
привести DOM-порядок к визуальному |
| Tab уходит «в никуда» | скрытое opacity: 0 / translateX(-9999px) меню |
display: none, hidden или inert в закрытом состоянии |
| До элемента нельзя дотабать | <div onclick> без tabindex и роли |
нативный <button> |
| Из модалки уходит фокус, из виджета нельзя выйти | нет изоляции фона / безусловный возврат фокуса | <dialog>.showModal() либо inert; Esc как выход, Tab не блокировать полностью |
После закрытия окна фокус на body, «Удалить» ломает навигацию |
триггер или узел исчезли, фокус не перенесён | запомнить activeElement, заранее выбрать соседа |
| Фокус под липкой шапкой | нет отступа прокрутки | scroll-padding-block-start на :root |
| Порядок «странный» после правки | положительный tabindex |
только 0 и -1 |
| В SPA «ничего не произошло» | фокус не перенесён при навигации | фокус на h1 с tabindex="-1" |
Мини-итог
- В документе одна точка ввода: всё, что нельзя обойти по ленте табстопов, для клавиатуры не существует. Порядок обхода задаётся DOM; CSS переставляет пиксели, но не ленту — держите их синхронными (2.4.3).
tabindexв проде принимает только0и-1: положительные значения — глобальная поломка порядка. - Индикатор фокуса обязателен и должен быть виден целиком:
:focus-visibleвместоoutline: none(2.4.7),scroll-paddingпротив липких панелей (2.4.11). - Группа однотипных элементов — один табстоп: roving tabindex или
aria-activedescendant. - Ловушка фокуса допустима только в модальном окне, которое пользователь открыл сам и закрывает по Esc (2.1.2); инструменты —
<dialog>.showModal()иinert. - Skip-link — первый табстоп страницы, видимый при фокусе, ведущий на
<main tabindex="-1">(2.4.1). - При любом изменении DOM отвечайте на вопрос «где сейчас фокус и куда он должен уйти». Пять минут ручной проверки табом находят больше, чем любой автоматический сканер.
Источники
- WCAG 2.2 — W3C Recommendation и Understanding WCAG 2.2 — критерии 2.1.1, 2.1.2, 2.1.4, 2.4.1, 2.4.3, 2.4.7, 2.4.11, 2.4.13, 2.5.7, 3.2.1.
- WAI-ARIA APG: Developing a Keyboard Interface и каталог паттернов; focus-trap / tabbable — эталонный расчёт фокусируемых элементов со всеми краевыми случаями.
- MDN: tabindex, :focus-visible, inert, HTMLDialogElement; HTML Standard: focus management — первоисточник по вычислению таб-цепочки.
- WebAIM: Keyboard Accessibility и Skip Navigation Links.
Что дальше
Клавиатура — это механика перемещения. Дальше разберём, что при этом слышит пользователь: как скринридер строит модель страницы, чем режим чтения отличается от режима форм и почему одна и та же разметка звучит по-разному в NVDA и VoiceOver.
Скринридеры: как они читают страницу, NVDA/VoiceOver на практике