Веб-доступность — это не просто чек-лист верстки, а фундаментальный архитектурный инвариант надежного интерфейса. В корпоративных системах несоблюдение стандартов доступности несет прямые юридические риски (соответствие European Accessibility Act (EAA), American with Disabilities Act (ADA)) и блокирует использование приложения для миллионов пользователей вспомогательных технологий (экранные дикторы Screen Readers, навигация с физической клавиатуры, брайлевские дисплеи).
Доступность в современных веб-приложениях строится на стыке трех уровней:
При динамическом рендеринге в React традиционная доступность мгновенно разрушается, если разработчик пренебрегает управлением фокусом и состояниями:
Tab по фоновым ссылкам страницы, не имея возможности попасть в диалог.onClick на <div> лишает элемент роли в Accessibility Tree: экранный диктор объявляет его как статический текст, а клавиши Enter и Space не вызывают действие.aria-live).Tab создает экстремальную нагрузку на моторику пользователя (Cognitive & Motor overload).Браузер преобразует HTML DOM в параллельную структуру данных — Accessibility Tree (Дерево доступности). Скринридеры (NVDA, JAWS, VoiceOver) и клавиатурные менеджеры читают именно Accessibility Tree, а не стили CSS.
В динамическом SPA на React выделяются три ключевые зоны ответственности a11y:
aria-live="polite" или aria-live="assertive".Использование нативных button, dialog, nav, main вместо div и span в соответствии с First Rule of ARIA.
Добавление атрибутов aria-expanded, aria-controls, aria-haspopup для раскрывающихся панелей.
Настройка навигации клавишами Tab, Esc, стрелками (Roving Tabindex) и циклического Focus Trap.
Интеграция axe-core в юнит-тесты и ручная проверка с экранными дикторами (NVDA / VoiceOver).
В чем главное преимущество паттерна Roving Tabindex перед стандартной навигацией клавишей Tab в списках из 50+ пунктов?
Изоляция пользователя внутри всплывающего окна: блокировка скролла страницы, перехват клавиши Escape для закрытия и циклический Focus Trap для клавиши Tab.
Реализация паттерна Roving Tabindex: пользователь нажимает Tab, чтобы войти в таблицу, затем стрелками [←] [↑] [→] [↓] перемещается между тысячами строк, не засоряя историю табуляции браузера.
Использование атрибута aria-live="polite" или aria-live="assertive" для того, чтобы скринридер корректно озвучил статус операции ("Файл успешно сохранен"), не прерывая текущее чтение пользователя.
Управление доступностью в React требует координации жизненного цикла DOM-узлов, обработчиков событий клавиатуры и очистки ссылок:
Перед открытием модального окна сохраняется ссылка на элемент, вызвавший открытие (кнопка 'Редактировать').
Сканирование DOM-поддерева контейнера на предмет интерактивных элементов: button, input, [tabindex='0'].
При нажатии Tab на последнем элементе фокус перебрасывается на первый. При Shift+Tab на первом — на последний. По Escape инициируется закрытие.
При размонтировании модального окна фокус возвращается точно на исходный элемент.
В какой ситуации оправдано использование атрибута aria-live='assertive' вместо стандартного aria-live='polite'?
Хук находит все фокусируемые элементы внутри переданного контейнера и замыкает навигацию в цикл:
import * as React from 'react';
// Селектор всех потенциально фокусируемых интерактивных элементов DOM
const FOCUSABLE_ELEMENTS_SELECTOR = [
'a[href]',
'area[href]',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'iframe',
'object',
'embed',
'[contenteditable]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export interface UseFocusTrapOptions {
/** Флаг активности захвата фокуса */
isActive: boolean;
/** Функция закрытия при нажатии клавиши Escape */
onEscape?: () => void;
/** Возвращать ли фокус на исходный элемент при размонтировании */
returnFocusOnDeactivate?: boolean;
}
export function useFocusTrap<T extends HTMLElement>(
options: UseFocusTrapOptions
): React.RefObject<T | null> {
const {
isActive,
onEscape,
returnFocusOnDeactivate = true,
} = options;
const containerRef = React.useRef<T | null>(null);
const previousActiveElementRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
if (!isActive) return;
// 1. Сохраняем элемент, который имел фокус до открытия ловушки
if (typeof document !== 'undefined') {
previousActiveElementRef.current = document.activeElement as HTMLElement | null;
}
const container = containerRef.current;
if (!container) return;
// 2. Находим доступные интерактивные элементы
const getFocusableNodes = (): HTMLElement[] => {
const nodes = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS_SELECTOR)
);
// Фильтруем элементы, скрытые через display: none / visibility: hidden
return nodes.filter(
(node) => node.offsetWidth > 0 || node.offsetHeight > 0 || node.getClientRects().length > 0
);
};
const focusableNodes = getFocusableNodes();
const firstNode = focusableNodes[0];
// Устанавливаем первоначальный фокус
if (firstNode) {
firstNode.focus();
} else {
// Если интерактивных элементов внутри нет, фокусируем сам контейнер
container.tabIndex = -1;
container.focus();
}
// 3. Обработчик клавиатурных событий
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onEscape?.();
return;
}
if (event.key !== 'Tab') return;
const nodes = getFocusableNodes();
if (nodes.length === 0) {
event.preventDefault();
return;
}
const firstElement = nodes[0];
const lastElement = nodes[nodes.length - 1];
// Shift + Tab: движение назад
if (event.shiftKey) {
if (document.activeElement === firstElement || document.activeElement === container) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab: движение вперед
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
// 4. Очистка и восстановление фокуса
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
if (returnFocusOnDeactivate && previousActiveElementRef.current) {
// Асинхронный возврат фокуса в следующем микротаске
setTimeout(() => {
previousActiveElementRef.current?.focus();
}, 0);
}
};
}, [isActive, onEscape, returnFocusOnDeactivate]);
return containerRef;
}Вспомогательный компонент для оповещения пользователей скринридеров об асинхронных событиях без визуального засорения интерфейса:
'use client';
import * as React from 'react';
type Politeness = 'polite' | 'assertive';
interface Announcement {
id: string;
message: string;
politeness: Politeness;
}
interface AnnouncerContextValue {
announce: (message: string, politeness?: Politeness) => void;
}
const AnnouncerContext = React.createContext<AnnouncerContextValue | null>(null);
export function useAnnouncer(): AnnouncerContextValue {
const context = React.useContext(AnnouncerContext);
if (!context) {
throw new Error('useAnnouncer должен использоваться внутри <AnnouncerProvider>');
}
return context;
}
export function AnnouncerProvider({ children }: { children: React.ReactNode }) {
const [announcements, setAnnouncements] = React.useState<Announcement[]>([]);
const announce = React.useCallback((message: string, politeness: Politeness = 'polite') => {
const id = Math.random().toString(36).substring(2, 9);
setAnnouncements((prev) => [...prev, { id, message, politeness }]);
// Очищаем сообщение после озвучивания
setTimeout(() => {
setAnnouncements((prev) => prev.filter((item) => item.id !== id));
}, 1000);
}, []);
const politeAnnouncements = announcements.filter((a) => a.politeness === 'polite');
const assertiveAnnouncements = announcements.filter((a) => a.politeness === 'assertive');
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
{/* Визуально скрытые живые регионы (sr-only) */}
<div className="sr-only">
<div role="status" aria-live="polite" aria-atomic="true">
{politeAnnouncements.map((a) => (
<p key={a.id}>{a.message}</p>
))}
</div>
<div role="alert" aria-live="assertive" aria-atomic="true">
{assertiveAnnouncements.map((a) => (
<p key={a.id}>{a.message}</p>
))}
</div>
</div>
</AnnouncerContext.Provider>
);
}Тестирование доступности на уровне интеграционных тестов Vitest гарантирует отсутствие регрессий цветового контраста, невалидных ARIA-тегов и отсутствующих aria-label.
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import * as React from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
expect.extend(toHaveNoViolations);
describe('Accessibility Audit (axe-core)', () => {
it('Модальное окно обязано соответствовать критериям WCAG 2.2 AA без нарушений', async () => {
const { container } = render(
<Dialog open={true}>
<DialogContent>
<DialogTitle>Настройки профиля</DialogTitle>
<DialogDescription>
Управление параметрами конфиденциальности и сессиями.
</DialogDescription>
<form className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium">
Имя пользователя
</label>
<input
id="username"
type="text"
className="mt-1 block w-full rounded border px-3 py-2"
defaultValue="principal_architect"
/>
</div>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded cursor-pointer">
Сохранить
</button>
</form>
</DialogContent>
</Dialog>
);
// Запуск аудита axe-core по правилам WCAG 2.2
const results = await axe(container, {
rules: {
// Проверка цветового контраста (минимум 4.5:1 для обычного текста)
'color-contrast': { enabled: true },
// Проверка корректности ARIA-ролей
'aria-roles': { enabled: true },
// Наличие доступных имен для форм и интерактивных элементов
'label': { enabled: true },
},
});
expect(results).toHaveNoViolations();
});
});Навешивание onClick на <div> лишает клавиатурных пользователей возможности активировать элемент: <div> не получает фокус по Tab, не нажимается клавишами Enter и Space и воспринимается скринридером как бессмысленный блок текста.
// ❌ АНТИПАТТЕРН: Недоступный кликабельный div
<div onClick={handleSubmit} className="btn-primary">
Отправить
</div>
// ✅ ПРАВИЛЬНО: Семантический тег button с нативной доступностью
<button type="button" onClick={handleSubmit} className="btn-primary">
Отправить
</button>Использование утилиты outline-none без добавления альтернативных стилей (focus-visible:ring-2 focus-visible:ring-accent-violet) делает интерфейс полностью слепым для пользователей, управляющих браузером с клавиатуры: невозможно понять, какой элемент сейчас активен.
// ❌ АНТИПАТТЕРН: Полное удаление кольца фокуса
<button className="outline-none">Клик</button>
// ✅ ПРАВИЛЬНО: Доступный фокус только при клавиатурном взаимодействии
<button className="focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet focus-visible:ring-offset-2">
Клик
</button>Режим assertive немедленно прерывает синтез речи скринридера прямо посередине предложения. Использование его для стандартных информационных тостов («Черновик сохранен») вызывает серьезное раздражение. Для 99% уведомлений следует применять aria-live="polite", откладывающий озвучивание до завершения текущей фразы.
Если пользователь удаляет элемент списка кнопкой «Удалить» и этот элемент размонтируется, фокус браузера сбрасывается на тег <body>, заставляя пользователя заново листать всю страницу с самого верха. При удалении активного элемента необходимо явно переместить фокус на соседний элемент списка либо на родительский заголовок.