Веб-доступність — це не просто чек-лист верстки, а фундаментальний архітектурний інваріант надійного інтерфейсу. У корпоративних системах недотримання стандартів доступності несе прямі юридичні ризики (відповідність European Accessibility Act (EAA), Americans 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>, змушуючи користувача заново гортати всю сторінку з самого верху. При видаленні активного елемента необхідно явно перемістити фокус на сусідній елемент списку або на батьківський заголовок.