Как делать11 октября 2026 г.8 мин чтения
Вёрстка: HTML и CSSШаг 7 из 7

Доступность в коде: семантика, фокус, ARIA

Коротко

Доступный интерфейс начинается с нативных элементов: <button>, <a>, <label>, <dialog> уже умеют фокус, клавиатуру и правильные роли. ARIA нужна там, где нативного элемента нет, — например, для вкладок, — и тогда вы обязаны сами реализовать клавиатуру по шаблону WAI-ARIA APG. Проверка — автоматическая (axe в тестах Playwright) плюс ручная: axe находит только часть проблем, а клавиатурные ловушки и потерянный фокус ловят тесты клавиатуры и человек.

РуководствоУровень: СреднийДоступность вебаПрактика: ~1–2 ч

После материала вы сможете

  • пройти страницу с клавиатуры и найти элементы, до которых не доходит Tab или на которых не виден фокус
  • реализовать по WAI-ARIA APG раскрывающееся меню, вкладки с roving tabindex и модальный диалог на <dialog> с возвратом фокуса
  • выбрать между нативным элементом, aria-label, aria-labelledby, aria-describedby и живой областью для конкретной задачи
  • написать тесты Playwright на фокус и клавиатуру и прогнать axe через @axe-core/playwright в нескольких состояниях интерфейса

Проверено на

HTML Living Standard (dialog, showModal)WAI-ARIA APG (dialog, tabs, disclosure)WCAG 2.2Playwright 1.64 (Chromium 156)@axe-core/playwright 4.13 (axe-core 4.13)Vite 8.3Node.js 24 LTS (24.18)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Первое правило ARIA: нативный элемент лучше
  2. 02Клавиатура: порядок Tab, tabindex и видимый фокус
  3. 03Шаблоны APG в коде: меню, вкладки, диалог
  4. 04Имена, описания и живые области
  5. 05Автоматическая проверка: Playwright и axe
  6. 06Ручная проверка: минимальный сценарий

Первое правило ARIA: нативный элемент лучше

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

ARIA (Accessible Rich Internet Applications) — набор атрибутов role и aria-*, которые сообщают вспомогательным технологиям, чем является элемент и в каком он состоянии. W3C в документе «Using ARIA» формулирует правила её использования. Первое: если есть нативный элемент HTML с нужной семантикой и поведением, используйте его, а не перепрофилируйте другой элемент ролью ARIA. Второе: не меняйте нативную семантику без крайней необходимости. Третье: все интерактивные ARIA-элементы должны работать с клавиатуры. Четвёртое: не ставьте role="presentation" и aria-hidden="true" на фокусируемый элемент.

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

before.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Coffee House — before</title>
    <style>
      body { font-family: system-ui, sans-serif; margin: 0 1rem; }
      *:focus { outline: none; }
      .muted { color: #aaa; }
      .tab { display: inline-block; padding: 4px 8px; cursor: pointer; }
    </style>
  </head>
  <body>
    <div class="header">
      <div class="menu-toggle" onclick="document.querySelector('.menu').hidden = !document.querySelector('.menu').hidden">☰</div>
      <ul class="menu" hidden>
        <li><a href="#drinks">Drinks</a></li>
        <li><a href="#delivery">Delivery</a></li>
      </ul>
      <button class="search"><svg width="16" height="16" viewBox="0 0 16 16"><circle cx="7" cy="7" r="5" fill="none" stroke="black" /></svg></button>
    </div>
    <h1>Coffee House</h1>
    <img src="/latte.svg" />
    <div class="tabs">
      <div class="tab" onclick="">Coffee</div>
      <div class="tab" onclick="">Tea</div>
    </div>
    <p class="muted">Open daily 8:00–22:00</p>
    <span>Phone</span> <input type="tel" />
    <div class="button" onclick="alert('booked')">Book a table</div>
  </body>
</html>
Недоступная версия: div вместо кнопок, нет подписей и alt, фокус спрятан

<div onclick> для меню, вкладок и «Book a table»

Чем плохо
Не попадает в порядок Tab, не нажимается Enter и Space, не объявляется как кнопка
Исправление
<button type="button">

<button> только с SVG внутри

Чем плохо
У кнопки нет доступного имени — скринридер скажет просто «кнопка»
Исправление
Текст в кнопке или aria-label="Search"

<span>Phone</span> <input>

Чем плохо
Поле не связано с подписью
Исправление
<label for="…">

<img> без alt

Чем плохо
Скринридер прочитает имя файла или пропустит смысл
Исправление
alt с содержанием или alt="" для декора

*:focus { outline: none; }

Чем плохо
Пользователь клавиатуры не видит, где он
Исправление
Свой стиль через :focus-visible

Клавиатура: порядок Tab, tabindex и видимый фокус

Порядок Tab повторяет порядок элементов в DOM. Поэтому первый шаг — правильная разметка в правильной последовательности, а не перестановка порядка через CSS. Атрибут tabindex используют в трёх значениях, и только два из них полезны:

tabindex="0"

Что делает
Добавляет элемент в порядок Tab по его месту в DOM
Когда использовать
Панель вкладок без фокусируемого содержимого, свой виджет с ролью ARIA

tabindex="-1"

Что делает
Убирает из порядка Tab, но разрешает element.focus() из кода
Когда использовать
Неактивные вкладки (roving tabindex), заголовок, на который переводим фокус

tabindex="1" и больше

Что делает
Ставит элемент раньше всех остальных
Когда использовать
Практически никогда: порядок перестаёт совпадать с порядком на экране

Видимый фокус — требование WCAG 2.2 (критерий 2.4.7, уровень AA). Удалять outline без замены нельзя. Псевдокласс :focus-visible показывает рамку, когда браузер считает, что пользователю она нужна, — при навигации с клавиатуры, — и обычно не показывает её после клика мышью. Весь CSS исправленной страницы:

styles.css
body { font-family: system-ui, sans-serif; color: #1f2328; background: #fff; margin: 0 1rem; }
button, input { font: inherit; }
:focus-visible { outline: 3px solid #1a56db; outline-offset: 2px; }
[role="tab"][aria-selected="true"] { border-bottom: 3px solid currentColor; }
dialog::backdrop { background: rgb(0 0 0 / 0.5); }
.error { color: #b42318; }
#site-menu a { display: inline-block; padding: 8px 4px; }
Фокус-рамка через :focus-visible, видимое выделение выбранной вкладки

Шаблоны APG в коде: меню, вкладки, диалог

Для составных виджетов W3C ведёт руководство WAI-ARIA Authoring Practices Guide (APG): какие роли и атрибуты ставить и какие клавиши должны работать. Берём три шаблона. Выпадающее меню навигации — это disclosure (раскрывающийся блок), а не role="menu": APG прямо объясняет, что роль menu обещает сложное поведение меню приложения, которое обычной навигации не нужно. Вкладкам нативного аналога нет — здесь ARIA обязательна. Модальное окно — нативный <dialog>.

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Coffee House — accessible version</title>
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <header>
      <nav aria-label="Main">
        <button type="button" id="menu-button" aria-expanded="false" aria-controls="site-menu">
          Menu
        </button>
        <ul id="site-menu" hidden>
          <li><a href="#drinks">Drinks</a></li>
          <li><a href="#delivery">Delivery</a></li>
          <li><a href="#contacts">Contacts</a></li>
        </ul>
      </nav>
    </header>

    <main>
      <h1>Coffee House</h1>

      <section id="drinks">
        <h2 id="drinks-heading">Drinks</h2>
        <div role="tablist" aria-labelledby="drinks-heading">
          <button type="button" role="tab" id="tab-coffee" aria-selected="true" aria-controls="panel-coffee">Coffee</button>
          <button type="button" role="tab" id="tab-tea" aria-selected="false" aria-controls="panel-tea" tabindex="-1">Tea</button>
          <button type="button" role="tab" id="tab-desserts" aria-selected="false" aria-controls="panel-desserts" tabindex="-1">Desserts</button>
        </div>
        <div role="tabpanel" id="panel-coffee" aria-labelledby="tab-coffee" tabindex="0">
          <p>Espresso, flat white, latte.</p>
        </div>
        <div role="tabpanel" id="panel-tea" aria-labelledby="tab-tea" tabindex="0" hidden>
          <p>Green, black, herbal.</p>
        </div>
        <div role="tabpanel" id="panel-desserts" aria-labelledby="tab-desserts" tabindex="0" hidden>
          <p>Cheesecake, croissant.</p>
        </div>
      </section>

      <section id="delivery">
        <h2>Book a table</h2>
        <button type="button" id="book-button">Book a table</button>
        <p id="booking-status" role="status" aria-live="polite"></p>
      </section>
    </main>

    <dialog id="book-dialog" aria-labelledby="book-title">
      <h2 id="book-title">Book a table</h2>
      <form id="book-form" novalidate>
        <label for="book-name">Name</label>
        <input id="book-name" name="name" autocomplete="name" />

        <label for="book-phone">Phone</label>
        <input
          id="book-phone"
          name="phone"
          type="tel"
          autocomplete="tel"
          aria-describedby="book-phone-hint book-phone-error"
        />
        <p id="book-phone-hint">We will only call to confirm the booking.</p>
        <p id="book-phone-error" class="error"></p>

        <button type="submit">Confirm</button>
        <button type="button" id="book-cancel">Cancel</button>
      </form>
    </dialog>

    <footer id="contacts"><p>Almaty, open daily 8:00–22:00</p></footer>
    <script type="module" src="/app.js"></script>
  </body>
</html>
Исправленная страница: кнопка-disclosure, вкладки по APG, нативный dialog, живая область
app.js
// 1. Disclosure: the site menu
const menuButton = document.querySelector("#menu-button");
const menu = document.querySelector("#site-menu");

function setMenuOpen(open) {
  menuButton.setAttribute("aria-expanded", String(open));
  menu.hidden = !open;
}

menuButton.addEventListener("click", () => {
  setMenuOpen(menuButton.getAttribute("aria-expanded") !== "true");
});

menuButton.parentElement.addEventListener("keydown", (event) => {
  if (event.key === "Escape" && !menu.hidden) {
    setMenuOpen(false);
    menuButton.focus();
  }
});

// 2. Tabs: roving tabindex, arrows, Home/End, automatic activation
const tabs = [...document.querySelectorAll('[role="tab"]')];

function selectTab(tab) {
  for (const t of tabs) {
    const selected = t === tab;
    t.setAttribute("aria-selected", String(selected));
    t.tabIndex = selected ? 0 : -1;
    document.getElementById(t.getAttribute("aria-controls")).hidden = !selected;
  }
  tab.focus();
}

for (const tab of tabs) {
  tab.addEventListener("click", () => selectTab(tab));
  tab.addEventListener("keydown", (event) => {
    const i = tabs.indexOf(tab);
    const next = {
      ArrowRight: tabs[(i + 1) % tabs.length],
      ArrowLeft: tabs[(i - 1 + tabs.length) % tabs.length],
      Home: tabs[0],
      End: tabs[tabs.length - 1],
    }[event.key];
    if (next) {
      event.preventDefault();
      selectTab(next);
    }
  });
}

// 3. Modal dialog: native <dialog> + showModal()
const bookButton = document.querySelector("#book-button");
const dialog = document.querySelector("#book-dialog");
const form = document.querySelector("#book-form");
const phone = document.querySelector("#book-phone");
const phoneError = document.querySelector("#book-phone-error");
const bookingStatus = document.querySelector("#booking-status");

bookButton.addEventListener("click", () => dialog.showModal());
document.querySelector("#book-cancel").addEventListener("click", () => dialog.close());

form.addEventListener("submit", (event) => {
  event.preventDefault();
  if (!phone.value.trim()) {
    phone.setAttribute("aria-invalid", "true");
    phoneError.textContent = "Enter a phone number.";
    phone.focus();
    return;
  }
  phone.removeAttribute("aria-invalid");
  phoneError.textContent = "";
  dialog.close();
  bookingStatus.textContent = "Table booked. We will call you to confirm.";
});
Поведение: Escape в меню, стрелки и Home/End во вкладках, showModal() для диалога

Disclosure (меню)

Роли и атрибуты
<button> с aria-expanded и aria-controls; список ссылок с hidden
Клавиатура
Enter и Space переключают; Escape закрывает и возвращает фокус на кнопку (как в примере APG)

Tabs

Роли и атрибуты
tablist с подписью, tab с aria-selected и aria-controls, tabpanel с aria-labelledby и tabindex="0"
Клавиатура
Tab входит на выбранную вкладку и выходит на панель; ← → по кругу; Home/End — первая и последняя

Dialog (modal)

Роли и атрибуты
<dialog> с aria-labelledby на заголовок; showModal() делает его модальным
Клавиатура
Фокус внутрь при открытии, Tab не уходит на страницу, Escape закрывает, фокус возвращается на кнопку
  • Вкладки используют roving tabindex: у выбранной tabindex="0", у остальных -1. Вся группа — одна остановка Tab, а между вкладками ходят стрелками. APG рекомендует активировать вкладку сразу при фокусе, если панель показывается без задержки — как здесь.
  • showModal() по MDN делает всё вне диалога инертным, закрывает его по Escape и неявно задаёт aria-modal="true". По спецификации HTML при открытии фокус уходит на элемент с autofocus или на первый подходящий элемент внутри, а при закрытии модального диалога возвращается на элемент, который был в фокусе до открытия. В Chromium 156 это подтвердил тест ниже — без строчки кода на возврат фокуса.
  • Самописный модальный <div role="dialog"> возможен, но тогда ловушку фокуса, Escape, инертность фона и возврат фокуса придётся писать и тестировать самостоятельно — шаблон APG описывает, что именно.

Имена, описания и живые области

Каждый интерактивный элемент должен иметь доступное имя — то, что скринридер произнесёт и по чему тесты находят элемент. Источники имени по убыванию предпочтительности: видимый текст элемента или связанный <label>; aria-labelledby со ссылкой на видимый текст (так подписаны диалог, список вкладок и панели); aria-label, когда видимого текста нет, — например, у кнопки-иконки. Описание — дополнительный текст, который читается после имени: подсказка формата и ошибка поля связываются через aria-describedby.

Живая область (live region) озвучивает изменения на странице, на которые не переходил фокус. В примере после бронирования текст появляется в <p role="status" aria-live="polite">. MDN советует две вещи: держать живую область в исходной разметке пустой и менять только её содержимое, а к role="status" для совместимости добавлять избыточный aria-live="polite". polite ждёт паузы в речи скринридера; assertive перебивает его, и MDN рекомендует его только для срочных сообщений.

Автоматическая проверка: Playwright и axe

axe-core — движок автоматической проверки доступности от Deque, @axe-core/playwright — его обёртка для Playwright. Отдельно ставить Lighthouse не обязательно: его аудит доступности взвешивает проверки по оценкам влияния axe, и каждый аудит ссылается на правило axe. Проект для проверки — статическая страница, которую Playwright сам поднимает через Vite перед тестами.

bash
npm init -y
npm pkg set type=module
npm install -D @playwright/test @axe-core/playwright vite typescript @types/node
npx playwright install chromium
Временный проект для тестов доступности
playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "tests",
  use: { baseURL: "http://127.0.0.1:4173" },
  webServer: {
    command: "npx vite --host 127.0.0.1 --port 4173 --strictPort",
    url: "http://127.0.0.1:4173",
    reuseExistingServer: true,
  },
});
webServer запускает Vite и ждёт, пока страница ответит
tests/a11y.spec.ts
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";

const WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];

async function expectNoAxeViolations(page: Page) {
  const results = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
  expect(results.violations).toEqual([]);
}

let errors: string[] = [];

test.beforeEach(async ({ page }) => {
  errors = [];
  page.on("console", (msg) => {
    if (msg.type() === "error") errors.push(msg.text());
  });
  page.on("pageerror", (error) => errors.push(error.message));
  await page.goto("/");
});

test.afterEach(() => {
  expect(errors).toEqual([]);
});

test("axe: no violations in closed, menu-open and dialog-open states", async ({ page }) => {
  await expectNoAxeViolations(page);

  await page.getByRole("button", { name: "Menu" }).click();
  await expectNoAxeViolations(page);

  await page.getByRole("button", { name: "Book a table" }).click();
  await expectNoAxeViolations(page);
});

test("disclosure menu: Enter opens, Escape closes and returns focus", async ({ page }) => {
  const toggle = page.getByRole("button", { name: "Menu" });

  await page.keyboard.press("Tab");
  await expect(toggle).toBeFocused();

  await page.keyboard.press("Enter");
  await expect(toggle).toHaveAttribute("aria-expanded", "true");
  await page.keyboard.press("Tab");
  await expect(page.getByRole("link", { name: "Drinks" })).toBeFocused();

  await page.keyboard.press("Escape");
  await expect(toggle).toHaveAttribute("aria-expanded", "false");
  await expect(page.getByRole("link", { name: "Drinks" })).toBeHidden();
  await expect(toggle).toBeFocused();
});

test("tabs: one tab stop, arrows and Home/End move selection", async ({ page }) => {
  const coffee = page.getByRole("tab", { name: "Coffee" });
  const tea = page.getByRole("tab", { name: "Tea" });
  const desserts = page.getByRole("tab", { name: "Desserts" });

  await page.keyboard.press("Tab"); // Menu
  await page.keyboard.press("Tab"); // the selected tab
  await expect(coffee).toBeFocused();

  await page.keyboard.press("ArrowRight");
  await expect(tea).toBeFocused();
  await expect(tea).toHaveAttribute("aria-selected", "true");
  await expect(page.getByRole("tabpanel", { name: "Tea" })).toBeVisible();
  await expect(page.getByRole("tabpanel", { name: "Coffee" })).toBeHidden();

  await page.keyboard.press("End");
  await expect(desserts).toBeFocused();
  await page.keyboard.press("ArrowRight");
  await expect(coffee).toBeFocused();

  await page.keyboard.press("Tab"); // leaves the tablist for the panel
  await expect(page.getByRole("tabpanel", { name: "Coffee" })).toBeFocused();
});

test("dialog: focus moves in, stays in, Escape returns it", async ({ page }) => {
  const open = page.getByRole("button", { name: "Book a table" });
  const dialog = page.getByRole("dialog", { name: "Book a table" });

  await open.focus();
  await page.keyboard.press("Enter");
  await expect(dialog).toBeVisible();
  await expect(page.getByLabel("Name")).toBeFocused();

  for (let i = 0; i < 6; i++) {
    await page.keyboard.press("Tab");
    const outside = await page.evaluate(() => {
      const active = document.activeElement;
      const dlg = document.querySelector("dialog");
      return active !== document.body && !dlg?.contains(active);
    });
    expect(outside).toBe(false);
  }

  await page.keyboard.press("Escape");
  await expect(dialog).toBeHidden();
  await expect(open).toBeFocused();
});

test("dialog form: error is announced, success goes to the live region", async ({ page }) => {
  await page.getByRole("button", { name: "Book a table" }).click();
  const phone = page.getByLabel("Phone");

  await page.getByRole("button", { name: "Confirm" }).click();
  await expect(phone).toBeFocused();
  await expect(phone).toHaveAttribute("aria-invalid", "true");
  await expect(phone).toHaveAccessibleDescription(/Enter a phone number\./);

  await phone.fill("+7 701 123 45 67");
  await page.getByRole("button", { name: "Confirm" }).click();
  await expect(page.getByRole("dialog")).toBeHidden();
  await expect(page.getByRole("status")).toHaveText("Table booked. We will call you to confirm.");
  await expect(page.getByRole("button", { name: "Book a table" })).toBeFocused();
});
axe в трёх состояниях страницы и клавиатурные сценарии для меню, вкладок и диалога; ошибки консоли валят тест
  • Теги wcag2a … wcag22aa ограничивают проверку правилами WCAG уровней A и AA; набор из документации Playwright дополнен wcag22aa.
  • axe проверяет только то, что сейчас в DOM и видно. Документация Playwright отдельно напоминает привести интерфейс в нужное состояние до analyze() — поэтому сканов три: всё закрыто, меню открыто, диалог открыт.
  • Клавиатурные тесты нажимают реальные клавиши и проверяют toBeFocused(), aria-expanded, aria-selected и видимость панелей.
  • toHaveAccessibleDescription проверяет, что ошибка действительно попала в описание поля, а не просто появилась рядом.
terminal
$ npx playwright test --reporter=list
Running 5 tests using 1 worker

  ok 1 tests\a11y.spec.ts:26:1 › axe: no violations in closed, menu-open and dialog-open states (943ms)
  ok 2 tests\a11y.spec.ts:36:1 › disclosure menu: Enter opens, Escape closes and returns focus (135ms)
  ok 3 tests\a11y.spec.ts:53:1 › tabs: one tab stop, arrows and Home/End move selection (128ms)
  ok 4 tests\a11y.spec.ts:77:1 › dialog: focus moves in, stays in, Escape returns it (154ms)
  ok 5 tests\a11y.spec.ts:101:1 › dialog form: error is announced, success goes to the live region (220ms)

  5 passed (6.1s)
Реальный вывод: 5 тестов прошли в Chromium

Первый прогон исправленной страницы был красным. axe нашёл нарушение target-size — критерий WCAG 2.2 2.5.8: ссылки раскрытого меню были высотой 21 px и стояли слишком близко друг к другу, а минимум — 24 × 24 CSS-пикселя или достаточный отступ. Ссылки внутри предложения критерий не затрагивает, а пункты меню — да. Исправление — одна строка CSS:

styles.css
/* WCAG 2.2, 2.5.8: a target is at least 24×24 CSS px */
#site-menu a { display: inline-block; padding: 8px 4px; }
Последняя строка styles.css — после неё axe-скан с открытым меню чистый

Теперь отчёт «до и после». Скрипт прогоняет axe по обеим страницам и пять раз нажимает Tab, записывая, где оказался фокус:

report.mjs
import AxeBuilder from "@axe-core/playwright";
import { chromium } from "@playwright/test";

const WCAG_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
const browser = await chromium.launch();
const context = await browser.newContext(); // AxeBuilder needs a page from a context

for (const path of ["/before.html", "/"]) {
  const page = await context.newPage();
  await page.goto(`http://127.0.0.1:4173${path}`);

  const { violations } = await new AxeBuilder({ page }).withTags(WCAG_TAGS).analyze();
  console.log(`\n${path}: ${violations.length} axe violation(s)`);
  for (const v of violations) console.log(`  ${v.id} (${v.impact}) × ${v.nodes.length}`);

  const stops = [];
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press("Tab");
    stops.push(
      await page.evaluate(() => {
        const el = document.activeElement;
        if (el === document.body) return "(page left)";
        const name = el.getAttribute("aria-label") || el.textContent.trim().slice(0, 20);
        return `${el.tagName.toLowerCase()} "${name}"`;
      }),
    );
  }
  console.log(`  Tab ×5: ${stops.join(" → ")}`);
  await page.close();
}
await browser.close();
Отчёт: нарушения axe и маршрут фокуса для двух страниц
terminal
$ node report.mjs

/before.html: 4 axe violation(s)
  button-name (critical) × 1
  color-contrast (serious) × 1
  image-alt (critical) × 1
  label (critical) × 1
  Tab ×5: button "" → input "" → (page left) → button "" → input ""

/: 0 axe violation(s)
  Tab ×5: button "Menu" → button "Coffee" → div "Espresso, flat white" → button "Book a table" → (page left)
Реальный вывод при запущенном Vite (npx vite --host 127.0.0.1 --port 4173)

Это главный урок отчёта. axe нашёл на «до»-странице четыре нарушения: кнопка без имени, недостаточный контраст, картинка без alt, поле без подписи. Но он промолчал о трёх самых серьёзных проблемах: меню, вкладки и «Book a table» недоступны с клавиатуры — Tab проходит только безымянную кнопку и поле и уходит со страницы. Не заметил он и outline: none. На исправленной странице Tab проходит меню, выбранную вкладку, её панель и кнопку бронирования.

Ручная проверка: минимальный сценарий

Автотесты закрепляют то, что вы уже проверили. Новый экран стоит один раз пройти руками. Минимальный сценарий занимает несколько минут:

  • Уберите мышь и пройдите страницу Tab и Shift+Tab: фокус виден всегда, порядок совпадает с порядком на экране, нет мест, где фокус пропадает
  • Все действия доступны с клавиатуры: Enter и Space нажимают кнопки, Escape закрывает меню и диалоги, стрелки работают во вкладках
  • После закрытия диалога или меню фокус возвращается туда, откуда его открыли
  • Включите скринридер — NVDA в Windows или VoiceOver в macOS и iOS — и пройдите ключевой сценарий: у каждой кнопки и поля понятное имя, ошибки и статусы озвучиваются
  • Увеличьте масштаб до 200% и проверьте, что содержимое не обрезается и не перекрывает фокус
  • Прогоните axe в каждом состоянии интерфейса, а не только при загрузке

APG предупреждает, что его примеры не обходят ошибки конкретных браузеров и скринридеров, и советует тестировать каждую комбинацию, важную для вашей аудитории. По нашему опыту, для сайтов малого бизнеса разумный минимум — Chrome с NVDA и Safari с VoiceOver на iPhone.

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    index.html, styles.css, app.js, playwright.config.ts, tests/a11y.spec.ts

    временный проект: tsc -p . (TypeScript 7.0.2) без ошибок; npx playwright test — 5/5 в headless Chromium 156 (Playwright 1.64.0, @axe-core/playwright 4.13.0 / axe-core 4.13.0, Vite 8.3.4, Node.js 24.18.0); 0 нарушений axe в трёх состояниях, 0 ошибок консоли

  • запущено, работает

    находка target-size до правки CSS

    первый прогон: axe target-size/target-offset на ссылках меню (21 px высоты); после padding 8px 4px — 0 нарушений

  • запущено, работает

    контрольная поломка: убран menuButton.focus() в обработчике Escape

    тест disclosure падает (toBeFocused: inactive), после отката 5/5

  • запущено, работает

    report.mjs + before.html

    node report.mjs при запущенном Vite: before — 4 нарушения (button-name, color-contrast, image-alt, label) и маршрут Tab без меню/вкладок/брони; after — 0. AxeBuilder требует page из browser.newContext() (иначе ошибка «Please use browser.newContext()»)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

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

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