Семантический HTML, клавиатура, управление фокусом, ARIA, сообщения об ошибках и автоматизированный аудит.
Доступность начинается с корректной семантики и управления фокусом, а не с добавления ARIA после завершения интерфейса.
Вы создадите интерфейс, которым можно пользоваться клавиатурой и вспомогательными технологиями, и закрепите ключевые гарантии тестами.
Понадобятся семантика DOM, формы, проектирование компонентов и Testing Library. Итог урока — доступные Dialog и форма с предсказуемым управлением фокусом, описанными ошибками и клавиатурным управлением.
Используйте button для действия, a для навигации, input и select для ввода, а заголовки — в логической иерархии. Нативный элемент уже имеет роль, клавиатурное и платформенное поведение. Для самодельной роли всё это придётся воспроизвести вручную.
Сравните объём работы. Самодельная кнопка требует роли, попадания в порядок обхода и двух клавиш:
// ❌ нужно воспроизвести всё, что браузер даёт бесплатно
<div
role="button"
tabIndex={0}
onClick={onSave}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSave();
}
}}
>
Сохранить
</div>Нативный элемент даёт то же поведение без единой строки логики — и заодно поддержку disabled, отправку формы и корректную работу с программой чтения с экрана:
// ✅ роль, фокус, Enter, Space и disabled уже работают
<button type="button" onClick={onSave} className="btn">
Сохранить
</button>Внешний вид здесь ничего не решает: button со сброшенными стилями визуально не отличается от div.
Проверьте себя. Замените кликабельный div на button без изменения внешнего вида.
Частая ошибка. <div role="button" onClick> без tabindex и клавиатурного управления.
Элемент получает доступное имя (accessible name) из label, текста, aria-label или aria-labelledby. Заполнитель поля (placeholder) не заменяет подпись. Описание подсказки или ошибки связывают через aria-describedby. Имя должно оставаться понятным вне визуального контекста.
Кнопка-иконка — самый частый случай безымянного элемента: программа чтения с экрана произнесёт «кнопка» и ничего больше.
// ❌ имени нет: SVG не является текстом
<button onClick={onDelete}><TrashIcon /></button>
// ✅ имя есть, иконка скрыта от дерева доступности
<button type="button" onClick={onDelete} aria-label="Удалить заявку">
<TrashIcon aria-hidden="true" />
</button>Поле связывают с подписью и описанием явными идентификаторами — тогда имя и подсказка звучат вместе:
<label htmlFor="ticket-title">Заголовок</label>
<input id="ticket-title" aria-describedby="ticket-title-hint" />
<p id="ticket-title-hint">Кратко опишите проблему: до 80 символов.</p>Заполнитель для этого не годится: он исчезает при вводе, часто не читается вспомогательными технологиями и не проходит по контрасту.
Проверьте себя. Найдите элементы через getByRole с именем.
Частая ошибка. Кнопка только с иконкой без доступного имени.
Все действия должны быть доступны с клавиатуры, порядок фокуса следует DOM и визуальному потоку, а индикатор фокуса хорошо заметен. Не используйте положительный tabindex для ручной перестановки. Составные виджеты следуют шаблонам APG с перемещаемым tabindex (roving tabindex) или aria-activedescendant.
Сброс outline без замены — это удаление единственного признака положения фокуса для клавиатурного пользователя:
/* ❌ фокус исчез полностью */
button:focus { outline: none; }
/* ✅ убираем только рамку при клике мышью, оставляем для клавиатуры */
button:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}Положительный tabindex создаёт вторую, невидимую в разметке очередь обхода: элементы с tabindex="1" и выше идут раньше всего остального документа, и порядок перестаёт совпадать с тем, что видит пользователь. Нужен другой порядок — меняйте порядок в DOM.
Проверьте себя. Пройдите сценарий клавишами Tab, Shift+Tab, Enter, Space и Escape.
Частая ошибка. outline: none без равноценного стиля :focus-visible.
DialogПри открытии модального окна фокус переходит на осмысленный элемент внутри, Tab остаётся в диалоге (focus trap), а фон недоступен. Escape закрывает окно по контракту; после закрытия фокус возвращается на кнопку открытия или следующий логичный элемент. Удержание фокуса должно учитывать динамические элементы и вложенные окна.
Готовая примитивная библиотека вроде Radix закрывает удержание фокуса, Escape и возврат фокуса сама — от вас требуется подпись и осмысленная начальная точка:
// src/features/tickets/CloseTicketDialog.tsx
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent aria-describedby={undefined}>
<DialogTitle>Закрыть заявку?</DialogTitle>
{/* фокус ставим на безопасное действие, а не на деструктивное */}
<button type="button" ref={cancelRef} onClick={() => setOpen(false)}>Отмена</button>
<button type="button" onClick={onConfirm}>Закрыть заявку</button>
</DialogContent>
</Dialog>Самодельная модалка на fixed inset-0 требует ручного удержания фокуса, role="dialog", aria-modal, обработки Escape и возврата фокуса — и обычно ломается в одном из этих пунктов. Отдельный неочевидный случай: если кнопка открытия исчезла во время операции, возвращать фокус некуда, и его нужно поставить на осмысленный контейнер, иначе он уйдёт в начало документа.
Проверьте себя. Откройте и закройте Dialog клавиатурой, затем удалите кнопку открытия во время операции.
Частая ошибка. Фокус остаётся на скрытом элементе или на фоне.
Ошибку связывают с полем через aria-describedby, неверное состояние отмечают aria-invalid, а сводка помогает перейти к ошибкам после отправки. Текст объясняет исправление; цвет и иконка лишь дополняют его. Фокус не должен прыгать после каждой клавиши.
Красная рамка не сообщает ни о наличии ошибки, ни о способе её исправить:
// ❌ ошибка существует только визуально
<input className={error ? 'border-error-500' : 'border-border'} />
// ✅ состояние и текст доступны программно
<input
id="ticket-title"
aria-invalid={error ? true : undefined}
aria-describedby={error ? 'ticket-title-error' : undefined}
/>
{error && (
<p id="ticket-title-error" className="text-error-600">
Заголовок обязателен: опишите проблему одной строкой.
</p>
)}Текст ошибки формулируют как инструкцию к исправлению, а не как констатацию («Поле невалидно»). Сводку ошибок после отправки делают списком ссылок на поля — это единственный способ быстро дойти до нужного места в длинной форме.
Проверьте себя. Отправьте форму и проверьте озвучивание сводки и описаний полей.
Частая ошибка. Красная рамка без текста и связи.
Важные изменения вне фокуса объявляются через status или alert с подходящей срочностью. Не делайте всё приложение живой областью (aria-live): поток станет шумом. Элемент живой области лучше создать до изменения текста. Сообщение о загрузке не должно повторяться при каждой отрисовке.
Живая область должна существовать в разметке заранее и пустеть, а не появляться вместе с текстом:
// src/features/tickets/SaveStatus.tsx
function SaveStatus({ state }: { state: 'idle' | 'saving' | 'saved' | 'failed' }) {
// контейнер отрисован всегда: иначе первое сообщение может не объявиться
return (
<p role="status" aria-live="polite">
{state === 'saved' ? 'Изменения сохранены' : state === 'failed' ? 'Не удалось сохранить' : ''}
</p>
);
}aria-live="assertive" прерывает речь пользователя и оправдан только для настоящих аварий — потери соединения, отмены операции. Список результатов поиска в assertive превращает работу с фильтрами в непрерывный поток перебиваний, и пользователь просто отключает озвучивание.
Проверьте себя. Озвучьте результат фонового сохранения один раз.
Частая ошибка. aria-live assertive на весь список результатов.
Интерфейс должен работать при увеличении масштаба, крупном тексте и узком экране без двумерной прокрутки основного содержимого. Не фиксируйте высоту текста и не запрещайте масштабирование. Области касания должны иметь достаточный размер и расстояние. Альбомная ориентация и уменьшенное движение — отдельные условия.
Фиксированная высота карточки обрезает текст ровно у тех пользователей, которые увеличили шрифт:
/* ❌ при 200% масштаба текст уходит под обрез */
.card { height: 120px; overflow: hidden; }
/* ✅ высота следует содержимому, у карточки есть только минимум */
.card { min-height: 120px; }Запрет масштабирования метатегом (maximum-scale=1, user-scalable=no) — прямое нарушение критерия WCAG: пользователь со слабым зрением теряет единственный доступный ему инструмент. Проверять нужно два порога: масштаб 200% и ширину 320 CSS-пикселей, при которых основное содержимое обязано читаться без горизонтальной прокрутки.
Проверьте себя. Проверьте масштаб 200% и ширину 320 CSS-пикселей.
Частая ошибка. Обрезать перенос строки карточкой фиксированной высоты.
ESLint и axe находят отсутствующие подписи, роли и часть связей. Компонентные тесты проверяют имя и фокус, сквозные браузерные — клавиатуру и перестроение, ручная проверка — программу чтения с экрана и понятность сценария. Ноль автоматических нарушений ещё не доказывает соответствие WCAG.
Автоматическая проверка встраивается в обычный компонентный тест и стоит дешево:
// src/features/tickets/TicketForm.test.tsx
it('форма не содержит известных нарушений доступности', async () => {
const { container } = render(<TicketForm />);
expect(await axe(container)).toHaveNoViolations();
});
it('ошибка связана с полем', async () => {
const user = userEvent.setup();
render(<TicketForm />);
await user.click(screen.getByRole('button', { name: 'Создать' }));
expect(screen.getByLabelText('Заголовок')).toHaveAccessibleDescription(/обязателен/);
});Второй тест проверяет то, чего axe не видит: связь конкретной ошибки с конкретным полем. За пределами автоматики остаются понятность формулировок, осмысленность порядка объявлений и работоспособность сценария целиком — это ручная проверка с программой чтения с экрана, и заменить её нечем.
Проверьте себя. Составьте список автоматических, клавиатурных и ручных проверок с программой чтения с экрана для критического сценария.
Частая ошибка. Объявить приложение доступным после одного запуска axe.
Исправьте командную панель: кнопки из div, удержание фокуса без возврата, невидимые подписи, ошибки только цветом и неозвученный асинхронный результат. Добавьте семантические элементы, подпись диалога, возврат фокуса и тесты axe и клавиатуры.
Работа готова, когда критический сценарий проходит только клавиатурой, фокус видим, дерево доступности осмысленно, а автоматический аудит не находит известных нарушений.
Далее: Стили и дизайн-системы