Доступность (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 лишает его этой навигации полностью — он будет слушать страницу от начала до конца.
Два правила, которые решают большинство проблем:
- Правильный тег вместо
div. Кнопка —<button>, ссылка —<a href>, список —<ul>/<ol>. Нативный элемент привозит поведение (фокус, Enter/Space, роль) бесплатно. mainиh1ровно один на view. Несколькоh1— рассыпанная навигация по заголовкам. Скринридер не знает, где «главный» заголовок.
Навигация с клавиатуры: focus-visible и focus-trap
Заголовок раздела «Навигация с клавиатуры: focus-visible и focus-trap»Базовый порядок и видимый фокус
Заголовок раздела «Базовый порядок и видимый фокус»Всё интерактивное обязано быть достижимо по Tab и иметь видимый фокус. Порядок фокуса = порядку DOM, поэтому вёрстка «визуально одно, в DOM другое» ломает навигацию.
focus-visible — селектор, показывающий обводку только для клавиатурного фокуса (мышь её не получает):
.button:focus-visible { outline: 2px solid #4f46e5; outline-offset: 2px;}
/* НИКОГДА так не делай: */.button:focus { outline: none; } /* слепой пользователь теряет позицию */Focus trap: модалка держит фокус
Заголовок раздела «Focus trap: модалка держит фокус»Открытая модалка — отдельный «мир»: 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
Заголовок раздела «Клавиатурные паттерны по WAI-ARIA»Комплексные виджеты имеют стандартизированные раскладки клавиш (WAI-ARIA Authoring Practices, раздел Patterns): табы — стрелки влево/вправо, меню — стрелки + Home/End, комбобокс — стрелки + Escape + Enter. Не выдумывай свои — следуй стандарту, иначе пользователи скринридеров будут удивляться.
ARIA: первое правило и живые регионы
Заголовок раздела «ARIA: первое правило и живые регионы»Первое правило ARIA: не используй ARIA
Заголовок раздела «Первое правило ARIA: не используй ARIA»Формулировка из спецификации: «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>aria-live: динамика без перезагрузки
Заголовок раздела «aria-live: динамика без перезагрузки»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 в большинстве скринридеров не сработает).
Формы: label, fieldset и ошибки
Заголовок раздела «Формы: label, fieldset и ошибки»Форма — самый частый источник нарушений. Правила:
// 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-core и скринридеры
Заголовок раздела «Тестирование: axe-core и скринридеры»Автоматика: axe-core
Заголовок раздела «Автоматика: axe-core»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 и VoiceOver
Заголовок раздела «Ручная проверка: NVDA и VoiceOver»Автоматика не поймёт «порядок чтения бессмысленен» или «имя кнопки — “кнопка кнопка”». Минимальный навык работы со скринридером обязателен для фронтендера.
NVDA (Windows, бесплатно):
- Запуск:
Ctrl + Alt + N. Режим обзора/фокуса:Insert + Пробел. - Навигация:
H— заголовки,D— лендмарки,F— формы,L— списки,Tab— фокусные элементы. - Читает всё подряд:
Insert + Стрелка вниз.
VoiceOver (macOS):
- Включение:
Cmd + F5. Навигация — через «быстрые роторы»:VO + U(ротор — список заголовков/лендмарок/ссылок). VO + Стрелки— ходить по элементам,VO + Space— активировать.
Сценарий проверки формы: включи скринридер, закрой глаза (или отвернись от экрана), пройди весь сценарий клавиатурой: фокус по Tab, ввод, submit, чтение ошибки. Если на каком-то шаге ты потерялся — пользователь тоже потеряется.
WCAG: уровни и практика
Заголовок раздела «WCAG: уровни и практика»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-прогон главных сценариев), ручной скринридерный прогон перед релизом по чек-листу (лендмарки, формы, модалки, уведомления), раз в квартал — полный аудит одной типовой воронки «с закрытыми глазами», когда ты видишь только то, что озвучивает скринридер.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»div onclickвместо кнопки. Нет фокуса, не работает Enter/Space, скринридер не видит роли. Хорошо:<button type="button">; если дизайн «не кнопка» — всё равно button +appearance: none+ свои стили.- Скрытие элементов через
display: none/visibility: hidden— или наоборот, «сокрытие» через прозрачность. Первые два действительно скрывают от скринридера;opacity: 0и визуальное смещение (left: -9999px) — нет: невидимый контент продолжают читать. Скрывай декоративное черезaria-hidden="true"+ CSS, либоsr-only-паттерн. - Автофокус на первом поле без предупреждения. Скринридер начинает читать с середины формы, пользователь не слышит заголовка и контекста. Хорошо: фокус на заголовок модалки/формы (
tabindex="-1"+.focus()), описание — вaria-describedby. - Пустой alt там, где нужен текст, и текст там, где нужна пустота. Декоративное изображение —
alt=""(скринридер пропустит); информативное — осмысленный alt («График роста продаж за 2025 год», а не «график»). Фон через CSS —role="img"+ aria-label, если несёт смысл. aria-liveна весь блок приложения. Каждое мелкое изменение будет озвучиваться — пользователь сошёл с ума. Хорошо: узкие регионы только под статусные сообщения.- Таблицы ради раскладки.
<table>читается построчно с объявлением ячеек — для визуальной раскладки это ад. Вёрстка — CSS Grid/Flex; таблица — только для табличных данных.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Первое правило ARIA? Не используй ARIA, если есть нативный элемент: он привозит поведение и семантику бесплатно. Плохая ARIA хуже её отсутствия — она врёт assistive-технологиям.
- Как скринридер-пользователь ориентируется на странице? По лендмаркам (D), заголовкам (H), ссылкам, формам. Поэтому семантика и иерархия заголовков — навигация, а не «SEO-штука».
- Что такое focus trap и когда он нужен? Циклическое удержание Tab внутри модалки/меню: фокус с последнего элемента переходит на первый. Нужен при модальных окнах; при закрытии — возврат на триггер.
- Чем
aria-live="polite"отличается отassertive? Polite — объявление после текущей фразы (статусы, результаты поиска); assertive — немедленный прерывающий анонс (критические ошибки). Assertive — редкий, его перебор делает страницу нечитаемой. - Как связать ошибку валидации с полем?
aria-describedbyна инпуте → id контейнера ошибки; ошибку плюсом пометитьrole="alert". Фокус после submit — на первое невалидное поле. - Почему placeholder — не label? Исчезает при вводе, контраст низкий, не все скринридеры читают. Это подсказка формата, а не имя поля.
- Что покрывает axe-core, а что нет? Статические нарушения: отсутствие label/alt, контраст, некорректные роли, дубли id. Не покрывает: смысл alt-текста, порядок чтения, логику фокуса, удобство — это ручная проверка со скринридером.
- Разница уровней WCAG A/AA/AAA? A — базовая доступность; AA — стандарт законодательства (контраст 4.5:1, видимый фокус); AAA — максимум, требуется редко. Коммерческий ориентир — AA.
Практика
Заголовок раздела «Практика»- Аудит лендмарков. Пройди все страницы pet-проекта через DevTools → Accessibility: ровно один
main, одинh1, все секции с именами. Критерий: страница навигации в NVDA/VoiceOver (ротор/лендмарки) осмысленна без единого взгляда на экран. - Клавиатурный прогон. Пройди регистрацию, логин и главный сценарий pet-проекта только с клавиатуры: Tab, Shift+Tab, Enter, Space, Escape. Критерий: ни одной ловушки, фокус виден везде, порядок логичен; записывай отклонения списком.
- Форма по правилам. Перепиши форму регистрации: явные label, fieldset для радио, ошибки через
aria-describedby+role="alert", фокус на первую ошибку после submit. Критерий: axe-проход без нарушений + сценарий «слепого» прохождения с ошибками понятен. - Живой статус. Добавь к поиску
aria-liveрегион «Найдено N результатов» и статус загрузки. Критерий: в NVDA при вводе запроса результат озвучивается автоматически, без перемещения фокуса. - axe в CI. Подключи
jest-axe(или@axe-core/playwright) к тестам pet-проекта на уровне smoke: главная, формы, диалоги. Критерий: CI падает при любом новом нарушении; существующие — заведены как issues с планом исправления.