Урок 04

ARIA и доступность в интерфейсах

Что такое ARIA

ARIA — Accessible Rich Internet Applications. Атрибуты для описания ролей и состояний.

Правило №1: не использовать ARIA, если есть нативный тег.

<!-- Плохо -->
<div role="button" tabindex="0" onclick="...">Кнопка</div>

<!-- Хорошо -->
<button type="button">Кнопка</button>

Правило №2: ARIA не меняет поведение. Только описывает.

Правило №3: ARIA для динамического контента, где HTML не хватает.

Основные роли

role — роль элемента:

<div role="button">Кнопка</div>
<div role="navigation">Меню</div>
<div role="dialog">Модалка</div>
<div role="alert">Уведомление</div>
<div role="tab">Вкладка</div>
<div role="tabpanel">Панель</div>
<div role="tooltip">Тултип</div>
<div role="status">Статус</div>

⚠️ Роли — не заменяют семантические теги.

Правило: роль только там, где нет тега.

aria-label

Текстовая метка элемента:

<button aria-label="Закрыть окно">
    <svg>...</svg>
</button>

<a href="/cart" aria-label="Корзина, 3 товара">
    <i class="bi bi-cart"></i>
</a>

Когда: у элемента нет видимого текста.

Правило: обязательно для кнопок с иконками.

aria-labelledby

Ссылка на элемент с меткой:

<section aria-labelledby="features-title">
    <h2 id="features-title">Возможности</h2>
    <p>...</p>
</section>

Когда: метка — существующий элемент.

Разница:

  • aria-label — текст прямо в атрибуте.
  • aria-labelledby — ID элемента.

aria-describedby

Описание через ID:

<input type="password" aria-describedby="password-hint">
<p id="password-hint">Минимум 8 символов</p>

Что даёт: скринридер читает подсказку.

Правило: ошибки форм — через aria-describedby.

aria-hidden

Скрыть от скринридеров:

<button aria-label="Закрыть">
    <svg aria-hidden="true">...</svg>
</button>

Когда: декоративные элементы, дубли.

Правило: не скрывать фокусируемые элементы.

aria-expanded

Раскрыт ли элемент:

<button aria-expanded="false" aria-controls="menu">
    Меню
</button>

<ul id="menu" hidden>...</ul>

JS:

button.addEventListener('click', () => {
    const expanded = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!expanded));
    menu.hidden = expanded;
});

Когда: dropdown, аккордеон, burger-меню.

aria-controls

Какой элемент контролирует:

<button aria-controls="panel-1" aria-expanded="true">
    Показать
</button>
<div id="panel-1">...</div>

aria-current

Текущий элемент:

<nav>
    <ul>
        <li><a href="/" aria-current="page">Главная</a></li>
        <li><a href="/about">О нас</a></li>
    </ul>
</nav>

Значения:

  • page — текущая страница.
  • step — шаг.
  • location — текущая локация.
  • date — текущая дата.
  • time — текущее время.
  • true — общее.

aria-live

Динамически обновляемый контент:

<div aria-live="polite" id="status"></div>

Значения:

  • polite — когда удобно.
  • assertive — немедленно.
  • off — не сообщать.

JS:

status.textContent = 'Товар добавлен в корзину';

Когда: toast, счётчики, уведомления, чат.

aria-busy

Элемент загружается:

<div aria-busy="true" aria-live="polite">
    Загрузка...
</div>

Что даёт: скринридер ждёт завершения.

aria-invalid

Невалидное поле:

<input
    type="email"
    aria-invalid="true"
    aria-describedby="email-error"
>
<p id="email-error" role="alert">Введите корректный email</p>

Когда: после неудачной валидации.

aria-required

Обязательное поле:

<input type="email" required aria-required="true">

⚠️ required уже делает это в HTML5. aria-required — для кастомных элементов.

Роли для навигации

Меню:

<nav aria-label="Главное меню">
    <ul role="list">
        <li role="listitem">
            <a href="/" aria-current="page">Главная</a>
        </li>
    </ul>
</nav>

⚠️ <ul> уже list. Не нужно.

Правило: не добавлять роли к нативным элементам.

Accordion (аккордеон)

<h3>
    <button
        aria-expanded="false"
        aria-controls="section1"
        id="accordion1id"
    >
        Заголовок аккордеона
    </button>
</h3>
<div
    id="section1"
    role="region"
    aria-labelledby="accordion1id"
    hidden
>
    Содержимое
</div>

Tabs (вкладки)

<div role="tablist">
    <button
        role="tab"
        id="tab-1"
        aria-selected="true"
        aria-controls="panel-1"
    >
        Вкладка 1
    </button>
    <button
        role="tab"
        id="tab-2"
        aria-selected="false"
        aria-controls="panel-2"
        tabindex="-1"
    >
        Вкладка 2
    </button>
</div>

<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
    Содержимое 1
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
    Содержимое 2
</div>

Клавиши:

  • Arrow Left/Right — переключение.
  • Home / End — первая / последняя.

Dialog / Modal

<div
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-title"
    aria-describedby="modal-body"
>
    <h2 id="modal-title">Заголовок</h2>
    <p id="modal-body">Содержимое</p>
    <button aria-label="Закрыть">×</button>
</div>

Что важно:

  • aria-modal="true" — фокус внутри.
  • aria-labelledby — заголовок.
  • Фокус-ловушка — Tab не выходит.
  • Escape — закрывает.

Правило: лучше использовать нативный <dialog>.

Нативный <dialog>

<dialog id="myModal">
    <h2>Заголовок</h2>
    <p>Содержимое</p>
    <button>Закрыть</button>
</dialog>

JS:

dialog.showModal();
dialog.close();

Что даёт:

  • Фокус-ловушка.
  • Escape.
  • Backdrop.
  • Accessibility.

Правило: использовать <dialog> — минимум ARIA.

Практика: доступные кнопки-иконки

<button type="button" aria-label="Удалить">
    <i class="bi bi-trash" aria-hidden="true"></i>
</button>

<button type="button" aria-label="Редактировать">
    <i class="bi bi-pencil" aria-hidden="true"></i>
</button>

Правило:

  • Иконка — aria-hidden.
  • Кнопка — aria-label.

Практика: доступная форма

<form>
    <div class="mb-3">
        <label for="email">Email</label>
        <input
            type="email"
            id="email"
            required
            aria-required="true"
            aria-describedby="email-error"
            aria-invalid="false"
        >
        <p id="email-error" role="alert"></p>
    </div>

    <button type="submit">Отправить</button>
</form>

JS:

input.addEventListener('blur', () => {
    const valid = input.checkValidity();
    input.setAttribute('aria-invalid', String(!valid));
    error.textContent = valid ? '' : 'Введите корректный email';
});

Инструменты

Автоматически:

  • Lighthouse — Accessibility аудит.
  • axe DevTools — расширение.
  • WAVE — онлайн.
  • Pa11y — CLI.

Вручную:

  • Tab — навигация по всем элементам.
  • Screen reader — NVDA, VoiceOver.
  • Zoom — 200%.
  • Windows High Contrast Mode.

Правило: автоматика ловит 30%. Остальное — вручную.

Практика

1. Прогоните сайт через axe DevTools.

2. Пройдите табом — все элементы доступны?

3. Проверьте: кнопки-иконки с aria-label.

4. Модалка через <dialog>.

5. Toast с aria-live.

6. Tabs с ролями.

7. Форма с ошибками через aria-describedby.

Частые ошибки

1. ARIA-роль вместо тега:

<div role="button">

Решение: <button>.

2. aria-hidden на фокусируемом:

<button aria-hidden="true">

Решение: убрать или заменить.

3. aria-label совпадает с текстом:

<button aria-label="Сохранить">Сохранить</button>

Избыточно.

4. Нет aria-current:

Непонятно, где мы

Решение: добавить.

5. Кнопка без type:

<button>Отправить</button>

По умолчанию submit. В форме — type="button".

6. role="alert" без aria-live:

Не всегда работает

Решение: role="alert" уже assertive. Или aria-live="assertive".

7. Модалка без фокус-ловушки:

Tab уходит за пределы

Решение: <dialog> или focus trap.

8. Забыть aria-expanded:

Скринридер не знает состояние

Решение: обновлять через JS.

Итог

  • Правило №1: не ARIA, если есть тег.
  • aria-label — метка.
  • aria-labelledby — метка из элемента.
  • aria-describedby — описание.
  • aria-hidden — скрыть от скринридера.
  • aria-expanded — раскрыто.
  • aria-current — текущий.
  • aria-live — динамика.
  • aria-invalid — ошибка.
  • <dialog> — для модалок.
  • Lighthouse + axe — проверка.
  • Tab + screen reader — вручную.

Дальше — тёмная тема: реализация.