Webrium
Подписаться

Доступность как инженерная задача, а не как галочка

Почему чек-листы и оверлеи не делают сайт доступным, где на самом деле живут барьеры и как найти их обычной клавиатурой

Цепочка шагов аудита доступности: автоматические проверки разметки лежат внутри границы, а последний шаг — поведение по Tab с реальными барьерами — выходит за евидит автоматбарьер в поведении

Задача выглядит как аудит, а барьеры живут в поведении

В бэклоге задача «сделать сайт доступным» выглядит как разовая работа с конечным списком пунктов: есть инструменты, которые подсвечивают проблемы, есть чек-листы — прогнать, поправить подсвеченное, закрыть.

Ощущение обманчиво, и причина — в устройстве самих проверщиков. Автоматический инструмент читает страницу как застывшее дерево: есть ли у картинки замещающий текст, подписано ли поле, достаточен ли контраст. Это свойства разметки, и машина их действительно видит. Но барьер почти никогда не сидит в разметке сам по себе — он живёт в поведении во времени. Фокус, потерявшийся после закрытия диалога. Список, который не листается стрелками. Обновление контента, о котором скринридер молчит, потому что никто не сообщил ему, что обновление произошло. Чтобы это поймать, нужно нажать 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, честная иерархия заголовков, контраст из токенов.

Позиция редакции

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

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

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

И про фазы. Отдельная «фаза доступности» — признание, что свойство не встроено в процесс. Если требование пришло извне и срок горит, разовая фаза честнее имитации встроенности. Но её результат обязан превратиться в контракты и тесты — иначе через релиз всё вернётся.

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Два стыка: CSS встроен в конвейер рендеринга и совпадает с кадром, JS-наблюдатель стоит снаружи и разошёлся — опаздывает на кадрCSS в конвейереJS опаздывает
Предыстория

Что современный CSS убрал из вашего JS

Перед этим текстом стоит знать: JavaScript-наблюдатель за внешним видом всегда опаздывает на кадр, а ускорить его не выйдет. Часть задач решает сам CSS внутри конвейера рендеринга, но не все.

На шкале времени жизни экрана величина — момент, когда становятся известны входные данные, — пробивает пороговую линию между сервером и клиентом: если данные изсервер/клиентвремя жизни экрана
Углубление

Где проходит граница клиент — сервер

Сортировка компонентов по типам ведёт в тупик; вместо неё — ось времени: когда становятся известны входные данные. Один экран разобран по шагам, и редакция называет случаи, когда её позиция неверна.

Поле разделено швом «use client»: элементы-пропсы пересекают границу между сервером и клиентом и становятся сетевым контрактомсерверклиент
Предыстория

Серверные компоненты: год в продакшене

База для разговора о доступности: что уезжает по проводам, почему проп через границу становится частью сетевого контракта и какие поломки предсказуемы заранее.

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

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть