Доступность (a11y) Семантика как фундамент: нативные элементы против div-супа
0%

Семантика как фундамент: нативные элементы против div-супа

Семантика как фундамент: нативные элементы против div-супа

В обзоре трека мы договорились, что интерфейсом пользуются очень по-разному: глазами и мышью, ушами и клавиатурой, увеличенным до 400% экраном, голосовыми командами, одним переключателем. В статье «Зачем это нужно» разобрались, откуда берутся требования WCAG. Есть ещё вводная статья портала «Гайд по Accessibility» с общей рамкой и списком рекомендаций для менеджеров, дизайнеров и разработчиков — пересказывать её не будем, а копнём в один конкретный слой.

Этот слой — семантика разметки. Он самый нижний и самый дешёвый: почти всё, о чём пойдёт речь, делается не «дополнительной работой на доступность», а выбором правильного тега в момент, когда вы всё равно пишете этот кусок вёрстки. И наоборот: если фундамент кривой, поверх него приходится городить ARIA-подпорки, писать обработчики клавиатуры вручную и всё равно получать интерфейс, который работает хуже нативного.

Главная идея: разметка — это API страницы для машин

Привыкли думать, что HTML описывает, что показать. На самом деле HTML описывает, что это такое, а как показать — решает CSS. Расхождение между этими двумя ответами и есть корень большинства проблем доступности.

Возьмите строчку <div class="btn">Купить</div> со стилями кнопки. Человек, который смотрит на экран, видит кнопку: прямоугольник, контрастный фон, курсор-палец. Программа, которая читает страницу, видит контейнер общего назначения с текстом внутри. Никакой кнопки для неё не существует. Визуальный слой и смысловой слой разошлись — и все, кто получает интерфейс не через пиксели, получают его сломанным.

Кому эта разметка «отдаёт API»:

  • скринридерам — NVDA, JAWS, «Экранный диктор», VoiceOver, TalkBack, Orca;
  • программам голосового управления: команда «нажми Купить» в Dragon или Voice Control сработает, только если в дереве есть кнопка с таким именем;
  • switch-устройствам, которые перебирают интерактивные элементы, и режиму высокой контрастности Windows, который перекрашивает элементы по их роли;
  • поисковым роботам, режиму чтения браузера, автозаполнению, переводчику страниц;
  • вашим же тестам: getByRole('button', { name: 'Купить' }) в Testing Library ищет ровно то, что найдёт скринридер.

Полезная формулировка: семантика — единственный канал, по которому намерение разработчика доходит до пользователя, минуя пиксели.

Как из тегов получается речь: конвейер доступности

Между вашим <button> и фразой «Купить, кнопка» в наушниках лежит несколько преобразований. Понимание этой цепочки объясняет почти все странности, которые встретятся на практике.

Конвейер доступности: от разметки к речи скринридера

  1. Разметка. Браузер парсит HTML, применяет CSS, выполняет JS.
  2. DOM и CSSOM. Здесь семантика уже может пострадать: display: none и visibility: hidden убирают узел из дерева доступности целиком, display: contents в ряде случаев убирает бокс вместе с ролью, а list-style: none в WebKit исторически лишает список роли list.
  3. Дерево доступности. Браузер строит параллельную структуру, где каждый узел отвечает на четыре вопроса: роль, имя, состояние, свойства. Это и есть «то, что видит скринридер».
  4. Платформенный API. Дерево транслируется в UI Automation и IAccessible2 на Windows, NSAccessibility на macOS и iOS, AT-SPI2 на Linux, AccessibilityNodeInfo на Android.
  5. Ассистивная технология читает этот API и превращает узлы в речь, брайль или увеличение.

Именно поэтому доступность нельзя «включить постфактум одним пропсом»: информация должна родиться на первом шаге, иначе её неоткуда взять на пятом.

Обратите внимание: разработчик тут не написал ни строчки про клавиатуру, фокус или озвучку. Всё это уже встроено в <button>.

Четыре вопроса к каждому элементу

Роль, имя, состояние, свойства — не абстракция, а буквальный набор полей узла дерева доступности. WCAG 2.2 фиксирует их в критерии 4.1.2 Name, Role, Value (уровень A).

  • Роль — что это за штука: button, link, heading, checkbox, navigation, table. Роль определяет, как элемент объявят и каких клавиатурных соглашений от него ждут.
  • Имя (accessible name) — как элемент называется. Порядок вычисления по спецификации Accessible Name and Description Computation: aria-labelledbyaria-label → нативный источник (<label for>, alt, <caption>, текст внутри кнопки) → title.
  • Состояние — что происходит сейчас: нажат, развёрнут, выбран, отключён. У нативных элементов берётся из свойств DOM (checked, disabled, open), у самописных — только из ARIA-атрибутов, которые надо не забыть обновить.
  • Свойства — стабильные характеристики: required, readonly, уровень заголовка, позиция в списке, связь с описанием.

Упражнение для каждого ревью вёрстки: ткнуть в любой интерактивный элемент макета и вслух ответить на четыре вопроса. Если хоть на один нет ответа из разметки — это баг.

Что теряется, когда кнопка сделана из div

Типичный «дизайн-системный» вариант против нативного:

<!-- ПЛОХО: выглядит как кнопка, но кнопкой не является -->
<div class="btn btn--primary" onclick="addToCart(42)">Купить</div>

<!-- ХОРОШО -->
<button type="button" class="btn btn--primary" onclick="addToCart(42)">Купить</button>

Разница между ними — не «одна строчка про доступность», а восемь отдельных механизмов:

Что даёт <button> бесплатно Что происходит с <div onclick>
Роль button в дереве доступности Роль generic, элемент не объявляется как управляющий
Попадает в порядок Tab Не фокусируется, с клавиатуры недостижим
Enter и Space вызывают click Ничего не происходит, нужен свой keydown
Видимый фокус по умолчанию (:focus-visible) Нет фокуса — нет и кольца фокуса
disabled убирает из Tab и объявляет «недоступно» disabled не существует, нужен aria-disabled и ручная блокировка
Отправка формы по type="submit", в том числе по Enter в поле Форма не отправится
Корректная отрисовка в режиме высокой контрастности Windows Фон может исчезнуть, кнопка становится невидимой
Попадает в список кнопок скринридера и в цели голосовых команд Не попадает никуда

Чтобы честно догнать нативную кнопку на div, нужен и role="button", и tabindex="0", и вот такой обработчик:

const btn = document.querySelector('.btn');
const disabled = () => btn.getAttribute('aria-disabled') === 'true';

// div не знает, что Enter и Space — это активация
btn.addEventListener('keydown', (e) => {
  if (e.key !== 'Enter' && e.key !== ' ') return;
  e.preventDefault();          // иначе Space прокрутит страницу
  if (!disabled()) btn.click();
});

// aria-disabled не блокирует клик мышью — блокируем вручную
btn.addEventListener('click', (e) => {
  if (disabled()) { e.preventDefault(); e.stopPropagation(); return; }
  addToCart(42);
});

Полтора десятка строк, три атрибута и отдельный CSS для :focus-visible — ради того, что даёт слово button из шести букв. И отличия всё равно останутся: такая «кнопка» не отправит форму и иначе себя поведёт в режиме высокой контрастности. Это и есть содержание «первого правила ARIA», к которому мы вернёмся в статье про ARIA: если задачу решает нативный элемент — берите нативный элемент.

Ссылка или кнопка

Второй по частоте случай после div-кнопки — путаница <a> и <button>:

  • <a href> — переход куда-то. Меняется адрес, работает средний клик, «открыть в новой вкладке», копирование ссылки, история браузера. Активируется по Enter.
  • <button> — действие здесь и сейчас: открыть модалку, отправить форму, переключить тему. Активируется по Enter и Space.
<!-- ссылка без href: не фокусируется, роль generic, для клавиатуры её нет -->
<a onclick="openModal()">Подробнее</a>
<!-- ссылка-заглушка: скринридер объявит "ссылка", человек ждёт перехода, а его нет -->
<a href="#" onclick="openModal(); return false">Подробнее</a>
<!-- кнопка вместо навигации: ломает открытие в новой вкладке и индексацию -->
<button onclick="location.href='/pricing'">Тарифы</button>

Правильно: модалку открывает <button type="button">, на страницу тарифов ведёт <a href="/pricing">. В SPA-роутерах для этого есть готовые компоненты — <Link> в React Router и Next.js рендерят настоящий <a href>, не заменяйте их на div с обработчиком.

Дерево решений: когда вообще нужен div

div и span — законные элементы. Их задача — быть точкой приложения стилей без всякого смысла: обёртка для grid, контейнер для отступа, span для подкрашивания слова. Проблема не в существовании div, а в том, что им подменяют элементы со смыслом.

Ориентиры: карта страницы, которой пользуются каждый день

Зрячий человек за долю секунды понимает, где шапка, где основной текст, где боковая колонка. У пользователя скринридера этой «мгновенной картины» нет — вместо неё есть навигация по ориентирам (landmarks): нажал клавишу, прыгнул к следующему крупному блоку.

Ориентиры страницы и соответствующие им роли

Соответствие тегов и ролей закреплено в HTML Accessibility API Mappings — это нормативный документ, а не фольклор. Нюансы, на которых чаще всего спотыкаются:

  • <header> и <footer> дают роли banner и contentinfo только когда не вложены в <article>, <section>, <aside>, <nav> или <main>. Внутри карточки товара <footer> — просто generic.
  • <section> получает роль region только с доступным именем (aria-label или aria-labelledby). Без имени это generic-обёртка, и половина «семантической вёрстки из секций» не даёт ничего. То же самое с <form> и с <aside>, вложенным в другой секционирующий элемент.
  • <main> должен быть один на страницу; несколько <nav> обязаны различаться именами, иначе в списке ориентиров будет четыре одинаковых «навигация».
  • <search> — новый элемент (2023 год), даёт роль search; раньше писали <div role="search">.
<body>
  <header>
    <a class="logo" href="/">Курсы</a>
    <search>
      <label for="q">Поиск по сайту</label>
      <input id="q" name="q" type="search">
      <button type="submit">Найти</button>
    </search>
  </header>
  <nav aria-label="Основное меню"> ... </nav>
  <main>
    <h1>Семантика как фундамент</h1>
    <article> ... </article>
    <section aria-labelledby="comments-title">
      <h2 id="comments-title">Комментарии</h2>
    </section>
  </main>
  <aside aria-label="Похожие статьи"> ... </aside>
  <footer>
    <nav aria-label="Дополнительные ссылки"> ... </nav>
    <p>© 2026</p>
  </footer>
</body>

Правило покрытия: весь видимый контент должен лежать внутри какого-нибудь ориентира. Текст, оказавшийся между </header> и <main>, для навигации по регионам просто не существует — до него можно добраться только последовательным чтением.

Skip-link («Перейти к основному содержимому») — прямое следствие этой структуры и требование критерия 2.4.1 Bypass Blocks. Подробно про него — в статье про клавиатуру и фокус.

Заголовки: оглавление, которым реально пользуются

По данным опросов пользователей скринридеров WebAIM, навигация по заголовкам — самый популярный способ сориентироваться на длинной странице: её называют основной примерно две трети респондентов. В NVDA это клавиши H и 16, в JAWS — та же H, в VoiceOver — ротор. Список всех заголовков открывается одним сочетанием (в NVDA — Insert + F7). Отсюда практические правила:

  • Заголовок — уровень в структуре, а не размер шрифта. <h4>, выбранный «потому что h2 слишком крупный», ломает оглавление всем, кто им пользуется. Размер задаётся классом: <h2 class="text-sm">, а не сменой тега.
  • Не пропускайте уровни вниз. После <h2> может идти <h2> или <h3>, но не <h4> — прыжок через уровень читается как «здесь что-то потерялось». Вверх прыгать можно и нужно.
  • Один <h1> на страницу — заголовок самой страницы, обычно внутри <main>. Логотип в шапке заголовком не является.
  • Визуально скрытый заголовок — нормальный приём. Если блок понятен зрячему по расположению, но нуждается в названии для навигации, добавьте заголовок и спрячьте его визуально (не через display: none — он уберёт узел из дерева доступности).
/* Класс "visually hidden": элемент есть для скринридера, но не занимает места */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

Проверка за 30 секунд. Отключите CSS (в Firefox: Вид → Стиль страницы → Без стиля) или выполните в консоли:

// Печатает оглавление страницы с отступами по уровням
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((h) => {
  const level = Number(h.tagName[1]);
  console.log('  '.repeat(level - 1) + level + ' · ' + h.textContent.trim().slice(0, 70));
});

Если вывод читается как разумный план документа — структура в порядке. Если это набор случайных уровней, читатель, который пользуется только этим планом, потеряется.

Остальная семантика, которая работает на вас

Списки. <ul> и <ol> дают не только маркеры, но и объявление «список из 7 элементов, элемент 1 из 7» — человек сразу знает объём. Известная ловушка WebKit: list-style: none убирает роль списка в Safari и iOS, лечится явной ролью. Меню сайта — почти всегда список ссылок внутри <nav>, а не набор <div> подряд.

<ul class="nav-list" role="list"><!-- role возвращает семантику, снятую list-style: none -->
  <li><a href="/go">Go</a></li>
  <li><a href="/rust">Rust</a></li>
</ul>

Таблицы. Таблица данных без <th> — просто сетка текста. Скринридер объявляет заголовок столбца при переходе между ячейками, но только если знает, где заголовки. <caption> даёт таблице имя, scope связывает ячейки с заголовками. Таблица для раскладки (в письмах это до сих пор встречается) должна быть помечена role="presentation".

<table>
  <caption>Поддержка ARIA в скринридерах</caption>
  <thead>
    <tr><th scope="col">Атрибут</th><th scope="col">NVDA</th><th scope="col">VoiceOver</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">aria-expanded</th><td>да</td><td>да</td></tr>
  </tbody>
</table>

Изображения. Атрибут alt — не «описание картинки», а текстовая замена: что бы вы сказали вслух вместо неё.

<!-- Информативная: описываем смысл, а не пиксели -->
<img src="/chart.png" alt="Доля мобильного трафика выросла с 34% до 61% за два года">
<!-- Декоративная: пустой alt, чтобы скринридер её пропустил -->
<img src="/divider.svg" alt="">
<!-- Картинка внутри ссылки: alt заменяет текст ссылки -->
<a href="/cart"><img src="/cart.svg" alt="Корзина, 3 товара"></a>

Отсутствующий alt заставляет скринридер читать имя файла (IMG_20240712_final_v3.png) — это хуже, чем ничего. Пустой alt="" — осознанное «здесь ничего важного». Разница принципиальная. Для встроенных SVG используйте role="img" и <title> внутри: именно так сделаны схемы в этой статье.

Раскрывающиеся блоки. <details> и <summary> дают готовый disclosure-виджет с клавиатурой, состоянием и, что приятно, находимостью через Ctrl + F в современных браузерах:

Самописный аккордеон на div придётся снабжать aria-expanded, aria-controls, обработчиками клавиш и синхронизацией состояния — четыре места, где можно ошибиться, вместо нуля. Подробнее — в статье про доступные компоненты.

Язык документа. Один атрибут <html lang="ru"> решает, будет ли страница вообще произносима: без него скринридер прочитает русский текст английским синтезатором и получится бессмысленный набор звуков. Критерий 3.1.1 Language of Page, уровень A. Для вставок на другом языке — <span lang="en">continuous delivery</span>.

Карта семантики

Полный справочник элементов с их ролями и ограничениями — на MDN и в ARIA in HTML, где для каждого тега указано, какие ARIA-роли ему разрешено переопределять, а какие нет.

Семантика в компонентных фреймворках

Div-суп — не глупость предшественников, а наследие эпохи: в HTML 4.01 никаких header и nav просто не было, ARIA появилась как заплатка поверх уже написанного, и только к 2014 году HTML5 стал Рекомендацией W3C. Второй источник — компонентные фреймворки: когда UI собирается из <Card>, <Row>, <Stack>, легко потерять из виду, во что они разворачиваются. Дизайн-система, у которой <Button> рендерит <div>, тиражирует одну ошибку на сотни экранов.

Масштаб виден по ежегодному отчёту WebAIM Million: автоматически обнаружимые нарушения WCAG находятся примерно на 95% главных страниц из топ-миллиона, и лидируют там ровно семантические промахи — отсутствующий alt, пустые ссылки и кнопки, поля без подписей, незаданный lang.

React: не прячьте тег

Плохой компонент навязывает div. Хороший — прокидывает элемент наружу и не мешает передать пропсы:

import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';

type ButtonProps<T extends ElementType> = { as?: T; children: ReactNode }
  & Omit<ComponentPropsWithoutRef<T>, 'as' | 'children'>;

// Полиморфный компонент: ссылка остаётся ссылкой, кнопка — кнопкой
export function Button<T extends ElementType = 'button'>({ as, children, ...rest }: ButtonProps<T>) {
  const Tag = as ?? 'button';
  // У нативной button тип по умолчанию submit — внутри формы это неприятный сюрприз
  const typeProp = Tag === 'button' ? { type: 'button' as const } : {};
  return <Tag className="btn" {...typeProp} {...rest}>{children}</Tag>;
}

// <Button onClick={addToCart}>Купить</Button>
// <Button as="a" href="/pricing">Тарифы</Button>

Две ловушки, о которых стоит помнить отдельно. Первая: type по умолчанию у <button>submit, поэтому кнопка «Показать пароль» внутри формы без type="button" отправит форму. Вторая: <div> внутри <p> невалиден — браузер закроет параграф раньше, и структура поедет; компоненты, которые рендерят блочные обёртки, нельзя вставлять в текстовые. Об устройстве React-компонентов подробнее — в треке фронтенда, «Основы React»; базовая семантика HTML разобрана там же в статье «Семантика HTML».

Web Components: роль через ElementInternals

Кастомный элемент по умолчанию не имеет ни роли, ни фокусируемости. Современный способ их выдать — ElementInternals:

class ToggleSwitch extends HTMLElement {
  static formAssociated = true;              // участвует в отправке формы
  #i;
  constructor() {
    super();
    this.#i = this.attachInternals();
    this.#i.role = 'switch';                 // роль в дереве доступности
    this.#i.ariaChecked = 'false';           // состояние
  }
  connectedCallback() {
    if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
    this.addEventListener('click', () => this.toggle());
    this.addEventListener('keydown', (e) => {
      if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); this.toggle(); }
    });
  }
  toggle() {
    const next = this.#i.ariaChecked !== 'true';
    this.#i.ariaChecked = String(next);
    this.#i.setFormValue(next ? 'on' : null);
    this.dispatchEvent(new Event('change', { bubbles: true }));
  }
}
customElements.define('toggle-switch', ToggleSwitch);

Соотношение сил видно и здесь: два десятка строк на то, что <input type="checkbox" role="switch"> делает из коробки. Свой элемент оправдан, только когда нативного аналога действительно нет.

Практика: проверить семантику за пять минут

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

Минута 1. Только клавиатура. Уберите руку с мыши: Tab вперёд, Shift + Tab назад. Всё интерактивное должно быть достижимо, фокус — виден, порядок — совпадать с визуальным. Если по «кнопке» нельзя пройти табом, это div.

Минута 2. Консольный экспресс-аудит. Вставьте в DevTools:

const q = (sel) => document.querySelectorAll(sel).length;
console.table({
  'кликабельные div и span': q('div[onclick], span[onclick], div[role=button], span[role=button]'),
  'ссылки без href': q('a:not([href])'),
  'ссылки-заглушки href="#"': q('a[href="#"]'),
  'картинки без alt': q('img:not([alt])'),
  'поля без подписи': [...document.querySelectorAll('input:not([type=hidden]),select,textarea')]
    .filter((el) => !el.labels?.length && !el.getAttribute('aria-label')
                    && !el.getAttribute('aria-labelledby')).length,
  'h1 на странице': q('h1'),
  'есть main': q('main, [role=main]'),
  'lang у html': document.documentElement.lang || 'НЕ ЗАДАН',
});

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

Минута 3. Дерево доступности. Firefox DevTools → вкладка «Специальные возможности» → включить. Сверните дерево до верхнего уровня: должны быть видны banner, navigation, main, complementary, contentinfo. Если вместо них цепочка section и generic, ориентиров нет. В Chrome то же самое — в Elements → панель Accessibility → «Full accessibility tree».

Минута 4. Оглавление. Выполните скрипт из раздела про заголовки и прочитайте вывод: он должен читаться как план статьи.

Минута 5. Послушать. Включите скринридер и пройдите один короткий сценарий. На Windows — бесплатный NVDA, запуск Ctrl + Alt + N, выключение Insert + Q; на macOS — VoiceOver, Cmd + F5; на Android — TalkBack. Первый опыт будет неудобным: это нормально, вы пользуетесь незнакомым инструментом, а не «примеряете чужие трудности». Режимы чтения и горячие клавиши разобраны в статье про скринридеры.

Вот как звучит одна и та же карточка товара в двух вариантах вёрстки:

DIV-СУП                              СЕМАНТИЧНАЯ ВЁРСТКА
«графическое изображение»            «Ноутбук ProBook 14, заголовок уровня 3»
«Ноутбук ProBook 14»                 «изображение: ноутбук ProBook 14, крышка открыта»
«89 990 рублей»                      «89 990 рублей»
«Купить»                             «Купить, кнопка»
«Подробнее»                          «Подробнее о ноутбуке ProBook 14, ссылка»

клавиша B не находит ничего,         B находит кнопку, K — ссылку, H — заголовок,
Tab проходит карточку насквозь       порядок Tab совпадает с визуальным

Второй вариант отличается от первого выбором тегов и одним осмысленным alt.

Типичные ошибки: чек-лист для ревью

  1. <div onclick> вместо <button> — самая частая и самая дорогая ошибка.
  2. <a> без href: не фокусируется, роли нет. И зеркальная — кнопка внутри ссылки или ссылка внутри кнопки: невалидная вложенность, непредсказуемый фокус.
  3. Заголовок выбран по размеру шрифта — оглавление превращается в кашу.
  4. <section> без имени в надежде получить ориентир: получается generic.
  5. Двадцать ссылок «Подробнее» на одной странице: в списке ссылок скринридера они неразличимы. Лечится переписыванием текста или уточняющим aria-label.
  6. Пропущенный alt и, наоборот, alt="картинка" — обе крайности бесполезны.
  7. lang не задан или задан неверно (lang="en" на русской странице).
  8. Плейсхолдер вместо <label>: подпись исчезает при вводе; подробности — в статье про формы.
  9. display: none для текста, нужного скринридеру. Такой текст скрыт от всех — нужен класс visually-hidden.
  10. tabindex больше нуля ломает естественный порядок обхода почти всегда; таблица без <th> и <caption> делает данные нечитаемыми при навигации по ячейкам.

Что это даёт, кроме доступности

Хороший аргумент в разговоре с командой, которой «некогда заниматься доступностью»: семантика окупается сразу в нескольких местах.

  • Тесты устойчивее. getByRole('button', { name: /купить/i }) не сломается от переименования CSS-класса, в отличие от querySelector('.btn-primary'). Про подход — в треке тестирования, «Ручное тестирование».
  • Роботы, превью соцсетей, режим чтения, переводчики страниц и автозаполнение опираются на теги и autocomplete — на семантичной разметке всё это работает само.
  • Новые люди в команде быстрее читают вёрстку: <nav> понятнее, чем <div class="n-wrap">.
  • Меньше кода. Каждый нативный элемент — это десятки строк, которые вы не написали.

Мини-итог

  • Дерево доступности строится из тегов. Не написали смысл в разметке — его неоткуда взять дальше по конвейеру.
  • У каждого элемента должны быть роль, имя, состояние и свойства; нативные элементы дают их бесплатно.
  • div с обработчиком клика теряет восемь механизмов сразу. Догнать нативную кнопку вручную можно, но дорого и неточно.
  • Ориентиры (header, nav, main, aside, footer) — карта страницы, которой пользуются ежедневно. Весь контент внутри ориентиров, одноимённые — с разными именами.
  • Заголовки задают оглавление, а не размер шрифта: уровни не пропускаем, h1 один.
  • CSS умеет ломать семантику: display: none, display: contents, list-style: none в WebKit.
  • Пятиминутная ручная проверка — клавиатура, консольный скрипт, дерево доступности, оглавление, скринридер — ловит подавляющее большинство проблем этого слоя.

Хорошая семантика — не одолжение кому-то, а обычный признак аккуратно сделанного продукта: такой же, как отсутствие ошибок в консоли или разумные имена переменных.

Источники

Что дальше

Мы выжали максимум из нативной семантики. Но остаются виджеты, которых в HTML просто нет: табы, комбобоксы, деревья, тулбары. Для них существует WAI-ARIA — способ достроить роли, свойства и состояния там, где тегов не хватает, и одновременно самый лёгкий способ сделать интерфейс хуже, чем он был. Разбираемся, когда ARIA нужна, а когда вредна:

ARIA: роли, свойства, состояния и правило «лучше без ARIA»

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов