Доступный интерфейс начинается с нативных элементов: <button>, <a>, <label>, <dialog> уже умеют фокус, клавиатуру и правильные роли. ARIA нужна там, где нативного элемента нет, — например, для вкладок, — и тогда вы обязаны сами реализовать клавиатуру по шаблону WAI-ARIA APG. Проверка — автоматическая (axe в тестах Playwright) плюс ручная: axe находит только часть проблем, а клавиатурные ловушки и потерянный фокус ловят тесты клавиатуры и человек.
После материала вы сможете
- пройти страницу с клавиатуры и найти элементы, до которых не доходит 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 г.
Содержание
Первое правило ARIA: нативный элемент лучше
О том, что закладывается в макет — контраст, размер целей, порядок и вид фокуса, — мы писали в уроке о доступности интерфейса для дизайнеров. Здесь — вторая половина работы: что делает разработчик, чтобы интерфейсом можно было пользоваться без мыши и со скринридером (программой, которая озвучивает страницу).
ARIA (Accessible Rich Internet Applications) — набор атрибутов role и aria-*, которые сообщают вспомогательным технологиям, чем является элемент и в каком он состоянии. W3C в документе «Using ARIA» формулирует правила её использования. Первое: если есть нативный элемент HTML с нужной семантикой и поведением, используйте его, а не перепрофилируйте другой элемент ролью ARIA. Второе: не меняйте нативную семантику без крайней необходимости. Третье: все интерактивные ARIA-элементы должны работать с клавиатуры. Четвёртое: не ставьте role="presentation" и aria-hidden="true" на фокусируемый элемент.
Вот типичная «до»-версия страницы кофейни. Внешне она работает мышью, но почти каждый элемент нарушает какое-то из правил:
<!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 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 исправленной страницы:
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; }Шаблоны APG в коде: меню, вкладки, диалог
Для составных виджетов W3C ведёт руководство WAI-ARIA Authoring Practices Guide (APG): какие роли и атрибуты ставить и какие клавиши должны работать. Берём три шаблона. Выпадающее меню навигации — это disclosure (раскрывающийся блок), а не role="menu": APG прямо объясняет, что роль menu обещает сложное поведение меню приложения, которое обычной навигации не нужно. Вкладкам нативного аналога нет — здесь ARIA обязательна. Модальное окно — нативный <dialog>.
<!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>// 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.";
});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 перед тестами.
npm init -y
npm pkg set type=module
npm install -D @playwright/test @axe-core/playwright vite typescript @types/node
npx playwright install chromiumimport { 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,
},
});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();
});- Теги
wcag2a…wcag22aaограничивают проверку правилами WCAG уровней A и AA; набор из документации Playwright дополненwcag22aa. - axe проверяет только то, что сейчас в DOM и видно. Документация Playwright отдельно напоминает привести интерфейс в нужное состояние до
analyze()— поэтому сканов три: всё закрыто, меню открыто, диалог открыт. - Клавиатурные тесты нажимают реальные клавиши и проверяют
toBeFocused(),aria-expanded,aria-selectedи видимость панелей. toHaveAccessibleDescriptionпроверяет, что ошибка действительно попала в описание поля, а не просто появилась рядом.
$ 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)Первый прогон исправленной страницы был красным. axe нашёл нарушение target-size — критерий WCAG 2.2 2.5.8: ссылки раскрытого меню были высотой 21 px и стояли слишком близко друг к другу, а минимум — 24 × 24 CSS-пикселя или достаточный отступ. Ссылки внутри предложения критерий не затрагивает, а пункты меню — да. Исправление — одна строка 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; }Теперь отчёт «до и после». Скрипт прогоняет axe по обеим страницам и пять раз нажимает Tab, записывая, где оказался фокус:
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();$ 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)Это главный урок отчёта. 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.