Задача выглядит как аудит, а барьеры живут в поведении
В бэклоге задача «сделать сайт доступным» выглядит как разовая работа с конечным списком пунктов: есть инструменты, которые подсвечивают проблемы, есть чек-листы — прогнать, поправить подсвеченное, закрыть.
Ощущение обманчиво, и причина — в устройстве самих проверщиков. Автоматический инструмент читает страницу как застывшее дерево: есть ли у картинки замещающий текст, подписано ли поле, достаточен ли контраст. Это свойства разметки, и машина их действительно видит. Но барьер почти никогда не сидит в разметке сам по себе — он живёт в поведении во времени. Фокус, потерявшийся после закрытия диалога. Список, который не листается стрелками. Обновление контента, о котором скринридер молчит, потому что никто не сообщил ему, что обновление произошло. Чтобы это поймать, нужно нажать Tab и проследить, что будет дальше, — проверщик не нажимает Tab.
Галочка покрывает свойства, а недоступность живёт в поведении. Это та же история, что с производительностью: одним замером в конце проекта её не внедрить — она либо встроена в процесс, либо её нет.
Тупик: аудит, оверлей и ARIA как краска
Первое, что пробуют, обычно одно из двух.
Дорога первая — прогнать автоматический аудит и закрыть всё подсвеченное. Инструмент честно находит картинки без alt, бледный текст, поля без подписей; пункты закрываются, отчёт зеленеет. Дорога вторая, соседняя — подключить сторонний оверлей: скрипт, который на лету обходит DOM, докидывает атрибуты и рисует панель настроек для пользователя. Обе дороги ведут в одно и то же место, и вот почему.
Механизм первый: обе лечат снимок, а не поведение. Проверщик и оверлей работают с деревом разметки — докинуть подпись, поменять роль. Научить div реагировать на Enter, возвращать фокус при закрытии окна, листать список стрелками они не могут: это код, которого нет, и атрибутом его не добавить. Типичный артефакт такой попытки:
<div role="button" aria-label="Оформить заказ">Оформить</div>
Для проверщика это кнопка: роль есть, подпись есть. Для клавиатуры — прямоугольник: не дотабиться, Enter и Space не работают. ARIA — декларация без реализации: вы обещаете «здесь кнопка», браузер верит объявлению, пользователь получает ложь.
Механизм второй: оверлей работает после вашего кода и каждый раз опаздывает. В приложении, где DOM перерисовывается, он гоняется за меняющейся страницей и догоняет её с разрывом. Аудит же — снимок на дату: приезжает новый виджет, фокус снова ломается, и никто не замечает, потому что в конвейере нет проверки. Доступность гниёт так же, как производительность: без защиты в пайплайне она возвращается.
Механизм третий, самый неприятный: зелёный отчёт, которому нельзя верить, хуже честного красного. Красный хотя бы говорит «проверь руками». Зелёный снимает с команды необходимость думать — а думать здесь обязательно.
Выход из тупика — сменить единицу работы. Не «страница, прошедшая аудит», а «компонент, выполняющий контракт».
Шаг 1. Пройти путь без мыши
Самый дешёвый инструмент доступности — клавиатура, она есть у всех. Уберите мышь и пройдите главный пользовательский сценарий: поиск → карточка → корзина → оформление. Только Tab, Shift+Tab, Enter, Space, Escape и стрелки.
На каждом интерактивном элементе проверяйте достижимость, управляемость и видимость фокуса: могу ли я сюда попасть, могу ли я этим управлять, вижу ли я, где нахожусь. Это вся методика.
Находки раскладываются на те же типы, и каждый тип ведёт в свой слой кода. Недостижимо — интерактив висит на неинтерактивном элементе, лечится нативной семантикой (шаг 2). Застряли или потерялись — сломан контракт фокуса, тоже шаг 2. Всё работает, но непонятно, где вы, — фокус невидим, лечится CSS (шаг 3).
Условие применимости: за одну сессию — один пользовательский сценарий. «Пройти весь сайт» — способ бросить упражнение на середине. Внутренние экраны, куда ходят только свои, подождут.
Шаг 2. Нативные контракты, ARIA — по остаточному принципу
Браузер — готовая библиотека контрактов взаимодействия. Нативная кнопка бесплатно даёт место в порядке табуляции, реакцию на Enter и Space, состояние disabled и роль для скринридера. Div не даёт ничего. Отсюда порядок решений: есть нативный элемент под задачу — берём его (button, a, input, select, details, dialog); дизайн требует другого вида — стилизуем нативный элемент, а не подменяем его div-ом; нативного эквивалента нет — табы, combobox, дерево — берём устоявшийся ARIA-паттерн и реализуем целиком: роли, клавиатурная модель, состояния, фокус. Атрибуты тут меньшая часть работы, поведение — почти вся.
Чаще всего ломается фокус в модальных окнах. Контракт такой: при открытии фокус уходит внутрь; пока окно открыто, Tab гуляет внутри, фон недоступен; Escape закрывает; при закрытии фокус возвращается на элемент, который окно открыл. Платформа закрывает контракт почти целиком:
<button id="delete-draft">Удалить черновик</button>
<dialog id="confirm">
<form method="dialog">
<p>Удалить черновик?</p>
<button value="yes">Удалить</button>
<button value="no">Отмена</button>
</form>
</dialog>
const dialog = document.querySelector('#confirm');
document.querySelector('#delete-draft').addEventListener('click', () => {
dialog.showModal();
});
showModal уводит фокус внутрь, делает фон инертным, обрабатывает Escape и возвращает фокус на триггер при закрытии. Проверьте поддержку в целевых браузерах — и не пишите свою модалку, пока не обязаны.
Если всё-таки пишете — контракт целиком ваш, и тут пригодится атрибут inert: пометьте инертной остальную страницу, и Tab физически не выйдет за пределы окна. Меньше самодельных циклов перехвата фокуса — меньше мест, где они ломаются.
Отдельный пункт — асинхронные обновления. Скринридер не узнаёт о смене DOM сам: пользователь стоит фокусом в другом месте, а «товар добавлен в корзину» происходит молча. Решение — живой регион: элемент с aria-live существует в разметке заранее, а вы меняете его текст.
<p aria-live="polite" class="visually-hidden" id="cart-status"></p>
document.querySelector('#cart-status').textContent = 'Товар добавлен в корзину';
Регион обязан существовать до сообщения: созданный одновременно с текстом, он объявляется нестабильно, полагаться на это нельзя. И не спамьте: регион, объявляющий каждое нажатие клавиши, хуже молчащего.
Здесь же — связь с нашими прошлыми материалами. Чем меньше клиентского JavaScript, тем меньше самодельных контрактов: это ещё один аргумент в споре о том, где проходит граница клиент — сервер, и в пользу серверных компонентов, о которых у нас был отдельный разбор. Каждый нативный контракт вместо самописного — минус место, где ломаются клавиатура и фокус.
Шаг 3. CSS-дисциплина
Про то, сколько задач современный CSS забрал у JS, у нас уже был материал — на доступности эта линия работает в полную силу.
Кольцо фокуса — единственный ответ интерфейса на вопрос «где я сейчас». Правило жёсткое: никакого outline: none без равноценной замены. Компромисс, устраивающий и дизайн, и клавиатуру, — :focus-visible: кольцо появляется при клавиатурной навигации и не мелькает при кликах мышью.
:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
Цвет подберите под палитру, требование одно: кольцо должно отличаться от того, что рядом с ним.
Движение. Параллакс, автовоспроизведение анимаций и тряска — источник настоящей тошноты для части пользователей. Система сама сообщает, что пользователю движение мешает, — остаётся её послушать:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
}
Для сложных анимаций стратегию выключения подберите отдельно: резкое отключение может оставить элемент в стартовом состоянии.
Контраст — на уровне токенов. Если цвета живут в дизайн-токенах, контраст проверяется один раз на пару «текст — фон», а не на каждом экране. Способ расчёта: контраст — отношение яркости текста и фона, пороги зафиксированы в общедоступном стандарте; подставьте свои значения в любой калькулятор. Прогнав палитру один раз, вы делаете будущую типографику безопасной по построению: инвариант зашит в фундамент, а не проверяется на выходе.
Скрытие. Два разных смысла, которые постоянно путают. display: none, visibility: hidden и атрибут hidden убирают элемент для всех, включая скринридер, — так прячьте то, что не нужно никому. А .visually-hidden убирает элемент только для зрячих, оставляя его в дереве доступности, — так живут подписи иконных кнопок, статусы, подсказки:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
white-space: nowrap;
}
Механика такая: элемент вынимается из потока, чтобы не занимать место на экране, сжимается до неразличимого размера, переполнение обрезается, а запрет переноса не даёт тексту вылезти за пределы обрезки. Для глаза элемент исчез; из дерева доступности он не удалён, скринридер по-прежнему его читает.
Размер целей нажатия — одной строкой: мелкая цель недоступна для пальцев и нетвёрдых рук; проверяйте по гайдлайнам своей платформы.
Шаг 4. Удержать: контракты в тестах
Ручной проход находит баги, но ничего не удерживает. Инженерный ход — превратить проверки в ассерты. Контракт диалога из шага про модалки — уже готовый тест:
// набросок: API вашего тест-раннера будет своим.
// Суть: фокус и клавиши тестируются как любой другой контракт компонента
await page.click('#delete-draft');
await page.keyboard.press('Escape');
const focused = await page.evaluate(() => document.activeElement.id);
assert(focused === 'delete-draft'); // фокус вернулся на триггер
Остальная обвязка. Автоматические проверщики доступности — в CI на компонентных тестах: как регрессионная сетка для статичных свойств (подписи, контраст, роли) они идеальны, как замена человеку — нет; открытых проверщиков несколько, класс ловимых проблем общий — выбирайте любой и вшивайте в пайплайн. Линтер на антипаттерны: положительный tabindex, outline: none без :focus-visible, клики на неинтерактивных элементах, картинки без alt. Definition of done интерактивного компонента — плюс одна строка: «проход по клавиатуре без мыши».
Условия применимости всей схемы. Есть дизайн-система или библиотека компонентов — чините на уровне компонентов: выигрыш умножается на каждое использование. Легаси без библиотеки — начинайте с главных путей, а не с полного аудита: полный аудит легаси — способ утонуть, не улучшив ни одного сценария. Статичный контент — документация, блог — интерактивной работы мало, начинать нужно с шаблонов и контентных правил: осмысленные alt, честная иерархия заголовков, контраст из токенов.
Позиция редакции
Мы считаем, что правильная единица внедрения доступности — компонент, а не страница. Снизу вверх: контракт каждого интерактивного компонента — достижимость, управляемость, видимый фокус, корректные роли и состояния — зашивается в библиотеку и закрывается тестами. Аудит страницы не отменяется, но меняет роль: он проверяет сборку, а не фундамент.
Обоснование — из устройства систем: свойства страницы есть композиция свойств компонентов. Аудит находит симптом, фикс почти всегда ложится в компонент. Чинить на уровне страницы — значит дублировать исправление и ждать, пока его копия сгниёт в следующем использовании.
Позиция неверна, если интерактивного слоя почти нет: лендинги, документация, блоги — там нет компонентного рычага, единицей становятся шаблоны и контентные правила. Она неверна и с другой стороны: если каждый экран пишется руками без переиспользования, контракт компонента не на что умножать — единицей становится шаблон экрана.
И про фазы. Отдельная «фаза доступности» — признание, что свойство не встроено в процесс. Если требование пришло извне и срок горит, разовая фаза честнее имитации встроенности. Но её результат обязан превратиться в контракты и тесты — иначе через релиз всё вернётся.