Композитные ARIA-виджеты: паттерны APG — клавиатурные контракты
Композитный виджет — один Tab-стоп; стрелки ходят внутри: roving tabindex (настоящий фокус, танец 0/-1) или aria-activedescendant (DOM-фокус остаётся на input). ARIA-состояние выводится из того же состояния, что и пиксели, иначе виджет лжёт AT. Combobox-уровень — берите headless.
Редизайн диспетчерской консоли уехал в пятницу; на спринт-ревью через три недели был один некрасивый слайд: среднее время обработки вызова выросло на 9%. Виновником оказался дропдаун. Старый экран использовал нативный select для назначения перевозчика — операция, которую агент выполняет около двухсот раз за смену, почти всегда с клавиатуры: Tab внутрь, набрать «ma», чтобы прыгнуть на Maersk, Enter, Tab дальше. Редизайн заменил его самодельным стилизованным дропдауном, и клавиатурный контракт тихо умер. Каждая из пятидесяти опций получила tabIndex равный 0, так что пересечь виджет стоило пятьдесят нажатий Tab; стрелки не делали ничего; type-ahead не существовал. Агенту с JAWS пришлось хуже: на триггере был захардкожен aria-expanded="false" строковым литералом, так что дропдаун визуально открывался, объявляя «свёрнуто», — виджет лгал, и она научилась считать клики вслепую. Никто в команде не тестировал с клавиатуры: путь мыши работал, визуальный дифф был одобрен. Починка заняла два спринта, и большая их часть ушла на открытие, что дропдаун — не стилизованный div, а протокол: WAI-ARIA Authoring Practices определяет, какая клавиша что делает в listbox, и у пятидесяти тысяч пользователей скринридеров эти нажатия — в мышечной памяти. Сломайте протокол — и вы построили не кастомный виджет, а ловушку, которая на него похожа.
Паттерн — это карта клавиш, а не внешний вид
WAI-ARIA Authoring Practices Guide (APG) читается как книга рецептов, но на самом деле определяет набор контрактов: для каждого паттерна виджета — tabs, listbox, combobox, dialog, menu — точные роли, обязательные атрибуты состояния и точную карту клавиш. Tabs: tablist, содержащий элементы tab, aria-selected на активном, стрелки влево/вправо для перемещения, Home/End для прыжка. Listbox: listbox с элементами option, aria-selected, вверх/вниз, type-ahead. Combobox: input с role="combobox", aria-expanded, всплывающий listbox, Down открывает и ходит по списку, Escape закрывает. Контракт существует потому, что пользователи assistive technology выучивают паттерн один раз и воспроизводят его везде: когда скринридер объявляет «список вкладок», пользователь уже знает, что стрелки будут перемещать между вкладками. Реализуйте внешний вид без карты клавиш — и объявление даёт обещание, которое ваш виджет нарушает: это хуже, чем отсутствие паттерна, потому что обращает тренировку пользователя против него самого.
Структурное правило в основании каждого композитного паттерна: один Tab-стоп на виджет. Композит — список вкладок, listbox, грид, меню — это одна сущность в Tab-последовательности страницы. Tab входит в него, Tab выходит из него, стрелки ходят внутри. Диспетчерская консоль сломала ровно это: пятьдесят опций с tabIndex={0} превратили один виджет в пятьдесят Tab-стопов, и клавиатурная топология страницы стала болотом. Это правило — ещё и причина, по которой композиты вообще требуют инженерии: по умолчанию HTML вставляет каждый фокусируемый элемент в Tab-порядок, и чтобы пятьдесят сущностей вели себя как одна, нужно активно управлять тем, какой элемент фокусируем в каждый момент. Санкционированных механизмов ровно два.
В код-ревью кастомного компонента вкладок видно, что каждая кнопка вкладки отрендерена с tabIndex={0}. Мышь и визуальное поведение безупречны. Каково следствие для клавиатурного пользователя по контракту tabs из APG?
Roving tabindex против aria-activedescendant
Roving tabindex держит настоящий DOM-фокус на активном элементе и двигает его стрелками. Ровно один элемент композита имеет tabIndex={0} (активный); все остальные — tabIndex={-1}. Поэтому Tab входит в композит на активном элементе. По ArrowRight: обновить состояние React, ререндер меняет пару tabindex, и вызвать focus() на DOM-узле нового активного элемента — нужен ref на каждый элемент, и вызов фокуса должен случиться после коммита (территория flushSync из прошлого урока, если ведёте от состояния). Награда: это настоящий фокус — стилизация через :focus-visible просто работает, document.activeElement говорит правду, скринридеры отслеживают его нативно.
function Tabs({ tabs, active, onSelect }) {
const refs = useRef(new Map());
const onKeyDown = (e) => {
const delta = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!delta) return;
const next = (active + delta + tabs.length) % tabs.length;
onSelect(next);
refs.current.get(next)?.focus(); // настоящий фокус следует за состоянием
};
return (
<div role="tablist" onKeyDown={onKeyDown}>
{tabs.map((t, i) => (
<button
key={t.id}
role="tab"
aria-selected={i === active}
tabIndex={i === active ? 0 : -1}
ref={(el) => refs.current.set(i, el)}
>
{t.label}
</button>
))}
</div>
);
}aria-activedescendant переворачивает модель: DOM-фокус не двигается никогда. Он остаётся на контейнере (для combobox — на текстовом поле), а контейнер несёт aria-activedescendant={activeOptionId}, указывающий на визуально подсвеченный элемент. Скринридер объявляет элемент по ссылке так, будто тот в фокусе, — виртуальный фокус. Всё, что фокус давал бесплатно, вы теперь делаете сами: стилизацию подсветки (нет :focus, за который можно зацепиться), прокрутку активной опции в видимую область и стабильные уникальные id на каждой опции. Решающий случай — combobox: пользователь печатает, поэтому DOM-фокус обязан оставаться в поле ввода, пока стрелки ходят по списку подсказок, — перенос настоящего фокуса оборвал бы сессию набора. Roving tabindex физически не умеет выразить «фокус здесь, подсветка там»; activedescendant существует ровно для этого расщепления.
Таблица компромиссов: roving tabindex даёт настоящие события фокуса, бесплатную стилизацию фокуса и платформенную прокрутку при фокусировке — ценой ререндера плюс вызова focus() на каждое нажатие стрелки и сантехники с ref. Activedescendant даёт неподвижный фокус (обязательно для полей с набором текста, дешевле на нажатие — одна смена атрибута) — ценой самодельной подсветки, самодельной прокрутки, дисциплины id и исторически менее однородной поддержки в AT, почему сам APG применяет его главным образом там, где он структурно необходим. По умолчанию — roving tabindex для tabs, меню, радиогрупп, тулбаров; activedescendant — когда фокус должен стоять на месте: combobox, поиск с подсказками, командная палитра.
ARIA не должна лгать, и когда покупать вместо строить
Каждый атрибут состояния в контракте — aria-expanded, aria-selected, aria-activedescendant, aria-checked — обязан выводиться из того же состояния React, что управляет пикселями. Строковый литерал aria-expanded="false" диспетчерской консоли — каноническая ложь: рендер попапа зависел от open, атрибут — нет, и две истины разошлись на первом же клике. В JSX исправление механическое — aria-expanded={open} (React сериализует булево в строку, которую ждёт AT), — но дисциплина архитектурная: одна переменная состояния, две проекции (пиксели и ARIA), ноль синхронизируемых вручную копий. Виджет, у которого визуальное состояние расходится с объявленным, хуже неподписанного div, потому что пользователи AT действуют по объявлению: «свёрнуто» означает «нажми Enter, чтобы открыть», а Enter на уже открытом combobox его закрывает — пользователь и виджет теперь расходятся в картине реальности, и каждое следующее нажатие усугубляет ошибку.
Ваша команда строит поиск-селект: пользователь печатает в поле, фильтруя опции, и ходит по подсказкам стрелками вверх/вниз, не прерывая набор. Какой механизм фокуса навязывает контракт и почему?
Итак: строить или покупать? Честная калькуляция combobox, построенного по контракту APG: полная карта клавиш (type-ahead по печатным символам, Down/Up/Home/End/Escape/Enter, вариации Alt+Down), проводка activedescendant, управление прокруткой, дисциплина id, восстановление фокуса при закрытии — и затем статья, которую никто не закладывает в бюджет: верификация по матрице AT (NVDA и JAWS на Windows, VoiceOver на macOS и iOS, TalkBack на Android, каждый на двух-трёх браузерах). Это недели работы специалиста на один виджет, и это объясняет существование headless-библиотек компонентов: Radix UI, React Aria, Headless UI поставляют контракт — роли, атрибуты состояния, карты клавиш, управление фокусом — с нулём визуальных мнений, оставляя стилизацию вам. Сеньорское решение асимметрично: берите библиотеку для combobox, select, menu, dialog и date picker — паттернов, где контракт глубок и матрица AT беспощадна; стройте руками мелкие — tabs разумен как первая самостоятельная сборка, disclosure тем более, — когда нужно владеть паттерном или цена зависимости неоправданна. Честные издержки покупки: зависимость, которую надо обновлять, мнения библиотеки о композиции в вашем API компонентов и вес бандла; честная издержка стройки: весь контракт выше, навсегда, включая каждую регрессию связки браузер-AT, вышедшую после вас.
▸Почему это работает
Почему платформа делает композитные виджеты настолько трудными — почему нет нативного элемента tablist, как есть нативный select? Отчасти история: ARIA специфицировали, чтобы натянуть идиомы десктопных приложений (списки вкладок, деревья, строки меню) на документную платформу, у которой их никогда не было, — поэтому контракты живут в слое спецификации, а не в элементах. Отчасти экономика: даже паттерны, получившие нативные элементы (select, dialog, details), годами не были достаточно стилизуемыми, чтобы команды перестали их перестраивать, — настоящие крючки стилизации у select появились недавно. Честное прочтение для продуктовых команд: дыра платформы в композитных виджетах закрывается медленно, элемент за элементом, и пока она не закрыта, APG плюс headless-библиотека — это контрактно-совместимый заменитель нативных элементов, которых ещё не существует.
- 01Сопоставьте roving tabindex и aria-activedescendant: механизм, цены и случай, где выбор навязан.
- 02Почему виджет, чьё ARIA-состояние расходится с визуальным, хуже отсутствия ARIA, и какое архитектурное правило это предотвращает?
Паттерн APG — клавиатурный контракт, а не визуальный рецепт: роли объявляют паттерн, атрибуты состояния несут его истину, а карта клавиш — то, что у пятидесяти тысяч пользователей assistive technology уже в мышечной памяти, — реализовать внешний вид без клавиш значит превратить объявление в нарушенное обещание. Композиты подчиняются одному структурному правилу: один Tab-стоп на виджет, стрелки внутри. Его реализуют два механизма. Roving tabindex держит настоящий DOM-фокус на активном элементе — ровно один tabIndex ноль, остальные минус один, стрелки меняют пару и зовут focus() после коммита — покупая настоящие события фокуса и бесплатную стилизацию ценой ref-сантехники и фокус-вызовов на каждое нажатие. Aria-activedescendant держит DOM-фокус неподвижным на контейнере и направляет виртуальный фокус на id подсвеченной опции — обязательно там, где набор текста должен продолжаться (combobox, командная палитра), ценой самодельной подсветки, прокрутки и дисциплины id. Атрибуты состояния обязаны быть проекциями того же состояния React, что рисует пиксели — aria-expanded={open}, никогда захардкоженная строка, — потому что виджет, который рендерится открытым, объявляя «свёрнуто», заводит пользователя в нарастающее расхождение с реальностью. А линию строить-или-покупать проводит глубина контракта: паттерны уровня combobox несут недели работы над картой клавиш и матрицей AT — ровно то, что headless-библиотеки (Radix, React Aria, Headless UI) продают решённым с нулём визуальных мнений; мелкие паттерны вроде tabs — легитимная ручная сборка. Диспетчерская консоль заплатила 9% времени обработки за повторение этого урока: виджет — это его протокол, а протокол тестируется только с клавиатуры. Теперь, когда встретишь кастомный дропдаун или список вкладок на ревью, первый вопрос будет не «выглядит ли правильно?», а «Tab входит один раз, стрелки ходят внутри, и каждый атрибут состояния выводится из той же переменной, что рендерит пиксели?»
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.