Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Доступные интерфейсы
accessibility

Доступные интерфейсы

Семантический HTML, клавиатура, управление фокусом, ARIA, сообщения об ошибках и автоматизированный аудит.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Доступные интерфейсы

Доступность начинается с корректной семантики и управления фокусом, а не с добавления ARIA после завершения интерфейса.

#Результат урока

Вы создадите интерфейс, которым можно пользоваться клавиатурой и вспомогательными технологиями, и закрепите ключевые гарантии тестами.

Понадобятся семантика DOM, формы, проектирование компонентов и Testing Library. Итог урока — доступные Dialog и форма с предсказуемым управлением фокусом, описанными ошибками и клавиатурным управлением.

#1. Нативная семантика прежде ARIA

Используйте 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 и клавиатурного управления.

#2. Доступное имя и описание

Элемент получает доступное имя (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 с именем.

Частая ошибка. Кнопка только с иконкой без доступного имени.

#3. Клавиатура и видимый фокус

Все действия должны быть доступны с клавиатуры, порядок фокуса следует 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.

#4. Управление фокусом в 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 клавиатурой, затем удалите кнопку открытия во время операции.

Частая ошибка. Фокус остаётся на скрытом элементе или на фоне.

#5. Ошибки формы без одного цвета

Ошибку связывают с полем через 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> )}

Текст ошибки формулируют как инструкцию к исправлению, а не как констатацию («Поле невалидно»). Сводку ошибок после отправки делают списком ссылок на поля — это единственный способ быстро дойти до нужного места в длинной форме.

Проверьте себя. Отправьте форму и проверьте озвучивание сводки и описаний полей.

Частая ошибка. Красная рамка без текста и связи.

#6. Динамические обновления и живые области

Важные изменения вне фокуса объявляются через 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 на весь список результатов.

#7. Масштаб и перестроение без потери содержимого

Интерфейс должен работать при увеличении масштаба, крупном тексте и узком экране без двумерной прокрутки основного содержимого. Не фиксируйте высоту текста и не запрещайте масштабирование. Области касания должны иметь достаточный размер и расстояние. Альбомная ориентация и уменьшенное движение — отдельные условия.

Фиксированная высота карточки обрезает текст ровно у тех пользователей, которые увеличили шрифт:

/* ❌ при 200% масштаба текст уходит под обрез */ .card { height: 120px; overflow: hidden; } /* ✅ высота следует содержимому, у карточки есть только минимум */ .card { min-height: 120px; }

Запрет масштабирования метатегом (maximum-scale=1, user-scalable=no) — прямое нарушение критерия WCAG: пользователь со слабым зрением теряет единственный доступный ему инструмент. Проверять нужно два порога: масштаб 200% и ширину 320 CSS-пикселей, при которых основное содержимое обязано читаться без горизонтальной прокрутки.

Проверьте себя. Проверьте масштаб 200% и ширину 320 CSS-пикселей.

Частая ошибка. Обрезать перенос строки карточкой фиксированной высоты.

#8. Многоуровневый аудит

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 и клавиатуры.

Работа готова, когда критический сценарий проходит только клавиатурой, фокус видим, дерево доступности осмысленно, а автоматический аудит не находит известных нарушений.

#Источники для сверки

  • WAI-ARIA Authoring Practices
  • WCAG 2.2
  • MDN Accessibility

Устранение неисправностей

Далее: Стили и дизайн-системы