Семантика и роли: дерево доступности не видит ваши div
Кликабельный div не имеет роли, имени и клавиатуры — assistive tech не может им управлять. Нативные элементы дают контракт целиком; лестница accname (labelledby сильнее label, label сильнее контента) решает озвучиваемое имя; ориентиры и заголовки — поверхность навигации.
Претензионное письмо пришло во вторник: незрячий клиент попытался купить подарочную карту с NVDA и не смог, а юридическая фирма, отправившая письмо, ведёт сотни дел о веб-доступности в год. Аудит на устранение нарушений обошёлся дороже, чем исходная разработка фронтенда. Первая находка заняла у аудитора девяносто секунд: на странице товара было четырнадцать интерактивных элементов, и Tab доставал до двух из них. Всё остальное — div с onClick: стилизован как кнопка, ведёт себя как кнопка для мыши и отсутствует в дереве доступности как элемент управления. Нет роли — скринридер объявлял его как нечто неопределённое; нет доступного имени — даже виртуальный курсор читал лишь обрывки текста; не фокусируется — клавиатура туда просто не доходила; нет обработки клавиш — Enter всё равно ничего бы не сделал. Команда не была небрежной — она строила на компонентной библиотеке, где примитив Button был div-ом, копируемым вперёд с 2019 года, и одно неверное решение о выборе элемента дало метастазы во все воронки. Автоматический обзор WebAIM по миллиону главных страниц год за годом находит детектируемые нарушения WCAG примерно на 96% из них, и возглавляют этот каталог ровно такие вещи: отсутствующие подписи форм, пустые кнопки, отсутствующий alt. Чтобы починить большую часть каталога, не нужно учить ARIA. Нужно использовать элементы, которые платформа уже поставляет.
Что даёт кнопка и никогда не даст div
Когда React коммитит <button>Save</button>, браузер выводит из DOM второе дерево — дерево доступности, то самое, которое потребляют скринридеры, голосовое управление и свитч-устройства. Узел кнопки приходит в него полностью укомплектованным: неявная роль button, доступное имя, вычисленное из содержимого («Save»), членство в последовательном порядке фокуса, активация и по Enter, и по Space, корректное объявление («Save, кнопка») и видимый индикатор фокуса. div с onClick приходит безымянным узлом generic — присутствует как геометрия текста, невидим как элемент управления. Пользователь голосового управления не может сказать ему «нажми Save»; свитч-пользователь до него не доберётся; клавиатурный пользователь протабает мимо.
Восстановить паритет вручную — это пять обязательств в каждой точке вызова: role="button", tabIndex равный 0, обработчик onKeyDown, срабатывающий и на Enter, и на Space (и предотвращающий прокрутку страницы по Space), семантика недоступности через aria-disabled плюс реальная блокировка обработчика, и стиль фокуса. Каждое по отдельности легко забыть, ни одно не даёт видимого бага для мыши, и отказ молчит до самого аудита. Это и есть первое правило ARIA от самих авторов спецификации: если существует нативный элемент с нужной семантикой — используйте его, а не перепрофилируйте другой элемент, прикручивая ARIA. Следствие сформулировано ещё жёстче и так же официально: отсутствие ARIA лучше плохого ARIA — role="button" без обработки клавиатуры — это обещание assistive tech, которое элемент тут же нарушает, и это хуже, чем остаться скромным div-ом.
Реакт-специфичный угол: JSX не добавляет ни грамма семантики. React рендерит ровно те элементы, что вы написали, а компоненты прячут выбор элемента — примитив Button, рендерящий div, незаметно доставляет это решение каждому потребителю. Выбор элемента — это решение уровня API компонентной библиотеки, а не постраничная деталь стилизации, и это самое дешёвое решение о доступности из всех возможных: платформа уже реализовала и оттестировала поведение на всех скринридерах.
В код-ревью предлагают починить кликабельный div, добавив role='button' и tabIndex={0}. Автор утверждает, что теперь он эквивалентен нативной кнопке. Чего всё ещё не хватает?
Доступное имя вычисляется, а не угадывается
«Как скринридер назовёт эту штуку?» — у вопроса есть детерминированный ответ: вычисление доступного имени и описания, алгоритм W3C, который для каждого элемента идёт по строгой лестнице приоритетов. Знание лестницы превращает баги имён из фольклора в арифметику. Для данного элемента: побеждает aria-labelledby, если он присутствует и непуст, — он собирает текст элементов по ссылкам, в порядке id, даже из скрытых узлов. Иначе побеждает aria-label — плоская строка, которая полностью заменяет содержимое. Иначе вступает host language: связанный элемент label для полей формы, alt для изображений. Иначе — для ролей, допускающих имя из содержимого (button, link, heading — но не div и не generic), именем становится текст поддерева. Последняя инстанция: title, а для полей ввода placeholder — оба слабые, оба фактически lint-предупреждение.
Три ловушки, которые объясняет лестница. Первая: aria-label на голом div не делает ничего надёжного — generic-роли не несут имени, и AT может полностью игнорировать атрибут; это не волшебная подпись, а вход алгоритма, который сверяется с ролью. Вторая: поскольку aria-label заменяет содержимое, кнопка-иконка с видимым текстом «Поиск» и aria-label="лупа" объявляется как «лупа» — и пользователь голосового управления, говорящий «нажми Поиск», промахивается; именно поэтому WCAG требует, чтобы видимая подпись входила в доступное имя. Третья: aria-labelledby сильнее всего и при этом со странностями — он конкатенирует несколько id, включает собственный элемент при самоссылке и читает цели с display:none; это мощно для составных имён («Удалить» плюс название строки), но протухшая ссылка на id молча резолвится в пустую строку, алгоритм проваливается на следующую ступень — и объявление меняется без единого предупреждения в консоли.
Кнопка отрендерена так: видимый текст «Export», aria-label='Download CSV' и aria-labelledby='panel-title', где panel-title — заголовок с текстом «Quarterly report». Что объявит скринридер?
Ориентиры и заголовки — это то, как люди ориентируются
Пользователи скринридеров не читают страницы сверху вниз — они прыгают. В опросах пользователей скринридеров от WebAIM навигация по заголовкам стабильно остаётся доминирующей стратегией поиска контента на странице, а ориентиры (landmarks) — её структурное дополнение. Значит, иерархия заголовков и карта ориентиров — первичная навигационная поверхность, равная по рангу видимой навигационной панели. Нативные секционные элементы несут роли ориентиров неявно: header — banner, nav — navigation, main — main, footer — contentinfo. Правила поверхности: ровно один main; повторяющиеся ориентиры подписывайте (aria-label="Основная" на одной nav, «Хлебные крошки» на другой — здесь aria-label легитимен, потому что nav — именуемая роль); заголовки образуют настоящую иерархию — один h1, без пропуска уровней, уровень выбирается структурой, а не размером шрифта. Данные WebAIM Million показывают, насколько редка эта дисциплина в дикой природе: заметная доля главных страниц вообще не имеет ориентиров или имеет сломанный порядок заголовков, — а значит, хорошо структурированное приложение — это реальное конкурентное преимущество доступности, а не гигиенический минимум.
React добавляет две структурные привычки. Фрагменты существуют, чтобы «нужен один JSX-корень» никогда не стоил обёрточного div: каждый лишний div — ещё один узел generic, раздувающий дерево доступности, а внутри семантических структур он разрушителен — компонент, оборачивающий свой li в div, рвёт обязательную цепочку ul-к-li, и часть AT перестаёт объявлять «список, 5 элементов». Используйте Fragment (или фрагменты с key в списках) и сохраняйте семантические цепочки родитель-ребёнок через границы компонентов — DOM не знает о существовании вашего дерева компонентов, только о его выводе. Вторая привычка: dangerouslySetInnerHTML лишает всех структурных гарантий — вставленная разметка обходит JSX, так что eslint-plugin-jsx-a11y её не видит, уровни заголовков внутри HTML из CMS прыгают как попало, а картинки приходят без alt. Если вставлять необходимо (отрендеренный markdown, контент из CMS) — санитизируйте и отдельно аудируйте вывод по тем же правилам, потому что ваш инструментарий в этом поддереве только что ослеп.
▸Почему это работает
Почему ARIA меняет только заявления и никогда не добавляет поведение? Потому что он спроектирован как слой описания, надетый задним числом на произвольную разметку: язык контракта, на котором любой элемент может объявить дереву доступности «я кнопка». Если бы браузер ещё и прикреплял поведение кнопки ко всему, что заявляет роль, это изменило бы семантику исполнения существующих страниц и породило бы циклические ожидания (role=‘checkbox’ теперь сам себя переключает?). Поэтому разделение труда строгое: нативные элементы HTML связывают заявление с поведением, оттестированным во всех браузерах и скринридерах за два десятилетия; ARIA даёт только заявление и делает поведение вашей проблемой. Эта асимметрия — весь экономический аргумент первого правила ARIA: с нативным элементом дорогая половина достаётся бесплатно.
- 01Пройдите вычисление доступного имени для кнопки с видимым текстом, aria-label и aria-labelledby — и назовите три ловушки, которые объясняет лестница.
- 02Сформулируйте первое правило ARIA, что именно теряет div+onClick по сравнению с нативной кнопкой, и две реакт-специфичные структурные привычки.
Браузер выводит дерево доступности из DOM, и это дерево несёт только то, что туда положила семантика. Нативная кнопка входит в него с ролью, вычисленным именем, членством в порядке фокуса, активацией по Enter и Space и семантикой disabled — кликабельный div входит безымянным generic-узлом, которым скринридеры, голосовое управление и свитч-доступ управлять не могут. Воссоздание контракта вручную — пять обязательств в каждой точке вызова, отказывающих молча для мыши; поэтому первое правило ARIA велит брать нативный элемент, когда он существует, и поэтому заявление роли без её поведения хуже, чем отсутствие заявления. Доступные имена вычисляются строгой лестницей для каждого элемента — aria-labelledby, затем aria-label, затем механизмы host language, затем контент для именуемых ролей, затем title — побеждает первая непустая ступень, что делает баги имён детерминированными: подписи на generic не работают, подписи, замещающие видимый текст, ломают голосовое управление, протухшие id в labelledby молча проваливаются на следующую ступень. Ориентиры и иерархия заголовков — реальная навигационная поверхность страницы для пользователей скринридеров: прыжки по заголовкам доминируют в опросах WebAIM, а WebAIM Million показывает около 96% главных страниц с автоматически детектируемыми нарушениями WCAG во главе с отсутствующими подписями и пустыми кнопками. Роль React во всём этом структурная: JSX не добавляет семантики, примитивы компонентов прячут выбор элемента и тиражируют его в масштабе, фрагменты удерживают обёрточные div от разрыва семантических цепочек, а dangerouslySetInnerHTML выводит поддерево из-под всех гарантий вашего линта и вашего JSX. Теперь, когда встретишь компонентную библиотеку или код-ревью, где интерактивный контрол предлагается реализовать через div, ты точно знаешь, чего не хватает, и умеешь пройти лестницу вычисления имени шаг за шагом.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.