43 Модуль 9 Качество и производительность 18 мин урок 18 из 25

Доступность уровня production

Основы ты уже знаешь. Здесь — то, что начинается дальше: ARIA-паттерны, управление фокусом, живые области и как это проверить самому.

Что ты научишься делать

  • Соберёшь модальное окно, полностью доступное с клавиатуры
  • Научишься сообщать о динамических изменениях через живые области
  • Проверишь свой интерфейс скринридером — на своём же компьютере

#Что уже пройдено

В десятом уроке основ разобраны фундаментальные вещи: семантика, alt, видимый фокус, контраст, порядок заголовков, skip-link. Повторять не будем.

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

Интерактивный разбор доступен при включённом JavaScript.

#Из чего складывается доступное имя

Читалка объявляет элемент по формуле: имя, роль, состояние. «Удалить, кнопка, недоступна».

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

ПриоритетИсточникЗамечание
1aria-labelledbyСсылка на id другого элемента. Побеждает всё
2aria-labelПерекрывает видимый текст — легко случайно скрыть настоящее имя
3Связанный <label>Для полей формы
4Текст внутри элементаОбычный случай для кнопок и ссылок
5titleРаботает, но ненадёжно: на сенсорных экранах не показывается

Так не надо

<button aria-label="Кнопка">
  Оформить заказ
</button>

Читалка произнесёт «Кнопка», а не «Оформить заказ». Видимый текст перекрыт бесполезной подписью — и человек не сможет назвать команду голосом.

Так надо

<button>Оформить заказ</button>

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

Есть текст — <code>aria-label</code> не нужен. Нет текста, только иконка — вот тогда он и ставится.

#Управление фокусом

Это главное, что отличает доступный интерфейс от недоступного. Правило простое: фокус должен идти туда, куда пошло внимание.

Открылось модальное окно — фокус внутрь. Закрылось — обратно на кнопку, которая его открыла. Появилось сообщение об ошибке — фокус на первое поле с ошибкой.

HTMLМодальное окно
<button type="button" data-open-modal>Удалить заказ</button><div class="modal" role="dialog" aria-modal="true"     aria-labelledby="modal-title" hidden>  <h2 id="modal-title">Удалить заказ?</h2>  <p>Отменить это действие нельзя.</p>  <button type="button" data-confirm>Удалить</button>  <button type="button" data-cancel>Отмена</button></div>

Разметка — половина дела. Вторая половина: перевести фокус внутрь, не выпускать его наружу, пока окно открыто, и вернуть на место при закрытии.

  1. Запомни, откуда пришли

    Перед открытием сохрани document.activeElement. Иначе возвращать фокус будет некуда.

  2. Переведи фокус внутрь

    На первый интерактивный элемент или на сам диалог. Не на кнопку подтверждения удаления — человек может нажать её вслепую.

  3. Запри фокус

    Пока окно открыто, Tab не должен уводить на страницу под ним. Это и называется фокус-ловушкой.

  4. Дай выход по Escape

    Обязательно. Это единственный способ закрыть окно, если человек не видит крестик.

  5. Верни фокус

    При закрытии — на сохранённый элемент. Без этого фокус улетает в начало страницы, и человек теряет место.

Диалог: разметка и оформление

#Живые области

Зрячий человек видит, что счётчик корзины изменился. Читалка молчит: она озвучивает только то, на чём фокус.

aria-live помечает область, изменения в которой нужно объявлять вслух.

ЗначениеКогда объявитДля чего
politeКогда читалка закончит текущую фразуПочти всё: счётчики, результаты поиска, «сохранено»
assertiveНемедленно, перебиваяТолько критичное: ошибка оплаты, потеря связи
offНе объявлятьЗначение по умолчанию
HTMLПравильный порядок
<!-- Область объявлена ЗАРАНЕЕ и пуста.     Если создать её вместе с текстом, объявления не будет --><p class="status" role="status" aria-live="polite"></p><!-- Для критичного — отдельная область --><p class="error" role="alert"></p><script>  // Читалка объявит «Сохранено» после текущей фразы  document.querySelector('.status').textContent = 'Сохранено';</script>

#Скрывать правильно

Три способа спрятать элемент, и они не взаимозаменяемы.

СпособВидно глазамиСлышно читалкеДоступно с клавиатуры
display: none или hiddenНетНетНет
aria-hidden="true"ДаНетДа — и это ловушка
Класс visually-hiddenНетДаДа
opacity: 0, visibility: hiddenНетЗависитЗависит
CSSvisually-hidden: текст только для читалки
.visually-hidden {  position: absolute;  inline-size: 1px;  block-size: 1px;  padding: 0;  margin: -1px;  overflow: hidden;  /* clip-path вместо устаревшего clip */  clip-path: inset(50%);  white-space: nowrap;}

#Как проверить самому

Скринридер уже стоит на твоём компьютере — включается сочетанием клавиш. Полчаса с ним заменяют десяток статей.

СистемаПрограммаВключение
macOSVoiceOver + F5
WindowsNVDA (бесплатно) или встроенный «Экранный диктор»Ctrl + Win + Enter
AndroidTalkBackНастройки → Специальные возможности
iOSVoiceOverНастройки → Универсальный доступ

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

#Как это выглядит в реальном проекте

HTMLВкладки: полный паттерн
<div class="tabs">  <div role="tablist" aria-label="Информация о товаре">    <button role="tab" id="tab-desc"            aria-selected="true" aria-controls="panel-desc">      Описание    </button>    <button role="tab" id="tab-spec"            aria-selected="false" aria-controls="panel-spec"            tabindex="-1">      Характеристики    </button>  </div>  <div role="tabpanel" id="panel-desc" aria-labelledby="tab-desc"></div>  <div role="tabpanel" id="panel-spec" aria-labelledby="tab-spec" hidden></div></div>

Обрати внимание на tabindex="-1" у неактивной вкладки. Это не ошибка: в наборе вкладок клавиша Tab должна перескакивать весь набор целиком, а переключение между вкладками идёт стрелками. Так работают вкладки во всех настольных программах — и того же ждут от сайта.

#Когда НЕ надо

  • Вешать роли на семантические теги. <nav role="navigation"> — избыточно, роль уже есть. Лишние атрибуты только засоряют.
  • Дублировать текст в aria-label. Это перекрывает видимое имя, и человек не сможет назвать кнопку голосом так, как её видит.
  • Ставить aria-live="assertive" везде. Перебивать читалку — это как кричать. Для почти всего подходит polite.
  • Полагаться на автоматические проверки. Они ловят примерно треть проблем: пропущенный alt, слабый контраст. Понятна ли кнопка на слух — не проверит ни один инструмент.

#Поддержка браузерами

ВозможностьСостояние
Атрибуты ARIAВезде; поведение слегка различается между читалками
<dialog> и showModal()Во всех современных браузерах
::backdropВместе с dialog
inertВо всех современных браузерах — удобная замена ручной фокус-ловушке
:focus-visibleВо всех современных браузерах

Где спотыкаются почти все

Фокус не переводится в открытое окно

Почему Окно открылось, но Tab продолжает ходить по странице под ним. Человек не понимает, что произошло.

Как надо Переводи фокус внутрь при открытии и возвращай при закрытии. С <dialog> это делается само.

Живая область создаётся вместе с текстом

Почему Читалка следит за изменениями существующей области. Новый элемент с текстом она не заметит.

Как надо Пустой контейнер с aria-live должен быть в разметке заранее.

<code>aria-hidden</code> на фокусируемом элементе

Почему Элемент остаётся в обходе клавиатурой, но не объявляется. Фокус попадает в тишину.

Как надо Добавь tabindex="-1" или используй hidden.

Все вкладки доступны по Tab

Почему В наборе из шести вкладок человек шесть раз жмёт Tab, чтобы пройти мимо. Ожидается один.

Как надо Активная вкладка в обходе, остальные с tabindex="-1", переключение стрелками.

Доступное имя accessible name
Как читалка называет элемент. Собирается по приоритету источников.
Фокус-ловушка focus trap
Удержание фокуса внутри окна, пока оно открыто.
Живая область live region
Область, изменения в которой читалка объявляет вслух.
Инертность inert
Атрибут, выключающий ветку разметки из взаимодействия целиком.
Скрытие визуальное visually hidden
Приём: элемент не виден глазами, но доступен читалке.

Проверь себя

  1. Что произнесёт читалка для <code>&lt;button aria-label="Кнопка"&gt;Оформить заказ&lt;/button&gt;</code>?

    <code>aria-label</code> перекрывает видимый текст. Это частая ошибка: настоящее имя оказывается скрытым.

  2. Почему живая область должна быть в разметке заранее?

    Пустой контейнер объявляется заранее, а текст в него подставляется потом.

  3. Чем опасен <code>aria-hidden="true"</code> на кнопке?

    Нужно добавить <code>tabindex="-1"</code> либо использовать <code>hidden</code>.

  4. Сколько раз надо нажать Tab, чтобы пройти мимо набора из шести вкладок?

    Активная вкладка в обходе, остальные с <code>tabindex="-1"</code>. Так устроены вкладки во всех настольных программах.

  5. Что даёт <code>&lt;dialog&gt;</code> с <code>showModal()</code> из коробки?

    Это тот случай, когда готовый тег закрывает почти всё, ради чего раньше писали сотню строк скрипта.

Задание

Доступное модальное окно

Модальное окно собрано из div и недоступно: нет роли, нет связи с заголовком, кнопка-крестик без имени, а иконка внутри неё озвучивается.

Почини разметку: дай окну роль dialog с aria-modal, свяжи с заголовком через aria-labelledby, сделай крестик настоящей кнопкой с aria-label, спрячь иконку через aria-hidden. Добавь в CSS видимую обводку фокуса.

Открыть в песочнице
    Подсказка 1

    Окну: <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">

    Подсказка 2

    Заголовку: <h2 class="modal__title" id="modal-title">Удалить заказ?</h2>

    Подсказка 3

    Крестик: <button class="modal__close" aria-label="Закрыть окно"> и внутри <svg aria-hidden="true">

    Подсказка 4

    В CSS замени :focus { outline: none; } на :focus-visible { outline: 3px solid #6544e0; outline-offset: 2px; }

    Показать решение
    HTMLindex.html
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">  <h2 class="modal__title" id="modal-title">Удалить заказ?</h2>  <p class="modal__text">Отменить это действие нельзя.</p>  <button type="button" class="modal__close" aria-label="Закрыть окно">    <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">      <path d="m6 6 12 12M18 6 6 18" stroke="currentColor" stroke-width="2"/>    </svg>  </button>  <button class="modal__btn">Удалить</button></div>
    CSSstyle.css
    .modal {  position: relative;  max-width: 320px;  padding: 20px;  background: #fff;  border-radius: 14px;  box-shadow: 0 20px 50px -20px rgb(20 24 48 / .5);}.modal__title { margin: 0 0 8px; font-size: 18px; }.modal__text { margin: 0 0 16px; font-size: 14px; color: #5b6070; }.modal__close {  position: absolute;  top: 12px;  right: 12px;  padding: 4px;  background: transparent;  border: 0;  border-radius: 8px;  cursor: pointer;}.modal__btn {  padding: 9px 16px;  font: inherit;  color: #fff;  background: #c8394a;  border: 0;  border-radius: 999px;}:focus-visible {  outline: 3px solid #6544e0;  outline-offset: 2px;}

    Проверь себя