Это разбор реального опыта внедрения доступности в крупном веб-продукте с десятками микросервисов и сложным фронтендом. Без лозунгов, зато с кодом, ошибками, переработками дизайн-системы и неожиданными проблемами в CI. Поговорим про ARIA, серверный рендеринг, мобильные скринридеры, автоматическое тестирование и про то, почему доступность — это не про alt у картинок, а про архитектуру.

Когда я впервые услышал фразу сделаем доступность, я честно подумал: окей, добавим alt, поправим контраст, закроем задачу. Спойлер — это был самый наивный момент за весь проект.

Мы работали над крупной B2B-платформой. Много форм, таблиц, кастомных контролов, графиков, drag-and-drop, модалки внутри модалок. И в какой-то момент заказчик сказал: продукт должен соответствовать WCAG 2.1 AA. Причём не формально, а чтобы им реально могли пользоваться люди с ограничениями по зре��ию и моторике.

Если вы думаете, что это история про дизайнеров, то нет. Это история про архитектуру, состояние UI, серверный рендеринг, события клавиатуры, правильный фокус и даже про бэкенд-валидацию.

Давайте по порядку.

Доступность — это не слой поверх UI, а часть архитектуры

Первая ошибка — попытка прикрутить доступность к уже существующим компонентам. У нас была собственная дизайн-система на React с кучей кастомных контролов: Select, DatePicker, Dropdown, Tooltip. Всё красиво, анимировано, управляется через div.

И вот тут начинаются проблемы. Скринридер не понимает div с onClick. Он ожидает семантику. Кнопка — это button. Поле ввода — это input. Таблица — это table.

Мы решили не чинить каждый компонент по отдельности, а пересобрать базовый слой UI. Сделали правило: если есть нативный HTML-элемент — используем его. Только если нет альтернативы — добавляем ARIA.

Пример. Был кастомный селект:

// JavaScript (React)
function CustomSelect({ options, onChange }) {
  return (
    <div className="select">
      <div className="select-trigger">Выбрать</div>
      <div className="select-menu">
        {options.map(o => (
          <div key={o.value} onClick={() => onChange(o.value)}>
            {o.label}
          </div>
        ))}
      </div>
    </div>
  );
}

Скринридер видит просто набор div. Клавиатура не работает. Фокус теряется.

Переделали так:

// JavaScript (React)
function AccessibleSelect({ options, value, onChange, id }) {
  return (
    <div className="select-wrapper">
      <label htmlFor={id}>Выберите значение</label>
      <select
        id={id}
        value={value}
        onChange={(e) => onChange(e.target.value)}
      >
        {options.map(o => (
          <option key={o.value} value={o.value}>
            {o.label}
          </option>
        ))}
      </select>
    </div>
  );
}

Да, визуально пришлось дорабатывать стили. Да, дизайнер сначала был не в восторге. Но это сразу решило 80 процентов проблем: фокус, управление стрелками, озвучивание.

Иногда самый сложный технический шаг — отказаться от красивого хака.

Фокус, табуляция и ловушки клавиатуры

Если вы не пробовали пользоваться своим продуктом только клавиатурой — попробуйте. Без мыши. Вообще.

В нашем продукте был сложный модальный мастер из пяти шагов. Изначально фокус просто улетал в body после закрытия шага. Скринридер терял контекст. Пользователь нажимал Tab и оказывался где-то в футере.

Решение оказалось не косметическим. Мы внедрили централизованный менеджер фокуса.

// JavaScript
class FocusManager {
  constructor() {
    this.stack = [];
  }

  trap(container) {
    const focusable = container.querySelectorAll(
      'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
    );

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    container.addEventListener('keydown', (e) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    });

    first.focus();
    this.stack.push(container);
  }

  release() {
    this.stack.pop();
  }
}

export const focusManager = new FocusManager();

Мы подключили его ко всем модалкам. Плюс добавили aria-modal, role dialog, aria-labelledby.

Интересный момент: если у вас SSR и гидратация, фокус может сбрасываться при ререндере. Нам пришлось добавить проверку на сервере, чтобы не вызывать фокусировку до полной инициализации клиента.

А вы когда-нибудь проверяли, что происходит с фокусом при ошибке валидации формы? Он перескакивает к первому полю или просто появляется красный текст? Скринридеру красный цвет не виден.

Валидация и ошибки: бэкенд тоже участвует

Одна из неожиданных вещей — доступность упирается в API. Если сервер возвращает ошибку, её нужно связать с конкретным полем через aria-describedby.

Мы договорились о едином формате ошибок:

{
  "errors": [
    {
      "field": "email",
      "code": "INVALID_FORMAT",
      "message": "Некорректный формат email"
    }
  ]
}

На фронте:

// JavaScript (React)
function InputField({ id, error, ...props }) {
  return (
    <div className="input-wrapper">
      <input
        id={id}
        aria-invalid={!!error}
        aria-describedby={error ? `${id}-error` : undefined}
        {...props}
      />
      {error && (
        <div id={`${id}-error`} role="alert">
          {error}
        </div>
      )}
    </div>
  );
}

role alert важен — скринридер сразу озвучивает сообщение. Без этого пользователь может вообще не узнать, что что-то пошло не так.

И тут начинается интересное. Если у вас сложная форма с условными полями, нужно следить, чтобы скрытые поля не оставались в таб-порядке. display none — ок. Но visibility hidden или offscreen-позиционирование может оставить их доступными для фокуса.

Мы нашли пару багов, которые существовали годами, просто потому что никто не проверял поведение без мыши.

Графики, сложные таблицы и данные

Самая больная часть — аналитические дашборды. Canvas-графики, интерактивные чарты, кастомные тултипы.

Canvas сам по себе для скринридера пустота. Нам пришлось генерировать альтернативное текстовое представление данных.

Мы сделали вспомогательную таблицу, скрытую визуально, но доступную для ассистивных технологий:

<table class="sr-only">
  <caption>Продажи по месяцам</caption>
  <thead>
    <tr>
      <th>Месяц</th>
      <th>Сумма</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Январь</td>
      <td>120000</td>
    </tr>
    <tr>
      <td>Февраль</td>
      <td>95000</td>
    </tr>
  </tbody>
</table>

Класс sr-only реализован так, чтобы элемент был доступен скринридеру, но не ломал верстку.

Да, это дополнительный код. Да, нужно синхронизировать данные. Но альтернатива — полностью недоступный график.

Ещё интересный кейс — сортировка таблиц. Если пользователь нажимает на заголовок, нужно обновлять aria-sort и давать понятную текстовую индикацию направления сортировки. Иначе скринридер просто скажет столбец и всё.

Автоматизация, тесты и CI

Мы не могли проверять всё вручную. Подключили axe-core в e2e-тестах на Cypress. И тут началось веселье: десятки ошибок на старых страницах.

Самое сложное — договориться внутри команды, что падение теста из-за нарушения доступности — это такой же баг, как и 500 на API.

Пример интеграции:

// JavaScript (Cypress)
import 'cypress-axe';

describe('Accessibility check', () => {
  it('Home page should have no a11y violations', () => {
    cy.visit('/');
    cy.injectAxe();
    cy.checkA11y(null, {
      runOnly: {
        type: 'tag',
        values: ['wcag2aa']
      }
    });
  });
});

Сначала все ворчали. Потом привыкли. А потом стало проще писать правильно сразу, чем чинить тесты.

Что в итоге

Инклюзивность в большом проекте — это не один спринт и не задача на фронтендера. Это пересмотр подхода к компонентам, событиям, API, тестированию. Это иногда больно, иногда скучно, иногда неожиданно сложно.

Но знаете, что меня больше всего зацепило? Когда мы дали продукт на тест пользователю со скринридером, и он сказал: наконец-то я могу сам пройти этот процесс без помощи.

В этот момент все aria и tabindex перестают быть абстракцией.