Доступность (a11y) Доступные компоненты: модалки, меню, табы, автодополнение
0%

Доступные компоненты: модалки, меню, табы, автодополнение

Доступные компоненты: модалки, меню, табы, автодополнение

Простую страницу сломать трудно: заголовки, абзацы, ссылки и поля формы работают сами — браузер выдаёт им роль, имя и клавиатуру, а вы сверху пишете стили. Проблемы начинаются там, где появляется состояние: что-то открылось, что-то выбралось, что-то подгрузилось, фокус куда-то уехал. Модалка, меню, вкладки и автодополнение — четыре компонента, где состояния больше всего, и именно они чаще всего оказываются полностью неработающими для человека с клавиатурой или скринридером. Причина не в лени: визуальный слой выражает состояние бесплатно — подсветил вкладку, и зрячий человек всё понял. Для остальных каналов состояние надо объявлять явно, а фокус переносить руками; ни один CSS-класс этого не делает.

Общая рамка есть во вводном Гайде по Accessibility и в карте трека; дальше я исхожу из того, что вы прочитали про семантику, знаете, что ARIA не добавляет поведения, и умеете работать с фокусом и клавиатурой. Здесь — сборка: как эти слои складываются в конкретный работающий виджет.

Уточнение про тон, раз речь пойдёт про скринридеры. Пользователь NVDA или VoiceOver — не человек, которому мы «помогаем» из милосердия. Это опытный пользователь своего инструмента, слушающий речь на скорости, которую вы с непривычки не разберёте, и владеющий десятками горячих клавиш. Ему не нужны пояснительные подписи «нажмите стрелку вниз для выбора» — нужно, чтобы компонент вёл себя как компонент того типа, которым он представился. Недоработанный виджет для него не «менее удобен», а нерабочий: тупик посреди сценария покупки.

Почему компоненты ломаются: три источника

Поломки полезно разбирать по природе, а не по названию виджета — так их проще ловить в ревью.

  • Состояние существует только в пикселях. Активная вкладка отличается цветом, но в дереве доступности все три одинаковы. Меню раскрыто, а aria-expanded на кнопке остался false. Опция подсвечена, но aria-selected нет. Скринридер читает разметку, а не скриншот: чего нет в дереве — не существует. Это прямое нарушение WCAG 4.1.2 Name, Role, Value (A).
  • Фокусом никто не управляет. Диалог открылся — фокус остался под затемнением; закрылся — улетел на <body>, и человек начинает обход страницы заново. Меню открылось — фокус не вошёл внутрь, стрелки прокручивают страницу. Это 2.4.3 Focus Order (A), а иногда и 2.1.2 No Keyboard Trap (A).
  • Клавиатурная конвенция нарушена. Компонент представился вкладками (role="tab"), но стрелки не работают; представился меню — но Esc не закрывает. Пользователь приходит с ожиданиями, сформированными операционной системой и сотней других сайтов: объявив роль, вы подписались под её контрактом поведения. Роль без поведения хуже отсутствия роли — она обещает то, чего нет.

Отсюда правило, к которому мы будем возвращаться: сначала поведение, потом ARIA. Атрибуты описывают то, что компонент уже умеет, и ничего не чинят.

Контракт доступного компонента: шесть слоёв

Любой интерактивный виджет полезно разбирать по одному чек-листу из шести слоёв. Если хотя бы один пуст — компонент неисправен.

Пройти по этому списку — минута, а ловит он подавляющее большинство дефектов: «Какая роль? Как звучит имя? Какие атрибуты состояния меняются? Куда уходит и откуда возвращается фокус? Все ли клавиши паттерна работают? Что человек услышит, когда данные подгрузятся?»

Решение номер ноль: писать, брать или не делать

Перед тем как открывать редактор, честно оцените стоимость: доступный комбобокс — не «инпут и список», а недели работы и регулярное тестирование на трёх скринридерах, тогда как доступный <button> — ноль работы.

Грубый ориентир по трудоёмкости: disclosure, аккордеон и кнопка-переключатель — час работы; табы и тултип — день; модальный диалог и меню — несколько дней с тестированием; автодополнение, датапикер, дерево и drag-and-drop — недели и постоянная поддержка. Риск при этом распределён иначе: сломанный диалог или перетаскивание блокируют сценарий целиком, а неидеальная карусель просто раздражает.

Библиотеки, у которых доступность заявлена явно и подтверждается практикой: React Aria (Adobe, реализует APG и тестирует на реальных AT), Radix Primitives, Ark UI и Zag.js (стейт-машины отдельно от фреймворка), Downshift для комбобоксов, Headless UI. Это не индульгенция: библиотека даёт корректный каркас, но имя, порядок в DOM, контраст и содержимое объявлений — всё равно ваши. Регрессии в чужом коде тоже случаются, поэтому ручной прогон обязателен в любом случае.

Модальный диалог

Модальный диалог: фон становится инертным, диалог поднимается в top layer, фокус уходит внутрь и возвращается на триггер

Диалог — самый частый компонент и самый разрушительный при поломке: он перекрывает весь интерфейс. Механику фокуса и inert мы разобрали в статье про клавиатуру и фокус; здесь — всё остальное, что обычно забывают. Разметка, из которой стоит исходить:

<button type="button" id="open-delete">Удалить черновик</button>

<dialog id="delete-dialog" aria-labelledby="dd-title" aria-describedby="dd-desc">
  <h2 id="dd-title">Удалить черновик?</h2>
  <p id="dd-desc">Восстановить его будет нельзя.</p>
  <form method="dialog">
    <button value="cancel" autofocus>Отмена</button>
    <button value="delete" class="danger">Удалить</button>
  </form>
</dialog>
  • Имя диалога обязательно. aria-labelledby указывает на заголовок; без имени NVDA прочитает «диалог», и человек не поймёт, куда попал. aria-describedby добавляет пояснение, читается после имени.
  • autofocus — на безопасной кнопке. Никогда не ставьте начальный фокус на деструктивное действие: человек, привыкший подтверждать Enter, удалит данные вслепую.
  • method="dialog" закрывает диалог без JavaScript и кладёт value нажатой кнопки в dialog.returnValue.
  • aria-modal="true" не нужен нативному <dialog> с showModal(): браузер уже сделал страницу инертной. На самодельном диалоге он тоже не панацея — лишь просит скринридер ограничить виртуальный буфер, но не мешает Tab уйти наружу. Изоляцию делает inert, а не атрибут.
  • Кнопка «крестик» обязана иметь имя: aria-label="Закрыть" или визуально скрытый текст. Иконка без имени звучит как «кнопка» — и всё.

Что даёт showModal() и чего не даёт

showModal() бесплатно решает четыре задачи: поднимает диалог в top layer (поверх всего независимо от z-index, overflow: hidden и transform у предков — именно это ломает самодельные модалки внутри контейнеров со скроллом), делает остальную страницу инертной, вешает закрытие по Esc и возвращает фокус на элемент, активный до открытия. Плюс появляется ::backdrop — свой div-затемнитель больше не нужен. Чего он не делает: блокировка прокрутки фона различается между движками, полагаться на неё нельзя, а анимация закрытия требует явного описания, потому что элемент top layer исчезает мгновенно.

html:has(dialog[open]) { overflow: hidden; }
html { scrollbar-gutter: stable; }         /* иначе страница дёрнется на ширину скроллбара */
dialog { overscroll-behavior: contain; }   /* прокрутка внутри диалога не «протекает» на страницу */

dialog { opacity: 0; translate: 0 -8px;
         transition: opacity .2s, translate .2s, overlay .2s allow-discrete, display .2s allow-discrete; }
dialog[open] { opacity: 1; translate: 0 0; }
@starting-style { dialog[open] { opacity: 0; translate: 0 -8px; } }
@media (prefers-reduced-motion: reduce) { dialog { transition-duration: 0.01ms; } }

Последняя строка не декоративная: движение по экрану у части людей вызывает головокружение и тошноту — WCAG 2.3.3 Animation from Interactions (AAA) и здравый смысл; подробнее в статье про визуальную доступность. Отдельно обработайте возврат фокуса, когда триггера больше нет: диалог «удалить строку» закрывается, а кнопки, из которой его открыли, уже нет в DOM — браузер вернёт фокус на <body>.

const dialog = document.getElementById('delete-dialog');
document.getElementById('open-delete').addEventListener('click', () => dialog.showModal());

dialog.addEventListener('close', () => {
  if (dialog.returnValue !== 'delete') return;
  deleteDraft();
  // Триггер удалён вместе со строкой — переносим фокус осмысленно и объявляем результат.
  document.getElementById('drafts-heading').focus();   // у заголовка tabindex="-1"
  announce('Черновик удалён');                          // область role="status" уже в DOM
});

alertdialog, немодальные панели и Popover API

role="alertdialog" — для диалогов, прерывающих работу сообщением, требующим реакции: потеря соединения, несохранённые данные. Отличие от dialog в том, что скринридер немедленно зачитает содержимое, а не только имя. Для формы редактирования это лишний крик — берите обычный диалог.

Немодальные всплывающие слои (поповеры, подсказки, панели фильтров) с 2024 года описывает Popover API: <button popovertarget="filters">Фильтры</button> плюс <div id="filters" popover>. Атрибут popover даёт top layer, закрытие по Esc и «лёгкое» закрытие кликом снаружи, но не даёт ни роли, ни ловушки фокуса — поповер это просто слой. Роль (dialog, menu, listbox) и состояние на кнопке остаются на вас; браузеры постепенно проставляют aria-expanded для popovertarget сами, но полагаться на это ещё рано. Если внутри слоя форма и он должен блокировать работу со страницей — это не поповер, а модальный диалог.

Отдельная частая ошибка — выпадающие панели поверх сфокусированного элемента. В WCAG 2.2 появился критерий 2.4.11 Focus Not Obscured (Minimum) (AA): элемент с фокусом не должен быть полностью закрыт другим содержимым. Липкие шапки, чат-виджеты в углу и раскрывшиеся меню нарушают его регулярно, а проверяется он простым проходом Tab при открытых слоях. Проверка диалога за минуту: открыть с клавиатуры — фокус внутри, а не под затемнением; Tab и Shift+Tab по кругу наружу не уходят; Esc закрывает и фокус вернулся ровно на кнопку-триггер; NVDA при открытии произнёс имя диалога и описание; при зуме 400% окно не обрезано, внутри есть прокрутка, а фон не прокручивается и страница не прыгает.

Меню: одно слово, три разных компонента

Здесь чаще всего ошибаются на самом первом шаге — выбирают неправильную роль. Под «меню» в вебе понимают три разные вещи с разными контрактами.

Что это на самом деле Пример Правильная реализация
Навигация по сайту «Каталог» с подпунктами-ссылками <nav> + <ul> со ссылками, кнопка с aria-expanded. Паттерн APG Disclosure Navigation
Меню действий «⋯» у строки таблицы: переименовать, дублировать, удалить Menu Button: aria-haspopup="menu" + role="menu" и role="menuitem"
Строка меню приложения «Файл, Правка, Вид» в веб-редакторе role="menubar" — редкий случай, только для настоящих приложений

Главная ошибка — role="menu" на навигации по сайту. Роль menu переводит NVDA и JAWS в режим форм: перестают работать команды навигации по заголовкам и ссылкам, а список ссылок исчезает из общего списка ссылок страницы. Человек, который привык обходить сайт по ссылкам клавишей K, вашу навигацию просто не найдёт. Навигация — это ссылки в списке, и ничего больше.

Навигационное выпадающее — паттерн disclosure

<nav aria-label="Основная">
  <ul>
    <li>
      <button type="button" aria-expanded="false" aria-controls="sub-catalog">Каталог</button>
      <ul id="sub-catalog" hidden>
        <li><a href="/laptops">Ноутбуки</a></li>
        <li><a href="/phones">Смартфоны</a></li>
      </ul>
    </li>
  </ul>
</nav>

Никаких ролей: кнопка остаётся кнопкой, ссылки — ссылками, вложенность списков передаёт иерархию. JavaScript переключает aria-expanded и hidden, Esc закрывает и возвращает фокус на кнопку, клик снаружи закрывает. Всё. Этот вариант надёжнее и дешевле «настоящего» меню в девяти случаях из десяти. Если панель раскрывается на hover, она обязана раскрываться и на фокус и соответствовать WCAG 1.4.13 Content on Hover or Focus (AA): содержимое закрывается по Esc, до него можно довести указатель, не потеряв его, и оно не исчезает само по таймеру. На тач-устройствах hover не существует вовсе — клик по родительскому пункту должен открывать подменю, а не сразу уводить по ссылке.

Меню действий: контракт роли menu

Если вы всё-таки строите меню действий, роль обязывает реализовать паттерн целиком. Вот что при этом слышит и делает человек:

Обязательный набор клавиш по APG Menu Button: Enter, Space и ArrowDown открывают меню и фокусируют первый пункт; ArrowUp открывает и фокусирует последний; внутри — стрелки, Home, End и переход по первой букве; Enter активирует; Esc закрывает с возвратом фокуса; Tab закрывает и уходит дальше; клик снаружи закрывает.

/** Меню действий: фокус физически переносится на menuitem, Tab и Esc закрывают. */
export function initMenuButton(button: HTMLButtonElement, menu: HTMLElement) {
  const items = () => Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'));

  function open(where: 'first' | 'last' = 'first') {
    button.setAttribute('aria-expanded', 'true');
    menu.hidden = false;
    const list = items();
    (where === 'first' ? list[0] : list.at(-1))?.focus();
  }

  function close({ restoreFocus = true } = {}) {
    if (menu.hidden) return;
    button.setAttribute('aria-expanded', 'false');
    menu.hidden = true;
    if (restoreFocus) button.focus();          // без этого фокус улетает на body
  }

  button.addEventListener('click', () => (menu.hidden ? open() : close()));
  button.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowDown') { e.preventDefault(); open('first'); }
    if (e.key === 'ArrowUp')   { e.preventDefault(); open('last'); }
  });
  // Клик вне меню закрывает его: pointerdown, а не click, чтобы успеть до смены фокуса
  document.addEventListener('pointerdown', (e) => {
    if (!menu.contains(e.target as Node) && e.target !== button) close({ restoreFocus: false });
  });

  menu.addEventListener('keydown', (e) => {
    const list = items();
    const i = list.indexOf(document.activeElement as HTMLElement);
    switch (e.key) {
      case 'ArrowDown': list[(i + 1) % list.length].focus(); break;
      case 'ArrowUp':   list[(i - 1 + list.length) % list.length].focus(); break;
      case 'Home':      list[0].focus(); break;
      case 'End':       list.at(-1)!.focus(); break;
      case 'Escape':    close(); break;
      case 'Tab':       close({ restoreFocus: false }); return;  // не гасим Tab: он уводит дальше
      default:
        if (e.key.length !== 1) return;                          // переход по первой букве
        const from = list.slice(i + 1).concat(list.slice(0, i + 1));
        from.find((el) => el.textContent?.trim().toLowerCase().startsWith(e.key.toLowerCase()))?.focus();
    }
    if (e.key !== 'Tab') e.preventDefault();
  });
}

Тонкости, о которых узнают только в проде. Пунктами role="menu" должны быть элементы с role="menuitem" (или menuitemcheckbox, menuitemradio); вложенные <a href> при этом теряют семантику ссылки — поэтому меню из ссылок делать не надо, для них есть disclosure. Все пункты получают tabindex="-1", а фокус двигается вызовом focus() — это тот случай, когда aria-activedescendant не используется. Разделитель — role="separator", а не голый <hr>. Логически недоступный пункт помечайте aria-disabled="true" и оставляйте фокусируемым, иначе человек не узнает о существовании действия.

Табы

Анатомия табов: tablist, вкладки с aria-selected и roving tabindex, панель с aria-labelledby и раскладка клавиатуры

Вкладки — простой паттерн с одной серьёзной развилкой и несколькими ловушками дизайна.

<div role="tablist" aria-label="Разделы заказа">
  <button role="tab" id="tab-1" aria-selected="true"  aria-controls="panel-1" tabindex="0">Состав</button>
  <button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">Доставка</button>
  <button role="tab" id="tab-3" aria-selected="false" aria-controls="panel-3" tabindex="-1">Оплата</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">…</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>…</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>…</div>
  • Один табстоп на всю группу. Активная вкладка tabindex="0", остальные -1; переключение стрелками. Реализация roving tabindex разобрана в статье про клавиатуру.
  • Вкладки — <button>, а не <div>: клавиатура и активация достаются бесплатно, role="tab" лишь уточняет роль. Вертикальные вкладки требуют aria-orientation="vertical" и стрелок вверх-вниз.
  • Неактивные панели скрыты hidden или display: none. Если убрать их через opacity: 0 или height: 0, содержимое остаётся в дереве доступности: скринридер прочитает три панели подряд, а Tab уведёт фокус в невидимое.
  • tabindex="0" на панели — только если внутри нет фокусируемых элементов. Тогда Tab со вкладки попадает в панель и текст можно прочитать; если внутри есть кнопки и ссылки, лишний табстоп только мешает.

Автоматическая или ручная активация

При автоматической активации выбор следует за фокусом: стрелка сдвинула фокус — панель переключилась. При ручной фокус ходит по вкладкам свободно, а панель меняется только по Enter или Space. APG рекомендует автоматическую, когда все панели уже отрисованы (это привычнее и быстрее), и ручную, когда переключение запускает запрос к серверу или тяжёлый рендер: иначе проход стрелками через пять вкладок отправит пять лишних запросов, а человек услышит пять загрузок. Ручная активация также снимает риск нарушить WCAG 3.2.2 On Input (A), если смена вкладки существенно меняет контекст.

Когда вкладки — неправильный выбор

  • Вкладки ведут на разные URL. Тогда это навигация: <nav> со ссылками и aria-current="page", а не tablist. Пользователь скринридера ждёт от tab мгновенного переключения без перезагрузки; ссылка, притворившаяся вкладкой, обманывает.
  • Вкладки в форме прячут ошибки валидации. Ошибка в поле на третьей вкладке — человек слышит «Проверьте форму» и не понимает, где. Либо переключайте на вкладку с первой ошибкой и ставьте туда фокус, либо не разбивайте форму на вкладки; про сводки ошибок — в статье о доступных формах.
  • Вкладки ломают Ctrl+F и печать: текст в скрытых панелях не находится. Для документации добавьте режим «показать всё» или замените вкладки заголовками.
  • Вкладки как шаги мастера — неверная метафора: у мастера есть порядок и прогресс, у вкладок их нет. Делайте отдельный компонент с индикатором шага.

Автодополнение и комбобокс

Самый дорогой из четырёх компонентов. Разметку и связку атрибутов мы разбирали в статье про ARIA; здесь — жизненный цикл, рабочая реализация и то, что ломается на практике.

Ключевое требование ARIA 1.2. В ARIA 1.1 role="combobox" вешали на обёртку вокруг поля; в ARIA 1.2 роль переехала на сам <input>, а обёртка стала обычным div. Это не косметика: старая разметка в современных NVDA и JAWS читается неправильно — поле теряет роль текстового ввода, режим форм не включается, ввод не проходит. Код с <div role="combobox"><input></div> — это баг, а не легаси-стиль.

<label for="city">Город</label>
<input id="city" type="text" role="combobox" autocomplete="off"
       aria-expanded="false" aria-controls="city-list" aria-autocomplete="list">
<ul id="city-list" role="listbox" aria-label="Варианты городов" hidden></ul>
<div id="city-status" role="status" class="visually-hidden"></div>

Реализация: активная опция без переноса фокуса

type Option = { id: string; label: string };

export function initCombobox(input: HTMLInputElement, list: HTMLUListElement,
                             status: HTMLElement, search: (q: string) => Promise<Option[]>) {
  let options: Option[] = [];
  let active = -1;
  let timer: number | undefined;

  const render = () => {
    list.innerHTML = options.map((o, i) =>
      `<li role="option" id="${o.id}" aria-selected="${i === active}">${o.label}</li>`).join('');
    list.hidden = options.length === 0;
    input.setAttribute('aria-expanded', String(!list.hidden));
    // Активную опцию помечаем по id: DOM-фокус остаётся в поле, иначе сломается ввод текста.
    if (active >= 0) {
      input.setAttribute('aria-activedescendant', options[active].id);
      document.getElementById(options[active].id)?.scrollIntoView({ block: 'nearest' });
    } else input.removeAttribute('aria-activedescendant');
  };

  const move = (delta: number) => {
    if (!options.length) return;
    active = (active + delta + options.length) % options.length;
    render();
  };

  const commit = (i: number) => {
    if (i < 0) return;
    input.value = options[i].label;
    options = []; active = -1; render();
    status.textContent = `Выбрано: ${input.value}`;
  };

  input.addEventListener('input', () => {
    clearTimeout(timer);
    // Дебаунс обязателен: иначе живая область тараторит на каждую букву.
    timer = window.setTimeout(async () => {
      options = await search(input.value); active = -1; render();
      status.textContent = options.length
        ? `Найдено вариантов: ${options.length}. Стрелка вниз для перехода к списку.`
        : 'Ничего не найдено';
    }, 300);
  });

  input.addEventListener('keydown', (e) => {
    switch (e.key) {
      case 'ArrowDown': e.preventDefault(); move(1); break;
      case 'ArrowUp':   e.preventDefault(); move(-1); break;
      case 'Enter':     if (active >= 0) { e.preventDefault(); commit(active); } break;
      // Первое нажатие Escape закрывает список, второе очищает поле — так ждёт пользователь.
      case 'Escape':    if (!list.hidden) { options = []; active = -1; render(); }
                        else input.value = ''; break;
      case 'Tab':       commit(active); break;   // уходя, фиксируем выбранное
    }
  });

  // Клик мышью не должен уводить фокус из поля: гасим mousedown.
  list.addEventListener('mousedown', (e) => e.preventDefault());
  list.addEventListener('click', (e) => {
    const li = (e.target as HTMLElement).closest('li');
    if (li) commit(options.findIndex((o) => o.id === li.id));
  });
}

Разбор неочевидных мест. aria-activedescendant вместо focus() — потому что фокус обязан остаться в поле, иначе ввод текста прекратится; скринридер прочитает опцию, на которую указывает атрибут. mousedown с preventDefault — иначе клик по опции сначала уводит фокус из поля, срабатывает blur, список закрывается и клик приходит в пустоту. Дебаунс 300 мс — иначе живая область объявляет результат после каждой буквы и перебивает эхо набора. autocomplete="off" на поле нужен, чтобы браузерная автоподстановка не накладывалась на вашу; при этом для полей с личными данными атрибут autocomplete со значением вроде address-level2 наоборот обязателен по WCAG 1.3.5 Identify Input Purpose (AA) — это разные вещи и разные поля.

Что ломается на практике

  • Мобильные скринридеры и aria-activedescendant. Поддержка в TalkBack и iOS VoiceOver исторически слабая: человек слышит поле, но не слышит перемещение по вариантам. Выход — на узких экранах открывать список полноэкранным диалогом с настоящим перемещением фокуса либо брать нативный <select>. Проверять надо на реальном устройстве, эмулятор здесь не помогает.
  • Виртуальная клавиатура закрывает список. Ограничивайте высоту, показывайте первые 5–7 вариантов и объявляйте общее число. При асинхронной загрузке ставьте aria-busy="true" на список и объявляйте «Загружаем варианты», если ожидание дольше секунды.
  • Виртуализованный список. Если рендерятся только видимые строки, aria-activedescendant начинает указывать на удалённый узел. Проставляйте aria-setsize="248" aria-posinset="12" вручную, иначе позиция в списке теряется.
  • <datalist> как «дешёвая замена». Автодополнение почти без кода, но стилизовать нельзя, поведение расходится между браузерами, а объявление числа вариантов непредсказуемо. Для простой подсказки годится, для выбора из большого справочника — нет.
  • Комбобокс только для выбора (select-only). Роль остаётся combobox, но элемент делается кнопкоподобным с aria-haspopup="listbox". И всё же спросите себя, чем не подошёл нативный <select>: он работает в любом скринридере, на любом телефоне, при 400% зуме и с голосовым управлением, а стилизовать его в современных браузерах уже можно.

Остальные паттерны: краткая сводка

Компонент Главное требование Частая ошибка
Тултип aria-describedby на контроле; доступен и по фокусу; закрывается Esc (1.4.13) Интерактивное содержимое внутри тултипа — до него не добраться клавиатурой
Тост, уведомление Область role="status" уже в DOM; критичное не прячем по таймеру (2.2.1) Кнопка «Отменить» в тосте, который исчезает через 4 секунды
Аккордеон, карусель <button aria-expanded> внутри заголовка; кнопка паузы для автопрокрутки (2.2.2) aria-expanded на панели вместо кнопки; автопрокрутка без остановки
Таблица с сортировкой aria-sort на <th>, кнопка внутри ячейки, объявление результата Сортировка по клику на весь заголовок без кнопки и без состояния
Слайдер <input type="range"> либо role="slider" с aria-valuenow, aria-valuetext Значение читается как «73» вместо «73 процента» — нет aria-valuetext
Drag and drop Обязательная альтернатива одним указателем и с клавиатуры (2.5.7, AA в WCAG 2.2) Единственный способ изменить порядок — перетаскивание
Датапикер, дерево, treegrid Сначала <input type="date">; кастомные — это grid и roving tabindex с aria-level, aria-setsize Своя сетка дат без PageUp/PageDown; дерево из вложенных div без уровней

Про размеры: WCAG 2.2 добавил 2.5.8 Target Size (Minimum) (AA) — интерактивная цель не меньше 24×24 CSS-пикселей (с исключениями для инлайновых ссылок). Иконки в тулбарах и «крестики» закрытия нарушают этот критерий чаще всего.

Как тестировать компонент

Пять минут руками

Порядок именно такой — каждый шаг ловит свой класс дефектов.

  1. Уберите мышь. Пройдите весь сценарий только с клавиатуры: открыть, переместиться, выбрать, закрыть, вернуться. Если хоть один шаг невозможен — дальше можно не идти.
  2. Смотрите на фокус. Кольцо видно всегда, ни разу не пропало и не оказалось под липкой шапкой или выпадающей панелью (2.4.11).
  3. Включите скринридер — NVDA на Windows (бесплатный, nvaccess.org) или VoiceOver на macOS (Cmd+F5). Слушайте не «читает ли вообще», а три вещи: правильно ли назван компонент, называется ли его тип, объявляется ли смена состояния.
  4. Поставьте зум 400% и ширину окна 320 CSS-пикселей (1.4.10 Reflow, AA): выпадающие панели и модалки обрезаются чаще всего.
  5. Проверьте мышь после клавиатуры — что новые обработчики не сломали привычное: клик по опции работает, клик снаружи закрывает, выделение текста в поле не пропало.

Автоматизация

Автотесты не заменяют ручную проверку (инструменты ловят порядка трети дефектов), но держат оборону от регрессий. Про процесс подробно — в статье про тестирование; здесь то, что специфично для компонентов. Главное правило: ищите элементы по роли и имени, а не по CSS-классу — такой тест ломается ровно тогда, когда ломается доступность, то есть работает как проверка.

test('диалог: фокус входит внутрь и возвращается на триггер', async () => {
  const user = userEvent.setup();
  const { container } = render(<DeleteDraftDialog />);

  const trigger = screen.getByRole('button', { name: 'Удалить черновик' });
  await user.click(trigger);

  // Имя диалога приходит из заголовка — проверяем именно по имени.
  const dialog = await screen.findByRole('dialog', { name: 'Удалить черновик?' });
  expect(screen.getByRole('button', { name: 'Отмена' })).toHaveFocus();

  await user.keyboard('{Escape}');
  expect(dialog).not.toBeInTheDocument();
  expect(trigger).toHaveFocus();                       // главная проверка всего теста
  expect(await axe(container)).toHaveNoViolations();   // структурные нарушения ARIA
});

Второй приём — снимок дерева доступности: Playwright сравнивает структуру ролей и имён целиком, что удобнее десятка точечных ассертов.

await page.getByRole('button', { name: 'Действия' }).click();
await expect(page.getByRole('menu')).toMatchAriaSnapshot(`
  - menu "Действия":
    - menuitem "Переименовать"
    - menuitem "Дублировать"
    - menuitem "Удалить"
`);

Покрывать тестами стоит: возврат фокуса после закрытия, значения aria-expanded и aria-selected до и после действия, работу Esc, отсутствие фокусируемых элементов внутри скрытых панелей, доступные имена иконочных кнопок. Автотест при этом не проверит осмысленность имени («кнопка 3» проходит любой линтер), уместность assertive, формулировки объявлений и реальное поведение TalkBack.

Типичные ошибки — сводка

Ошибка Что чувствует пользователь Как чинить
Модалка на div с z-index внутри контейнера с overflow: hidden окно обрезано или невидимо <dialog> + showModal(), top layer
Фон не изолирован, фокус не вернулся после закрытия Tab уходит под затемнение; после закрытия человек в начале страницы showModal() или inert; запомнить триггер и предусмотреть его исчезновение
role="menu" на навигации сайта ссылки исчезли из списка ссылок, включился режим форм <nav> + список ссылок + aria-expanded на кнопке
Роль без клавиатуры (role="tab" без стрелок) компонент представился и не выполняет контракт реализовать паттерн APG целиком или снять роль
Скрытые панели через opacity: 0 скринридер читает все вкладки сразу, Tab уходит в невидимое hidden или display: none
aria-activedescendant на несуществующий id активная опция не объявляется вовсе синхронизировать с рендером, учитывать виртуализацию
Живая область без дебаунса или с целым списком речь тараторит, каждое нажатие зачитывает 30 строк 300–500 мс и одно короткое сообщение про количество
Иконочная кнопка без имени «кнопка», «кнопка», «кнопка» aria-label или визуально скрытый текст
Выпадающая панель поверх фокуса не видно, где ты находишься 2.4.11: проверить Tab при открытых слоях
Перетаскивание как единственный способ действие недоступно без точной моторики кнопки «вверх/вниз», меню «переместить в…» (2.5.7)
Тост с действием исчезает по таймеру до кнопки невозможно добраться не скрывать автоматически либо продублировать действие в интерфейсе

Мини-итог

  • Компоненты ломаются по трём причинам: состояние есть только в пикселях, фокусом никто не управляет, объявленная роль не подкреплена клавиатурой. Проверяйте виджет по шести слоям: роль, имя, состояние, фокус, клавиатура, объявления.
  • Модалка: <dialog> + showModal() решает top layer, инертность фона, Esc и возврат фокуса. Ваша забота — имя диалога, безопасный autofocus, блокировка прокрутки, анимация и случай исчезнувшего триггера.
  • Меню: 90% «меню» на сайтах — это навигация, и делается она списком ссылок с aria-expanded. role="menu" берите только для меню действий и реализуйте весь контракт клавиш.
  • Табы: один табстоп, стрелки, hidden на неактивных панелях, ручная активация при дорогом переключении. Вкладки, ведущие на разные URL, — это <nav>, а не tablist.
  • Автодополнение: role="combobox" на самом <input> (ARIA 1.2), активная опция через aria-activedescendant, дебаунс объявлений, отдельная проверка на мобильных.
  • Дорогие компоненты берите из библиотек с заявленной поддержкой доступности — и всё равно прогоняйте руками: пять минут с клавиатурой и NVDA находят больше, чем любой линтер.

Источники

Что дальше

Тестирование и процесс: автопроверки, ручной аудит, доступность в команде — как встроить всё это в рабочий процесс: какие проверки автоматизировать, как проводить ручной аудит, как договориться с командой, чтобы доступность не откатывалась назад с каждым релизом.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов