Перейти к содержимому

Доступность (a11y): семантика, клавиатура, ARIA и тестирование

Доступность — это единственная тема в этом разделе, где ошибка стоит не «красного Lighthouse», а реального человека, который не может оформить заказ, пройти регистрацию или прочитать твой контент. При этом 80% нарушений доступности — не экзотические кейсы, а банальные вещи: кнопка-div без фокуса, форма без label, модалка, уносящая фокус в никуда. В этой главе — механика: как скринридер видит твою страницу, как управлять фокусом, когда ARIA нужна, а когда вредна, и как встроить проверки в CI, чтобы регрессия не проскользнула.

Продакшен-контекст: в России и ЕС доступность — юридическое требование для части сайтов (госуслуги, банки, e-commerce), а во всём мире — фактор ранжирования и рыночное преимущество. Но даже если отбросить всё это: доступный интерфейс — это просто хороший интерфейс. Семантическая вёрстка лучше индексируется, клавиатурная навигация ускоряет работу пауэръюзеров, а aria-live удобнее модалок с алертами.

Скринридер не «видит» макет. Он читает accessibility tree — дерево, которое браузер строит из DOM с учётом семантики. Из этой семантики пользователь сразу получает навигацию: по заголовкам (H), по лендмаркам (landmarks), по ссылкам, по формам.

<!-- страница, с которой скринридер-пользователь ориентируется за секунды -->
<body>
<header>
<nav aria-label="Основная навигация"><!-- ссылки --></nav>
</header>
<main>
<h1>Оформление заказа</h1>
<section aria-labelledby="delivery-heading">
<h2 id="delivery-heading">Доставка</h2>
<!-- поля доставки -->
</section>
</main>
<aside aria-label="Корзина"><!-- итоги --></aside>
<footer><!-- контакты --></footer>
</body>

Ключевые лендмарки: header/banner, nav/navigation, main (один на страницу!), aside/complementary, footer/contentinfo. Пользователь NVDA жмёт D и прыгает по лендмаркам, H — по заголовкам, T — по таблицам. Плоская структура из div лишает его этой навигации полностью — он будет слушать страницу от начала до конца.

Два правила, которые решают большинство проблем:

  1. Правильный тег вместо div. Кнопка — <button>, ссылка — <a href>, список — <ul>/<ol>. Нативный элемент привозит поведение (фокус, Enter/Space, роль) бесплатно.
  2. main и h1 ровно один на view. Несколько h1 — рассыпанная навигация по заголовкам. Скринридер не знает, где «главный» заголовок.

Всё интерактивное обязано быть достижимо по Tab и иметь видимый фокус. Порядок фокуса = порядку DOM, поэтому вёрстка «визуально одно, в DOM другое» ломает навигацию.

focus-visible — селектор, показывающий обводку только для клавиатурного фокуса (мышь её не получает):

.button:focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
/* НИКОГДА так не делай: */
.button:focus { outline: none; } /* слепой пользователь теряет позицию */

Открытая модалка — отдельный «мир»: Tab не должен выходить за её пределы, Escape закрывает, при закрытии фокус возвращается на триггер. Реализация руками — классическая задача на собеседованиях:

function useFocusTrap(isActive: boolean, containerRef: RefObject<HTMLElement>) {
useEffect(() => {
if (!isActive) return;
const container = containerRef.current;
if (!container) return;
// 1. запоминаем, откуда пришли
const previouslyFocused = document.activeElement as HTMLElement;
// 2. переносим фокус внутрь
const focusables = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
focusables[0]?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
// 3. цикл: Shift+Tab на первом → на последний, Tab на последнем → на первый
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
// 4. возвращаем фокус при размонтировании
return () => {
container.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [isActive, containerRef]);
}

В проде это за тебя делает Radix Dialog (глава Radix UI и Shadcn) — но понимать механику обязан: на собеседовании спросят, и в кастомных виджетах пригодится.

Комплексные виджеты имеют стандартизированные раскладки клавиш (WAI-ARIA Authoring Practices, раздел Patterns): табы — стрелки влево/вправо, меню — стрелки + Home/End, комбобокс — стрелки + Escape + Enter. Не выдумывай свои — следуй стандарту, иначе пользователи скринридеров будут удивляться.

Формулировка из спецификации: «No ARIA is better than bad ARIA». Каждый aria-атрибут — контракт, который ты подписываешь с assistive-технологиями. <div role="button" tabindex="0"> требует: роль, фокус, обработку Enter и Space, состояния — нативная <button> даёт всё это строкой. ARIA нужна там, где нативной семантики не хватает: табы, аккордеоны, статусы загрузки, сложные виджеты.

Минимальный обязательный набор:

// кнопка-иконка: у неё нет текста — имя дай через aria-label
<button aria-label="Закрыть диалог" onClick={close}>
<CrossIcon aria-hidden /> {/* иконка — декорация, скрыта от скринридера */}
</button>
// состояние раскрытия — обязательно объявлять
<button aria-expanded={isOpen} aria-controls="filters-panel" onClick={toggle}>
Фильтры
</button>
<div id="filters-panel" hidden={!isOpen}>…</div>

SPA обновляет контент без перезагрузки — скринридер об этом не узнает, пока пользователь вручную не дойдёт до изменённого места. Живые регионы решают это: браузер объявляет изменение внутри региона автоматически.

// статус поиска: «Найдено 12 результатов» — объявится при изменении
<div aria-live="polite" aria-atomic="true" className="sr-only">
{results.length > 0 ? `Найдено ${results.length} результатов` : 'Ничего не найдено'}
</div>
// ошибка формы после submit — assertive прерывает текущее чтение
<div aria-live="assertive" role="alert" className="error">
{submitError}
</div>

Правила: polite (по умолчанию) — объявит, когда пользователь закончит текущую строку; assertive — прервёт немедленно (только для критичного: ошибки, потеря данных). aria-atomic="true" — объявлять весь регион целиком, а не только изменившийся кусок текста. Регион должен существовать в DOM с самого начала (динамически добавленный aria-live в большинстве скринридеров не сработает).

Форма — самый частый источник нарушений. Правила:

// 1. Явная связь label с полем: for/id (или оборачивание)
<label htmlFor="email">Электронная почта</label>
<input id="email" type="email" autoComplete="email" aria-describedby="email-error" />
{error && <p id="email-error" role="alert">{error}</p>} {/* ошибка привязана к полю */}
// 2. Группировка радио/чекбоксов — fieldset/legend вместо div-«группы»
<fieldset>
<legend>Тариф</legend>
<label><input type="radio" name="plan" value="free" /> Бесплатный</label>
<label><input type="radio" name="plan" value="pro" /> Про</label>
</fieldset>
// 3. required — нативно, со звёздочкой в label и текстом обязательности
<label htmlFor="name">
Имя <span aria-hidden="true">*</span>
</label>
<input id="name" required aria-describedby="name-hint" />
<span id="name-hint">Как в паспорте</span>

Три грабли, которые чаще всего ловит axe: инпут без label, placeholder вместо label (placeholder исчезает при вводе и многие скринридеры читают его плохо), ошибки, никак не связанные с полями. Плюс из практики: после неудачного submit перемещай фокус на первое поле с ошибкой — иначе пользователь клавиатуры не узнает, что пошло не так.

Axe — движок аудита от Deque, покрывает ~40-50% нарушений (всё, что проверяется статически: контраст, label, роли, alt). В браузере его обёртка — расширение axe DevTools. Интеграция в тесты:

import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { RegisterForm } from './RegisterForm';
expect.extend(toHaveNoViolations);
it('формирует корректную accessibility tree', async () => {
const { container } = render(<RegisterForm />);
expect(await axe(container)).toHaveNoViolations();
});
// а также: accessible-запросы из Testing Library — тест проверяет то же, что видит пользователь
it('отправляет форму', async () => {
render(<RegisterForm />);
await userEvent.type(screen.getByLabelText(/почта/i), 'a@b.ru');
await userEvent.click(screen.getByRole('button', { name: /зарегистрироваться/i }));
// getByRole провалится, если у кнопки нет имени — бесплатная проверка a11y
});

В браузере — расширение axe DevTools; в CI — @axe-core/playwright для E2E. Автоматика не заменяет ручную проверку, но гарантирует базовый уровень и ловит регрессии.

Автоматика не поймёт «порядок чтения бессмысленен» или «имя кнопки — “кнопка кнопка”». Минимальный навык работы со скринридером обязателен для фронтендера.

NVDA (Windows, бесплатно):

  • Запуск: Ctrl + Alt + N. Режим обзора/фокуса: Insert + Пробел.
  • Навигация: H — заголовки, D — лендмарки, F — формы, L — списки, Tab — фокусные элементы.
  • Читает всё подряд: Insert + Стрелка вниз.

VoiceOver (macOS):

  • Включение: Cmd + F5. Навигация — через «быстрые роторы»: VO + U (ротор — список заголовков/лендмарок/ссылок).
  • VO + Стрелки — ходить по элементам, VO + Space — активировать.

Сценарий проверки формы: включи скринридер, закрой глаза (или отвернись от экрана), пройди весь сценарий клавиатурой: фокус по Tab, ввод, submit, чтение ошибки. Если на каком-то шаге ты потерялся — пользователь тоже потеряется.

WCAG 2.2 — международный стандарт, организованный по принципам POUR: воспринимаемость, управляемость, понятность, надёжность. Удобная навигация по критериям — Quick Reference. Уровни:

Уровень Что означает Примеры критериев
A Минимум, без которого сайт недоступен alt у изображений, label у полей, не только цветом передаётся состояние
AA Стандарт для большинства законов (включая ГОСТ Р 52868 и европейский EAA) Контраст 4.5:1 для текста, фокус виден, ошибки форм описаны текстом
AAA Максимум, редко требуется Контраст 7:1, расширенная навигация

Цель для коммерческого продукта — AA. Ключевые критерии повседневной работы: контраст текста (проверяй DevTools → Accessibility или axe), видимый фокус (2.4.7 — теперь и 2.4.11 «фокус не перекрыт»), размер кликабельных зон (минимум 24×24 px в WCAG 2.2), отсутствие клавиатурных ловушек.

Два критерия AA, которые ломаются чаще всего на этапе дизайна, а не вёрстки:

Контраст. Для обычного текста — 4.5:1 к фону, для крупного (18pt+) — 3:1. Проверять нужно не только body-текст: placeholder-ы, подписи осей графиков, текст на градиентах и полупрозрачных подложках. В DevTools → Accessibility есть инспектор контраста, axe ловит автоматически. Показательный пример: серый #9ca3af на белом — это 2.8:1, и он есть в каждом третьем макете.

Состояние не только цветом. Ошибка поля не может выражаться только красной рамкой — пользователь с дальтонизмом её не увидит. Обязателен дополнительный канал: иконка, текст, подчёркивание:

/* ошибка: цвет + иконка + текст — тройной канал */
.field[aria-invalid='true'] { border-color: #dc2626; }
.field[aria-invalid='true'] ~ .field-error { display: block; }

То же правило для статусов (success/warning), линий на графиках (паттерны в дополнение к цвету серий) и состояний hover/focus: цвет никогда не бывает единственным носителем смысла.

Как и performance, доступность деградирует медленно, если у неё нет ворот в CI: одна фича привезла иконку-кнопку без label, другая — модалку без Title, и через полгода axe-отчёт не читается. Минимальный контур: axe-проверка на каждый PR (юнит-тесты на компоненты + один E2E-прогон главных сценариев), ручной скринридерный прогон перед релизом по чек-листу (лендмарки, формы, модалки, уведомления), раз в квартал — полный аудит одной типовой воронки «с закрытыми глазами», когда ты видишь только то, что озвучивает скринридер.

  1. div onclick вместо кнопки. Нет фокуса, не работает Enter/Space, скринридер не видит роли. Хорошо: <button type="button">; если дизайн «не кнопка» — всё равно button + appearance: none + свои стили.
  2. Скрытие элементов через display: none/visibility: hidden — или наоборот, «сокрытие» через прозрачность. Первые два действительно скрывают от скринридера; opacity: 0 и визуальное смещение (left: -9999px) — нет: невидимый контент продолжают читать. Скрывай декоративное через aria-hidden="true" + CSS, либо sr-only-паттерн.
  3. Автофокус на первом поле без предупреждения. Скринридер начинает читать с середины формы, пользователь не слышит заголовка и контекста. Хорошо: фокус на заголовок модалки/формы (tabindex="-1" + .focus()), описание — в aria-describedby.
  4. Пустой alt там, где нужен текст, и текст там, где нужна пустота. Декоративное изображение — alt="" (скринридер пропустит); информативное — осмысленный alt («График роста продаж за 2025 год», а не «график»). Фон через CSS — role="img" + aria-label, если несёт смысл.
  5. aria-live на весь блок приложения. Каждое мелкое изменение будет озвучиваться — пользователь сошёл с ума. Хорошо: узкие регионы только под статусные сообщения.
  6. Таблицы ради раскладки. <table> читается построчно с объявлением ячеек — для визуальной раскладки это ад. Вёрстка — CSS Grid/Flex; таблица — только для табличных данных.
  1. Первое правило ARIA? Не используй ARIA, если есть нативный элемент: он привозит поведение и семантику бесплатно. Плохая ARIA хуже её отсутствия — она врёт assistive-технологиям.
  2. Как скринридер-пользователь ориентируется на странице? По лендмаркам (D), заголовкам (H), ссылкам, формам. Поэтому семантика и иерархия заголовков — навигация, а не «SEO-штука».
  3. Что такое focus trap и когда он нужен? Циклическое удержание Tab внутри модалки/меню: фокус с последнего элемента переходит на первый. Нужен при модальных окнах; при закрытии — возврат на триггер.
  4. Чем aria-live="polite" отличается от assertive? Polite — объявление после текущей фразы (статусы, результаты поиска); assertive — немедленный прерывающий анонс (критические ошибки). Assertive — редкий, его перебор делает страницу нечитаемой.
  5. Как связать ошибку валидации с полем? aria-describedby на инпуте → id контейнера ошибки; ошибку плюсом пометить role="alert". Фокус после submit — на первое невалидное поле.
  6. Почему placeholder — не label? Исчезает при вводе, контраст низкий, не все скринридеры читают. Это подсказка формата, а не имя поля.
  7. Что покрывает axe-core, а что нет? Статические нарушения: отсутствие label/alt, контраст, некорректные роли, дубли id. Не покрывает: смысл alt-текста, порядок чтения, логику фокуса, удобство — это ручная проверка со скринридером.
  8. Разница уровней WCAG A/AA/AAA? A — базовая доступность; AA — стандарт законодательства (контраст 4.5:1, видимый фокус); AAA — максимум, требуется редко. Коммерческий ориентир — AA.
  1. Аудит лендмарков. Пройди все страницы pet-проекта через DevTools → Accessibility: ровно один main, один h1, все секции с именами. Критерий: страница навигации в NVDA/VoiceOver (ротор/лендмарки) осмысленна без единого взгляда на экран.
  2. Клавиатурный прогон. Пройди регистрацию, логин и главный сценарий pet-проекта только с клавиатуры: Tab, Shift+Tab, Enter, Space, Escape. Критерий: ни одной ловушки, фокус виден везде, порядок логичен; записывай отклонения списком.
  3. Форма по правилам. Перепиши форму регистрации: явные label, fieldset для радио, ошибки через aria-describedby + role="alert", фокус на первую ошибку после submit. Критерий: axe-проход без нарушений + сценарий «слепого» прохождения с ошибками понятен.
  4. Живой статус. Добавь к поиску aria-live регион «Найдено N результатов» и статус загрузки. Критерий: в NVDA при вводе запроса результат озвучивается автоматически, без перемещения фокуса.
  5. axe в CI. Подключи jest-axe (или @axe-core/playwright) к тестам pet-проекта на уровне smoke: главная, формы, диалоги. Критерий: CI падает при любом новом нарушении; существующие — заведены как issues с планом исправления.