Доступные компоненты: модалки, меню, табы, автодополнение
Простую страницу сломать трудно: заголовки, абзацы, ссылки и поля формы работают сами — браузер выдаёт им роль, имя и клавиатуру, а вы сверху пишете стили. Проблемы начинаются там, где появляется состояние: что-то открылось, что-то выбралось, что-то подгрузилось, фокус куда-то уехал. Модалка, меню, вкладки и автодополнение — четыре компонента, где состояния больше всего, и именно они чаще всего оказываются полностью неработающими для человека с клавиатурой или скринридером. Причина не в лени: визуальный слой выражает состояние бесплатно — подсветил вкладку, и зрячий человек всё понял. Для остальных каналов состояние надо объявлять явно, а фокус переносить руками; ни один 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> — ноль работы.
с нужным поведением?"} B -- "да" --> C["button, a, details, select, input,
dialog, input type=range:
роль, фокус и клавиатура бесплатно"] B -- "нет" --> D{"Есть ли паттерн
в WAI-ARIA APG?"} D -- "нет" --> E["Опасная зона: своя семантика.
Упростите до известного паттерна"] D -- "да" --> F{"Сколько состояний
и клавиш в паттерне?"} F -- "мало: disclosure, аккордеон,
переключатель, табы" --> G["Пишите сами по APG,
проверяйте клавиатурой и NVDA"] F -- "много: комбобокс, дерево,
меню, grid, датапикер" --> H{"Есть ресурс тестировать
на NVDA, JAWS, VoiceOver?"} H -- "нет" --> I["Берите библиотеку с заявленной
поддержкой a11y и всё равно проверьте руками"] H -- "да" --> G C --> Z["Прогон: Tab, Shift+Tab, стрелки,
Enter, Space, Esc, Home, End, зум 400%"] G --> Z I --> Z
Грубый ориентир по трудоёмкости: disclosure, аккордеон и кнопка-переключатель — час работы; табы и тултип — день; модальный диалог и меню — несколько дней с тестированием; автодополнение, датапикер, дерево и drag-and-drop — недели и постоянная поддержка. Риск при этом распределён иначе: сломанный диалог или перетаскивание блокируют сценарий целиком, а неидеальная карусель просто раздражает.
Библиотеки, у которых доступность заявлена явно и подтверждается практикой: React Aria (Adobe, реализует APG и тестирует на реальных AT), Radix Primitives, Ark UI и Zag.js (стейт-машины отдельно от фреймворка), Downshift для комбобоксов, Headless UI. Это не индульгенция: библиотека даёт корректный каркас, но имя, порядок в DOM, контраст и содержимое объявлений — всё равно ваши. Регрессии в чужом коде тоже случаются, поэтому ручной прогон обязателен в любом случае.
Модальный диалог
Диалог — самый частый компонент и самый разрушительный при поломке: он перекрывает весь интерфейс. Механику фокуса и 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" и оставляйте фокусируемым, иначе человек не узнает о существовании действия.
Табы
Вкладки — простой паттерн с одной серьёзной развилкой и несколькими ловушками дизайна.
<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-пикселей (с исключениями для инлайновых ссылок). Иконки в тулбарах и «крестики» закрытия нарушают этот критерий чаще всего.
Как тестировать компонент
Пять минут руками
Порядок именно такой — каждый шаг ловит свой класс дефектов.
- Уберите мышь. Пройдите весь сценарий только с клавиатуры: открыть, переместиться, выбрать, закрыть, вернуться. Если хоть один шаг невозможен — дальше можно не идти.
- Смотрите на фокус. Кольцо видно всегда, ни разу не пропало и не оказалось под липкой шапкой или выпадающей панелью (2.4.11).
- Включите скринридер — NVDA на Windows (бесплатный, nvaccess.org) или VoiceOver на macOS (Cmd+F5). Слушайте не «читает ли вообще», а три вещи: правильно ли назван компонент, называется ли его тип, объявляется ли смена состояния.
- Поставьте зум 400% и ширину окна 320 CSS-пикселей (1.4.10 Reflow, AA): выпадающие панели и модалки обрезаются чаще всего.
- Проверьте мышь после клавиатуры — что новые обработчики не сломали привычное: клик по опции работает, клик снаружи закрывает, выделение текста в поле не пропало.
Автоматизация
Автотесты не заменяют ручную проверку (инструменты ловят порядка трети дефектов), но держат оборону от регрессий. Про процесс подробно — в статье про тестирование; здесь то, что специфично для компонентов. Главное правило: ищите элементы по роли и имени, а не по 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 находят больше, чем любой линтер.
Источники
- WAI-ARIA APG — Patterns: эталонные описания с рабочими примерами, в том числе Dialog (Modal), Menu Button, Tabs и Combobox.
- WAI-ARIA 1.2 — нормативный список ролей, свойств и состояний.
- WCAG 2.2 и Understanding WCAG 2.2 — критерии 2.4.11, 2.5.7, 2.5.8, 1.4.13, 4.1.2, 4.1.3.
- MDN:
<dialog>, Popover API, ARIA live regions. - Inclusive Components Хейдона Пикеринга и разборы Adrian Roselli — что ломается в реальных браузерах и AT.
- Playwright: aria snapshots, Testing Library: queries by role.
Что дальше
Тестирование и процесс: автопроверки, ручной аудит, доступность в команде — как встроить всё это в рабочий процесс: какие проверки автоматизировать, как проводить ручной аудит, как договориться с командой, чтобы доступность не откатывалась назад с каждым релизом.