← Valtive QA Quiz quiz.valtive.io/notes/a11y valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA-інженерів, яких на співбесіді питають про доступність: від мануальних тестувальників, що вперше відкривають NVDA, до автоматизаторів, які вбудовують axe у Playwright, і senior-кандидатів, яким треба провести аудит чи впровадити a11y у команді з легасі-продуктом. Усі 40 питань квізу «Accessibility» покриті картками, а майже кожна картка має приклад: поганий і добрий HTML, тест, CSS, скрипт клавіатурної перевірки або шаблон баг-репорту.
For QA engineers who get accessibility questions in interviews: from manual testers opening NVDA for the first time, to automation engineers wiring axe into Playwright, to senior candidates who have to run an audit or bring a11y into a team with a legacy product. All 40 questions of the Accessibility quiz are covered by cards, and almost every card carries an example: bad and good HTML, a test, CSS, a keyboard check script or a bug report template.
Як користуватисьHow to use it
Прочитай картку, а потім повтори перевірку руками: пройди форму лише клавіатурою, увімкни скрінрідер, збільш масштаб до 400 %, запусти тест з axe з картки на своєму проєкті. Номери критеріїв WCAG (наприклад 1.4.3 чи 2.4.7) наведені навмисно: на співбесіді й у баг-репорті вони показують, що ти знаєш стандарт, а не лише інструмент. Потім закрий картку й розкажи відповідь уголос за блоком 🗣 Як читати вголос: так ти тренуєш пояснення, а не впізнавання правильного варіанта.
Read a card, then repeat the check by hand: go through a form with the keyboard only, turn on a screen reader, zoom to 400 %, run the axe test from the card against your own project. The WCAG criterion numbers (such as 1.4.3 or 2.4.7) are there on purpose: in an interview and in a bug report they show that you know the standard, not just a tool. Then close the card and give the answer out loud using the 🗣 How to say it out loud block: that trains explaining rather than recognising the right option.
Три якорі — з них випливає більшість відповідейThree anchors — most answers follow from them
ЯКІР 1 · WCAG 2.2 і POURANCHOR 1 · WCAG 2.2 and POUR
Будь-який баг доступності зводиться до одного з чотирьох питань: чи можна це сприйняти, керувати цим, зрозуміти це і чи надійно це працює з допоміжними технологіями. Рівень AA WCAG 2.2 — практична ціль і для законів, і для команд.
Every accessibility bug comes down to one of four questions: can it be perceived, operated, understood, and does it work robustly with assistive technology. Level AA of WCAG 2.2 is the practical target for both laws and teams.
ЯКІР 2 · Accessible name і роліANCHOR 2 · Accessible name and roles
Скрінрідер не бачить пікселів: він читає дерево доступності, де в кожного елемента є роль, ім'я і стан. Нативний HTML дає їх безкоштовно, ARIA — лише коли нативного елемента немає.
A screen reader does not see pixels: it reads the accessibility tree, where every element has a role, a name and a state. Native HTML gives you these for free; ARIA is for when there is no native element.
ЯКІР 3 · Автоматизація + ручна перевіркаANCHOR 3 · Automation + manual checks
axe, Lighthouse і pa11y швидко ловлять контраст, порожні кнопки й поля без label, але лише частину критеріїв. Порядок фокусу, зрозумілість помилок і поведінку зі скрінрідером перевіряє людина.
axe, Lighthouse and pa11y quickly catch contrast, empty buttons and unlabelled fields, but only part of the criteria. Focus order, clear error messages and screen reader behaviour are checked by a person.
57
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
40/40
питань квізу покритоquiz questions covered
📚 8 РОЗДІЛІВ📚 8 SECTIONS
—
Junior
—
Middle
—
Senior
▶ 1 6
1 Що таке a11y, кому потрібна доступність і чому це частина якості, а не «фіча для меншості»? What is a11y, who needs accessibility, and why is it part of quality rather than a «feature for a minority»?
WCAG і закониWCAG and law Junior ▶

Що це. a11y — нумеронім слова accessibility: між «a» і «y» 11 літер. Доступність означає, що продуктом можуть користуватися люди з різними можливостями: незрячі й слабозорі (скрінрідер, збільшення), глухі (субтитри), люди з моторними обмеженнями (лише клавіатура, перемикачі, голосове керування), з когнітивними особливостями, епілепсією чи вестибулярними розладами.

What it is. a11y is a numeronym for accessibility: there are 11 letters between «a» and «y». Accessibility means people with different abilities can use the product: blind and low-vision users (screen reader, magnification), Deaf users (captions), people with motor impairments (keyboard only, switches, voice control), with cognitive differences, epilepsy or vestibular disorders.

Масштаб. За оцінкою ВООЗ, приблизно 1,3 млрд людей, тобто кожен шостий, мають суттєву інвалідність. До цього додаються тимчасові й ситуативні обмеження: зламана рука, яскраве сонце на екрані телефона, шумна вулиця без навушників, літній вік. Тому рішення «a11y потрібна кільком відсоткам» — помилка в оцінці аудиторії.

Scale. The WHO estimates about 1.3 billion people, roughly one in six, live with a significant disability. Add temporary and situational limitations on top: a broken arm, bright sun on a phone screen, a noisy street without headphones, old age. So the idea that «a11y is for a few percent» is a mistake in sizing the audience.

Чому це якість. Недоступна форма оплати для незрячого користувача — такий самий блокер, як кнопка, що не натискається. Користувач не завершить сценарій, бізнес втратить гроші, а в ЄС і США ще й отримає юридичний ризик. Бонусом доступність покращує SEO, автотести (локатори за ролями) і зручність для всіх: субтитри дивляться в метро, клавіатурою швидше заповнювати форми.

Why it is quality. A payment form a blind user cannot complete is the same blocker as a button that does not click. The user drops out of the flow, the business loses money, and in the EU and US there is legal risk on top. As a bonus, accessibility improves SEO, automated tests (role-based locators) and convenience for everyone: people watch captions on the metro and fill forms faster with the keyboard.

Типова помилка. Відкладати доступність «на потім» і перевіряти її один раз перед релізом. Виправлення семантики в готовому дизайн-системному компоненті коштує в рази дорожче, ніж правильна розмітка на етапі розробки.

The common mistake. Pushing accessibility «to later» and checking it once before a release. Fixing semantics in a finished design-system component costs many times more than correct markup during development.

Матриця «хто користувач → що перевіряє QA»
Matrix «who the user is → what QA checks»
# Користувач / технологія      -> Що перевіряю
Незрячий, скрінрідер           -> ролі, імена, порядок читання, оголошення змін
Слабозорий, збільшення 200-400% -> reflow без горизонтального скролу, контраст
Лише клавіатура / перемикач    -> усе досяжне через Tab, видимий фокус, немає пасток
Голосове керування             -> видимий текст кнопки = її accessible name
Глухий / в шумі                -> субтитри, текстова альтернатива звуковим сигналам
Епілепсія, вестибулярні розлади -> немає спалахів, prefers-reduced-motion
Когнітивні особливості         -> зрозумілі помилки, немає жорстких таймерів
# User / technology            -> What I check
Blind, screen reader           -> roles, names, reading order, announced changes
Low vision, zoom 200-400%      -> reflow without horizontal scroll, contrast
Keyboard only / switch         -> everything reachable via Tab, visible focus, no traps
Voice control                  -> visible button text = its accessible name
Deaf / noisy place             -> captions, text alternative for sound cues
Epilepsy, vestibular disorders -> no flashing, prefers-reduced-motion
Cognitive differences          -> clear errors, no hard timers
🔧 Розбір прикладу: Кожен рядок пов'язує реальну групу користувачів з конкретною перевіркою. Такою матрицею зручно починати тест-план: вона одразу показує, що одного запуску axe недостатньо, бо більшість рядків перевіряється руками.
🔧 Example breakdown: Each row links a real group of users to a concrete check. A matrix like this is a handy start for a test plan: it shows straight away that one axe run is not enough, because most rows are checked by hand.
💡 Простими словами: Доступність — це коли продуктом можна користуватися не лише мишею, очима і вухами «за замовчуванням».
💡 In plain words: Accessibility means the product works without assuming a mouse, perfect eyesight and perfect hearing.
🗣 Як читати вголос a11y — це скорочення від accessibility, і я сприймаю його як частину якості. Якщо незрячий користувач не може оплатити замовлення, для мене це блокер, а не косметика. Користувачів з інвалідністю близько мільярда трьохсот мільйонів, а ще є тимчасові обмеження, як зламана рука чи сонце на екрані. Тому в тест-плані я розписую, хто користувач і чим він користується, і з цього виводжу конкретні перевірки.
🗣 How to say it out loud a11y is short for accessibility, and I treat it as part of quality. If a blind user cannot pay for an order, that is a blocker for me, not a cosmetic issue. There are around one point three billion people with disabilities, plus temporary limits like a broken arm or sun glare on a screen. So in a test plan I write down who the user is and what they use, and derive concrete checks from that.
2 Які закони вимагають доступності (ADA, Section 508, EN 301 549, European Accessibility Act) і що вони означають для тестування? Which laws require accessibility (ADA, Section 508, EN 301 549, European Accessibility Act), and what do they mean for testing?
WCAG і закониWCAG and law Senior ▶

США. ADA (Americans with Disabilities Act) прямо не згадує вебсайти, але суди тлумачать сайти й застосунки бізнесу як «місця публічного користування»; щороку подаються тисячі позовів, відома справа — Robles v. Domino's. Section 508 зобов'язує федеральні агенції й постачальників для них відповідати WCAG 2.0 AA. Правило Міністерства юстиції 2024 року для сайтів органів штатів і місцевого самоврядування посилається на WCAG 2.1 AA.

United States. The ADA (Americans with Disabilities Act) does not mention websites explicitly, but courts treat business sites and apps as «places of public accommodation»; thousands of suits are filed every year, a well-known one being Robles v. Domino's. Section 508 requires federal agencies and their vendors to meet WCAG 2.0 AA. The 2024 Department of Justice rule for state and local government sites references WCAG 2.1 AA.

ЄС. European Accessibility Act (Директива 2019/882) застосовується з 28 червня 2025 року до багатьох продуктів і послуг для споживачів: e-commerce, банківські послуги, електронні книги, транспортні квитки, термінали. Технічна основа — гармонізований стандарт EN 301 549, чиї вимоги до вебу збігаються з WCAG рівня AA (чинна версія базується на WCAG 2.1, нова версія узгоджується з WCAG 2.2). Мікропідприємства, що надають послуги, мають виняток, а контроль і штрафи визначає кожна країна.

European Union. The European Accessibility Act (Directive 2019/882) applies from 28 June 2025 to many consumer products and services: e-commerce, banking, e-books, transport tickets, terminals. Its technical basis is the harmonised standard EN 301 549, whose web requirements match WCAG level AA (the current version is based on WCAG 2.1, and the new one aligns with WCAG 2.2). Micro-enterprises providing services are exempt, and each country sets its own enforcement and penalties.

Що це означає для QA. Юридична ціль майже завжди — WCAG AA, тому я тестую саме за ним і тегую автоматичні перевірки відповідно. Результат аудиту має бути відстежуваним: критерій, сторінка, статус. Для B2B-продажів потрібен VPAT/ACR, і QA дає для нього докази. Нарешті, тестую не лише веб: закон охоплює мобільні застосунки й документи.

What it means for QA. The legal target is almost always WCAG AA, so I test against it and tag automated checks accordingly. Audit results must be traceable: criterion, page, status. B2B sales need a VPAT/ACR, and QA supplies the evidence for it. Finally, I test more than the web: the law covers mobile apps and documents too.

Пастка. Вважати, що оверлей-віджет «доступності за один скрипт» робить сайт відповідним. Такі віджети не виправляють розмітку, а позови проти сайтів з ними теж подаються.

The trap. Believing that an overlay widget, «accessibility in one script», makes a site compliant. Such widgets do not fix the markup, and sites using them get sued as well.

Карта відповідності для тест-плану
Compliance map for a test plan
# Ринок  Норма                       Технічна планка     Що здає QA
США     ADA Title III (суди)        WCAG 2.x AA де-факто звіт аудиту, план виправлень
США     Section 508 (федеральні)    WCAG 2.0 AA         ACR на VPAT 508 edition
ЄС      EAA, з 28.06.2025           EN 301 549 ~ WCAG AA ACR на VPAT EU edition
Усі     Внутрішня політика          WCAG 2.2 AA         теги axe: wcag2a..wcag22aa

# Висновок для тестування: ціль = WCAG 2.2 AA,
# вона покриває вимоги 2.0 і 2.1 AA з запасом.
# Market Rule                        Technical bar        What QA delivers
US      ADA Title III (courts)      WCAG 2.x AA de facto audit report, fix plan
US      Section 508 (federal)       WCAG 2.0 AA          ACR on VPAT 508 edition
EU      EAA, from 28.06.2025        EN 301 549 ~ WCAG AA ACR on VPAT EU edition
All     Internal policy             WCAG 2.2 AA          axe tags: wcag2a..wcag22aa

# Takeaway for testing: target = WCAG 2.2 AA,
# it covers the 2.0 and 2.1 AA requirements with margin.
🔧 Розбір прикладу: WCAG 2.2 зворотно сумісний: кожен критерій 2.0 і 2.1 (окрім вилученого 4.1.1 Parsing) лишився, тому сайт, що відповідає 2.2 AA, відповідає й старшим версіям. Саме тому одна ціль закриває кілька юрисдикцій.
🔧 Example breakdown: WCAG 2.2 is backward compatible: every 2.0 and 2.1 criterion (except the removed 4.1.1 Parsing) is still there, so a site that meets 2.2 AA also meets the older versions. That is why one target covers several jurisdictions.
💡 Простими словами: Закони різні, але планка майже скрізь одна — WCAG рівня AA.
💡 In plain words: The laws differ, but the bar is almost always the same: WCAG level AA.
🗣 Як читати вголос Я б пояснив так: у США є ADA, під яку суди підводять сайти, і Section 508 для держзакупівель; в ЄС з двадцять восьмого червня 2025 року діє European Accessibility Act, а його технічна основа — EN 301 549, тобто по суті WCAG AA. Тому як QA я не вибираю між законами, а тестую за WCAG 2.2 AA, бо він покриває старші версії. І чесно кажу команді, що оверлей-віджет юридичного ризику не знімає.
🗣 How to say it out loud I would put it like this: in the US there is the ADA, which courts apply to websites, and Section 508 for government procurement; in the EU the European Accessibility Act has applied since the twenty-eighth of June 2025, and its technical basis is EN 301 549, which is essentially WCAG AA. So as QA I do not pick between laws, I test against WCAG 2.2 AA because it covers the older versions. And I tell the team honestly that an overlay widget does not remove the legal risk.
3 Що таке WCAG, як побудовані критерії успіху WCAG 2.2 і що означають рівні A, AA та AAA? What is WCAG, how are WCAG 2.2 success criteria organised, and what do levels A, AA and AAA mean?
WCAG і закониWCAG and law Junior ▶

Що це. WCAG (Web Content Accessibility Guidelines) — стандарт W3C для доступності вебконтенту. Актуальна рекомендація — WCAG 2.2 (жовтень 2023). Він технологічно нейтральний: описує результат для користувача, а не конкретний код, тому його застосовують і до мобільних застосунків та документів.

What it is. WCAG (Web Content Accessibility Guidelines) is the W3C standard for accessible web content. The current recommendation is WCAG 2.2 (October 2023). It is technology-neutral: it describes the outcome for the user rather than specific code, which is why it is also applied to mobile apps and documents.

Структура. 4 принципи (POUR) → 13 настанов (guidelines) → понад 80 критеріїв успіху (success criteria). Номер читається як принцип.настанова.критерій: 1.4.3 — принцип 1 «Perceivable», настанова 1.4 «Distinguishable», критерій 3 «Contrast (Minimum)». Тестують саме критерії: кожен сформульований так, щоб його можна було перевірити як «pass» або «fail». До стандарту додаються Understanding-документи (навіщо) і Techniques (як виконати та типові помилки, наприклад F65 — відсутній alt).

Structure. 4 principles (POUR) → 13 guidelines → more than 80 success criteria. A number reads as principle.guideline.criterion: 1.4.3 is principle 1 «Perceivable», guideline 1.4 «Distinguishable», criterion 3 «Contrast (Minimum)». Testing happens at the criterion level: each one is written so it can be checked as pass or fail. The standard comes with Understanding documents (the why) and Techniques (how to meet it and common failures, for example F65 — a missing alt).

Рівні. A — мінімум, без якого частина людей взагалі не скористається сторінкою (1.1.1 текстова альтернатива, 2.1.1 клавіатура). AA — стандартна ціль законів і політик (1.4.3 контраст 4.5:1, 2.4.7 видимий фокус). AAA — найвища планка (1.4.6 контраст 7:1); W3C прямо не рекомендує вимагати AAA для цілих сайтів, бо для деякого контенту її неможливо досягти.

Levels. A is the minimum without which some people cannot use the page at all (1.1.1 text alternatives, 2.1.1 keyboard). AA is the usual target of laws and policies (1.4.3 contrast 4.5:1, 2.4.7 focus visible). AAA is the highest bar (1.4.6 contrast 7:1); the W3C explicitly does not recommend requiring AAA for whole sites, because some content cannot meet it.

Нюанс. Рівні кумулятивні: відповідати AA означає виконати всі критерії A і AA. Відповідність оцінюється для цілої сторінки і цілого процесу: якщо крок оплати недоступний, недоступний увесь сценарій.

Nuance. Levels are cumulative: meeting AA means meeting every A and AA criterion. Conformance is judged per full page and per complete process: if the payment step is inaccessible, the whole flow is.

Анатомія номера критерію і приклади за рівнями
Anatomy of a criterion number and examples by level
1 . 4 . 3   Contrast (Minimum)                       рівень AA
|   |   +-- критерій успіху (перевіряється: pass / fail)
|   +------ настанова 1.4 Distinguishable
+---------- принцип 1 Perceivable

A    1.1.1 Non-text Content     2.1.1 Keyboard       2.4.1 Bypass Blocks
AA   1.4.3 Contrast (Minimum)   2.4.7 Focus Visible  2.5.8 Target Size (Minimum)
AAA  1.4.6 Contrast (Enhanced)  2.4.13 Focus Appearance
1 . 4 . 3   Contrast (Minimum)                       level AA
|   |   +-- success criterion (testable: pass / fail)
|   +------ guideline 1.4 Distinguishable
+---------- principle 1 Perceivable

A    1.1.1 Non-text Content     2.1.1 Keyboard       2.4.1 Bypass Blocks
AA   1.4.3 Contrast (Minimum)   2.4.7 Focus Visible  2.5.8 Target Size (Minimum)
AAA  1.4.6 Contrast (Enhanced)  2.4.13 Focus Appearance
🔧 Розбір прикладу: Перша цифра завжди вказує на принцип POUR, тож за номером у баг-репорті одразу видно природу проблеми: 1.x — сприйняття, 2.x — керування, 3.x — зрозумілість, 4.x — сумісність з допоміжними технологіями.
🔧 Example breakdown: The first digit always points to a POUR principle, so a number in a bug report reveals the nature of the problem at once: 1.x is perception, 2.x is operation, 3.x is understanding, 4.x is compatibility with assistive technology.
💡 Простими словами: WCAG — це список перевірюваних вимог, а AA — рівень, який від тебе майже завжди чекатимуть.
💡 In plain words: WCAG is a list of testable requirements, and AA is the level you will almost always be expected to meet.
🗣 Як читати вголос WCAG — це стандарт W3C, зараз актуальна версія 2.2. Він складається з чотирьох принципів, під ними настанови, а під настановами критерії успіху, які я й перевіряю: наприклад, 1.4.3 про контраст. Рівні три: A — мінімум, AA — те, чого вимагають закони й більшість компаній, AAA — найвища планка, яку для всього сайту зазвичай не ставлять. AA включає всі критерії рівня A.
🗣 How to say it out loud WCAG is the W3C standard, and the current version is 2.2. It has four principles, guidelines under them, and success criteria under the guidelines, which are what I actually test: 1.4.3 on contrast, for example. There are three levels: A is the minimum, AA is what laws and most companies require, and AAA is the top bar that is usually not set for a whole site. AA includes every level A criterion.
4 Що означають принципи POUR і який типовий баг відповідає кожному з них? What do the POUR principles mean, and what typical bug matches each of them?
WCAG і закониWCAG and law Junior ▶

Perceivable (сприйнятний). Інформацію можна отримати хоча б одним із чуттів. Баги: картинка-посилання без alt, відео без субтитрів, сірий текст на білому з контрастом 2.8:1, статус лише кольором.

Perceivable. Information can be received through at least one sense. Bugs: an image link without alt, a video without captions, grey text on white at a 2.8:1 contrast, status shown by colour alone.

Operable (керований). Інтерфейсом можна керувати різними способами. Баги: дропдаун відкривається лише наведенням миші, фокус застрягає в iframe з картою, каруселлю не можна керувати клавіатурою, сесія закінчується без попередження.

Operable. The interface can be operated in different ways. Bugs: a dropdown that opens only on mouse hover, focus stuck in a map iframe, a carousel that cannot be controlled from the keyboard, a session that expires without warning.

Understandable (зрозумілий). Контент і поведінка передбачувані. Баги: помилка «Invalid input» без пояснення, що саме не так, фокус на полі сам перемикає сторінку, мова сторінки не вказана, і скрінрідер читає українське англійською вимовою.

Understandable. Content and behaviour are predictable. Bugs: an «Invalid input» error with no hint of what is wrong, focusing a field switches the page on its own, the page language is missing so the screen reader reads Ukrainian with English pronunciation.

Robust (надійний). Розмітка коректно інтерпретується браузерами й допоміжними технологіями. Баги: кастомний перемикач без ролі й стану aria-checked, дублікати id, на які посилається aria-labelledby, статусне повідомлення, яке скрінрідер не оголошує.

Robust. Markup is interpreted correctly by browsers and assistive technology. Bugs: a custom toggle without a role and an aria-checked state, duplicate ids referenced by aria-labelledby, a status message the screen reader never announces.

Як використовувати. POUR — зручна рамка для експлораторної сесії: проходжу сторінку чотири рази з різними питаннями. І корисна відповідь на співбесіді: коли питають про конкретний баг, я називаю принцип і критерій.

How to use it. POUR is a handy frame for an exploratory session: I walk the page four times with different questions. It is also a strong interview answer: when asked about a specific bug, I name the principle and the criterion.

Чотири проходи експлораторної сесії за POUR
Four passes of a POUR exploratory session
P  Сприйняти:  вимкни зображення і звук, увімкни 200% і сірі тони
               -> чи лишилась уся інформація? (1.1.1, 1.2.2, 1.4.1, 1.4.3)
O  Керувати:   відклади мишу, пройди сценарій Tab / Enter / Space / Esc
               -> чи все досяжне і чи видно фокус? (2.1.1, 2.1.2, 2.4.7)
U  Зрозуміти:  введи помилкові дані, зміни мову, дочекайся таймауту
               -> чи ясно, що сталося і як виправити? (3.1.1, 3.3.1, 3.3.3)
R  Надійність: відкрий дерево доступності, прослухай NVDA
               -> чи є ролі, імена, стани, оголошення? (4.1.2, 4.1.3)
P  Perceive:  turn off images and sound, zoom to 200%, switch to greyscale
              -> is all information still there? (1.1.1, 1.2.2, 1.4.1, 1.4.3)
O  Operate:   put the mouse away, run the flow with Tab / Enter / Space / Esc
              -> is everything reachable and is focus visible? (2.1.1, 2.1.2, 2.4.7)
U  Understand: enter wrong data, change language, wait for a timeout
              -> is it clear what happened and how to fix it? (3.1.1, 3.3.1, 3.3.3)
R  Robust:    open the accessibility tree, listen with NVDA
              -> are roles, names, states and announcements there? (4.1.2, 4.1.3)
🔧 Розбір прикладу: Кожен прохід змінює лише одну умову, тож знайдений баг одразу прив'язаний до принципу і до кількох кандидатів-критеріїв, які я вписую в репорт.
🔧 Example breakdown: Each pass changes a single condition, so a bug found is tied immediately to a principle and a few candidate criteria that go into the report.
💡 Простими словами: Побачити, керувати, зрозуміти і щоб працювало з допоміжними технологіями — чотири питання до кожного екрана.
💡 In plain words: Perceive it, operate it, understand it, and have it work with assistive tech: four questions for every screen.
🗣 Як читати вголос POUR — це чотири принципи WCAG. Perceivable: інформацію можна сприйняти, наприклад, у картинки є alt, а в відео субтитри. Operable: усім можна керувати без миші і встигнути за часом. Understandable: помилки пояснені, поведінка передбачувана. Robust: розмітка така, що скрінрідер розуміє ролі й стани. На практиці я проходжу екран чотири рази, щоразу з одним із цих питань.
🗣 How to say it out loud POUR is the four WCAG principles. Perceivable: the information can be taken in, like alt on images and captions on video. Operable: everything works without a mouse and there is enough time. Understandable: errors are explained and behaviour is predictable. Robust: the markup lets a screen reader understand roles and states. In practice I walk a screen four times, each time with one of these questions.
5 Що нового у WCAG 2.2 і як тестувальнику перевірити кожен новий критерій? What is new in WCAG 2.2, and how does a tester check each new criterion?
WCAG і закониWCAG and law Middle ▶

Що додали. WCAG 2.2 додав 9 критеріїв, з них 6 на рівнях A і AA — саме їх зазвичай питають. Також вилучено 4.1.1 Parsing: сучасні браузери однаково виправляють невалідний HTML, тож критерій втратив сенс, а реальні наслідки (наприклад, дубльовані id у зв'язках ARIA) ловляться іншими критеріями.

What was added. WCAG 2.2 added 9 criteria, 6 of them at levels A and AA, which are the ones interviews usually ask about. It also removed 4.1.1 Parsing: modern browsers repair invalid HTML consistently, so the criterion lost its point, and the real consequences (such as duplicate ids in ARIA relationships) are caught by other criteria.

Фокус. 2.4.11 Focus Not Obscured (Minimum), AA: елемент у фокусі не може бути повністю закритий контентом автора — липкою шапкою, cookie-банером, чат-віджетом. 2.4.12 (AAA) вимагає, щоб він не був закритий навіть частково, а 2.4.13 Focus Appearance (AAA) задає мінімальний розмір і контраст індикатора.

Focus. 2.4.11 Focus Not Obscured (Minimum), AA: the focused element must not be entirely hidden by author content — a sticky header, a cookie banner, a chat widget. 2.4.12 (AAA) says it must not be hidden even partly, and 2.4.13 Focus Appearance (AAA) sets a minimum size and contrast for the indicator.

Вказівник. 2.5.7 Dragging Movements, AA: кожна дія перетягуванням має альтернативу одним натисканням (кнопки «вгору/вниз», клік по доріжці слайдера). 2.5.8 Target Size (Minimum), AA: ціль щонайменше 24×24 CSS px або достатній відступ до сусідніх цілей.

Pointer. 2.5.7 Dragging Movements, AA: every drag action has a single-pointer alternative (up/down buttons, a click on the slider track). 2.5.8 Target Size (Minimum), AA: a target is at least 24×24 CSS px or has enough spacing from neighbouring targets.

Допомога і форми. 3.2.6 Consistent Help, A: посилання на допомогу чи чат стоїть в одному й тому самому місці на всіх сторінках. 3.3.7 Redundant Entry, A: не змушувати вводити вже введені в цьому процесі дані. 3.3.8 Accessible Authentication (Minimum), AA: вхід без когнітивного тесту — дозволені менеджери паролів, вставка, magic link, passkey.

Help and forms. 3.2.6 Consistent Help, A: a help link or chat sits in the same place on every page. 3.3.7 Redundant Entry, A: do not make users retype data already entered in this process. 3.3.8 Accessible Authentication (Minimum), AA: sign-in without a cognitive test — password managers, paste, magic links and passkeys are allowed.

Помилка кандидата. Називати 2.2 «майбутньою версією» чи плутати 2.5.8 з 2.5.5 (44×44, AAA).

Candidate mistake. Calling 2.2 a «future version», or confusing 2.5.8 with 2.5.5 (44×44, AAA).

Чекліст нових критеріїв A/AA з конкретною перевіркою
Checklist of the new A/AA criteria with a concrete check
2.4.11 Focus Not Obscured (AA)  Tab по сторінці з відкритим cookie-банером і
                                липкою шапкою: фокус ніколи не ховається повністю
2.5.7  Dragging Movements (AA)  кожен drag (сортування, слайдер, карта) має
                                кнопки або клік як альтернативу
2.5.8  Target Size (AA)         іконки, хрестики, пагінація >= 24x24 CSS px
                                або відстань між центрами >= 24 px
3.2.6  Consistent Help (A)      «Допомога» / чат в одному місці на всіх сторінках
3.3.7  Redundant Entry (A)      адреса доставки не вводиться вдруге для оплати
3.3.8  Accessible Auth (AA)     пароль можна вставити, менеджер паролів працює,
                                немає капчі-головоломки без альтернативи
2.4.11 Focus Not Obscured (AA)  Tab through the page with the cookie banner and
                                sticky header present: focus is never fully hidden
2.5.7  Dragging Movements (AA)  every drag (sorting, slider, map) has buttons
                                or a click as an alternative
2.5.8  Target Size (AA)         icons, close buttons, pagination >= 24x24 CSS px
                                or centre-to-centre spacing >= 24 px
3.2.6  Consistent Help (A)      «Help» / chat in the same place on every page
3.3.7  Redundant Entry (A)      delivery address is not typed again for billing
3.3.8  Accessible Auth (AA)     password can be pasted, password manager works,
                                no puzzle captcha without an alternative
🔧 Розбір прикладу: Для кожного критерію вказано дію тестувальника, а не формулювання стандарту. 2.5.8 має виняток за відстанню: маленька ціль проходить, якщо коло діаметром 24 px навколо неї не перетинає інші цілі.
🔧 Example breakdown: Each criterion lists a tester action rather than the wording of the standard. 2.5.8 has a spacing exception: a small target passes if a 24 px circle around it does not overlap other targets.
💡 Простими словами: WCAG 2.2 — це про фокус, який не ховається, зручні цілі, альтернативу перетягуванню і форми, що не мучать пам'ять.
💡 In plain words: WCAG 2.2 is about focus that stays visible, usable targets, alternatives to dragging and forms that do not tax memory.
🗣 Як читати вголос У 2.2 додали дев'ять критеріїв, і найважливіші для мене ті, що на рівнях A і AA. Фокус не можна повністю ховати під липкою шапкою чи банером. Будь-яке перетягування повинно мати альтернативу кліком. Цілі мінімум двадцять чотири на двадцять чотири пікселі. Допомога в одному місці, повторно вводити дані не треба, а логін не може вимагати запам'ятовувати чи розв'язувати головоломки. І ще прибрали 4.1.1 Parsing.
🗣 How to say it out loud Version 2.2 added nine criteria, and the ones that matter most to me are at levels A and AA. Focus must not be fully hidden under a sticky header or a banner. Any dragging needs a click alternative. Targets are at least twenty-four by twenty-four pixels. Help is in a consistent place, you do not retype data, and sign-in cannot make you memorise things or solve puzzles. They also removed 4.1.1 Parsing.
6 Як протестувати Accessible Authentication (3.3.8) і Redundant Entry (3.3.7) у логіні та чекауті? How do you test Accessible Authentication (3.3.8) and Redundant Entry (3.3.7) in sign-in and checkout?
WCAG і закониWCAG and law Middle ▶

Accessible Authentication. Критерій забороняє вимагати «когнітивний тест» (запам'ятати пароль і набрати його вручну, розв'язати пазл, переписати символи з картинки), якщо немає альтернативи або допоміжного механізму. На практиці сайт проходить, якщо дозволяє менеджер паролів і вставку з буфера. Типові провали: onpaste з preventDefault на полі пароля, поле OTP, розбите на 6 інпутів, куди не вставляється код, autocomplete=off на логіні, капча-головоломка без аудіо чи іншого способу. Розпізнавання об'єктів («оберіть світлофори») на рівні AA — дозволений виняток, але не на AAA.

Accessible Authentication. The criterion forbids requiring a «cognitive function test» (remembering a password and typing it by hand, solving a puzzle, transcribing characters from an image) unless there is an alternative or a helping mechanism. In practice a site passes if it allows password managers and pasting from the clipboard. Typical failures: onpaste with preventDefault on the password field, an OTP field split into 6 inputs that refuses a pasted code, autocomplete=off on sign-in, a puzzle captcha with no audio or other route. Object recognition («select the traffic lights») is an allowed exception at AA, but not at AAA.

Redundant Entry. Якщо користувач уже ввів дані в цьому процесі, їх треба підставити або дати вибрати: чекбокс «адреса оплати збігається з адресою доставки», збережена адреса у випадному списку. Винятки: повторне введення пароля для безпеки та ситуації, коли попередні дані вже неактуальні.

Redundant Entry. If the user has already entered data in this process, it must be auto-filled or selectable: a «billing address same as delivery» checkbox, a saved address in a dropdown. Exceptions: re-entering a password for security, and cases where the earlier data is no longer valid.

Як тестую. Ручно — зберігаю логін у менеджері паролів і перевіряю автозаповнення, вставляю пароль і код, проходжу чекаут двічі. Автоматично — перевіряю атрибути autocomplete і те, що вставка не блокується.

How I test. By hand — I save the login in a password manager and check autofill, paste a password and a code, go through checkout twice. Automatically — I assert the autocomplete attributes and that paste is not blocked.

Нюанс. Критерій не забороняє паролі, він забороняє заважати інструментам, які знімають навантаження з пам'яті.

Nuance. The criterion does not ban passwords; it bans getting in the way of tools that take the load off memory.

Playwright: вставка в поле пароля і коду не заблокована
Playwright: paste into the password and code fields is not blocked
import { test, expect } from '@playwright/test';

test('логін дозволяє вставку і менеджер паролів (WCAG 3.3.8)', async ({ page }) => {
  await page.goto('/login');
  const password = page.getByLabel('Пароль');

  // менеджер паролів знаходить поле за autocomplete
  await expect(password).toHaveAttribute('autocomplete', 'current-password');

  // імітуємо вставку: подія paste не повинна бути скасована
  const cancelled = await password.evaluate((el) => {
    const data = new DataTransfer();
    data.setData('text/plain', 'S3cret!pass');
    const event = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true });
    return !el.dispatchEvent(event);
  });
  expect(cancelled).toBe(false);

  // код з SMS має бути одним полем з one-time-code
  await expect(page.getByLabel('Код підтвердження')).toHaveAttribute('autocomplete', 'one-time-code');
});
import { test, expect } from '@playwright/test';

test('sign-in allows paste and password managers (WCAG 3.3.8)', async ({ page }) => {
  await page.goto('/login');
  const password = page.getByLabel('Password');

  // a password manager finds the field by autocomplete
  await expect(password).toHaveAttribute('autocomplete', 'current-password');

  // simulate a paste: the paste event must not be cancelled
  const cancelled = await password.evaluate((el) => {
    const data = new DataTransfer();
    data.setData('text/plain', 'S3cret!pass');
    const event = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true });
    return !el.dispatchEvent(event);
  });
  expect(cancelled).toBe(false);

  // the SMS code should be a single field with one-time-code
  await expect(page.getByLabel('Verification code')).toHaveAttribute('autocomplete', 'one-time-code');
});
🔧 Розбір прикладу: dispatchEvent повертає false, якщо обробник викликав preventDefault, тому cancelled === true означає, що сайт блокує вставку. Токени current-password і one-time-code — стандартні значення HTML autocomplete, за якими браузер і менеджери паролів знаходять поля.
🔧 Example breakdown: dispatchEvent returns false when a handler called preventDefault, so cancelled === true means the site blocks pasting. The current-password and one-time-code tokens are standard HTML autocomplete values that browsers and password managers use to find fields.
💡 Простими словами: Не змушуй людину пам'ятати й передруковувати те, що може зробити менеджер паролів або форма.
💡 In plain words: Do not make a person remember and retype what a password manager or the form itself can do.
🗣 Як читати вголос Для 3.3.8 я перевіряю, що логін не вимагає тримати щось у голові без допомоги: пароль можна вставити, менеджер паролів підставляє дані, код з SMS вставляється одним рухом, а капча має альтернативу. Найчастіший баг — заблокована вставка в поле пароля «для безпеки». Для 3.3.7 проходжу чекаут і дивлюся, щоб адресу не треба було вводити вдруге, достатньо поставити галочку.
🗣 How to say it out loud For 3.3.8 I check that sign-in does not demand holding something in your head without help: the password can be pasted, the password manager fills the fields, the SMS code pastes in one go and the captcha has an alternative. The most common bug is paste blocked on the password field «for security». For 3.3.7 I go through checkout and make sure the address does not have to be typed twice; a checkbox should be enough.
▶ 2 9
7 Що таке семантичний HTML і чому доступність починається саме з нього? What is semantic HTML, and why does accessibility start with it?
Семантика і ARIASemantics and ARIA Junior ▶

Що це. Семантичний HTML — розмітка, де тег описує зміст і призначення, а не вигляд: <button> для дії, <a href> для переходу, <nav>, <main>, <h1>–<h6>, <ul>, <table>, <label>. Браузер перетворює такі теги на вузли дерева доступності з правильною роллю, ім'ям і станом, а скрінрідер, голосове керування чи Switch Access працюють саме з цим деревом.

What it is. Semantic HTML is markup where the tag describes meaning and purpose, not looks: <button> for an action, <a href> for navigation, <nav>, <main>, <h1>–<h6>, <ul>, <table>, <label>. The browser turns these tags into accessibility tree nodes with the correct role, name and state, and a screen reader, voice control or Switch Access work with exactly that tree.

Що дає безкоштовно. <button> фокусується через Tab, спрацьовує на Enter і Space, оголошується як «кнопка», у формі відправляє її. <h2> дозволяє незрячому перейти до розділу клавішею H. <nav> стає орієнтиром (landmark). <label for> дає полю ім'я і збільшує зону кліку. Усе це без жодного рядка JavaScript чи ARIA.

What you get for free. A <button> is focusable with Tab, fires on Enter and Space, is announced as «button» and submits its form. An <h2> lets a blind user jump to the section with the H key. A <nav> becomes a landmark. A <label for> gives the field a name and enlarges the click area. All of that without a single line of JavaScript or ARIA.

Навіщо ще. Той самий зміст використовують пошукові системи, режим читання браузера, автоперекладачі і автотести: getByRole у Playwright знаходить елементи через те саме дерево.

Why else. The same meaning is used by search engines, the browser reader mode, automatic translators and automated tests: getByRole in Playwright finds elements through the same tree.

Типова помилка. «Div-суп»: <div class=title> замість заголовка, <span> з обробником кліку замість кнопки, список з <div>. Візуально сторінка ідентична, але для скрінрідера вона — суцільний текст без структури й без елементів керування. Друга помилка — вибирати тег за стилем: <h4>, бо «потрібен дрібний шрифт».

The common mistake. «Div soup»: <div class=title> instead of a heading, a <span> with a click handler instead of a button, a list made of <div>s. Visually the page is identical, but to a screen reader it is a flat run of text with no structure and no controls. The second mistake is choosing a tag for its style: an <h4> because «we need small text».

Погано: div-суп. Добре: семантичні теги
Bad: div soup. Good: semantic tags
<!-- ПОГАНО: для скрінрідера це просто текст -->
<div class="header">
  <div class="logo" onclick="goHome()">Shop</div>
  <div class="menu"><div onclick="openCart()">Кошик</div></div>
</div>
<div class="big-text">Оформлення замовлення</div>

<!-- ДОБРЕ: ролі, фокус і клавіатура з коробки -->
<header>
  <a href="/">Shop</a>
  <nav aria-label="Головне меню">
    <ul><li><a href="/cart">Кошик</a></li></ul>
  </nav>
</header>
<main>
  <h1>Оформлення замовлення</h1>
</main>
<!-- BAD: to a screen reader this is just text -->
<div class="header">
  <div class="logo" onclick="goHome()">Shop</div>
  <div class="menu"><div onclick="openCart()">Cart</div></div>
</div>
<div class="big-text">Checkout</div>

<!-- GOOD: roles, focus and keyboard out of the box -->
<header>
  <a href="/">Shop</a>
  <nav aria-label="Main menu">
    <ul><li><a href="/cart">Cart</a></li></ul>
  </nav>
</header>
<main>
  <h1>Checkout</h1>
</main>
🔧 Розбір прикладу: У поганому варіанті немає жодного фокусованого елемента, заголовка чи орієнтира. У доброму <header>, <nav> і <main> стають landmark-ами, посилання фокусуються і відкриваються через Enter, а <h1> з'являється у списку заголовків скрінрідера.
🔧 Example breakdown: The bad version has no focusable element, heading or landmark at all. In the good one <header>, <nav> and <main> become landmarks, links are focusable and open with Enter, and the <h1> shows up in the screen reader heading list.
💡 Простими словами: Обирай тег за тим, чим елемент є, а вигляд задавай у CSS.
💡 In plain words: Pick the tag for what the element is, and set its looks in CSS.
🗣 Як читати вголос Семантичний HTML — це коли тег каже, що це за елемент: кнопка, посилання, заголовок, навігація. Браузер з цього будує дерево доступності, і скрінрідер отримує роль, ім'я і стан без додаткового коду. Кнопка сама фокусується і реагує на Enter і Space. Коли я бачу «div-суп», я знаю, що клавіатура й скрінрідер там поламані, навіть якщо візуально все ідеально.
🗣 How to say it out loud Semantic HTML means the tag says what the element is: a button, a link, a heading, navigation. The browser builds the accessibility tree from that, and the screen reader gets the role, name and state without extra code. A button focuses itself and responds to Enter and Space. When I see «div soup», I know the keyboard and screen reader experience is broken there, even if it looks perfect.
8 Чому клікабельний div — погано для доступності і що потрібно, щоб його виправити? Why is a clickable div bad for accessibility, and what does it take to fix it?
Семантика і ARIASemantics and ARIA Junior ▶

Проблема. <div onclick> реагує лише на мишу. Він не потрапляє в порядок Tab, не спрацьовує на Enter чи Space, не має ролі, тож скрінрідер читає його як звичайний текст або взагалі пропускає, а голосове керування («натисни Зберегти») його не знаходить. Порушуються 2.1.1 Keyboard і 4.1.2 Name, Role, Value.

The problem. A <div onclick> responds to the mouse only. It is not in the Tab order, does not fire on Enter or Space and has no role, so a screen reader reads it as plain text or skips it, and voice control («click Save») cannot find it. It fails 2.1.1 Keyboard and 4.1.2 Name, Role, Value.

Правильне виправлення. Замінити на <button type=button>. Стилі кнопки повністю скидаються через CSS, тож аргумент «дизайн» не працює. Якщо елемент веде на іншу сторінку — це <a href>, а не кнопка.

The right fix. Replace it with <button type=button>. Button styles can be fully reset in CSS, so the «design» argument does not hold. If the element takes you to another page, it is an <a href>, not a button.

Якщо тег змінити неможливо (стороння бібліотека), доведеться відтворити поведінку вручну: role=button, tabindex=0, обробник keydown для Enter і Space (Space — з preventDefault, щоб сторінка не прокручувалась), стан aria-disabled замість атрибута disabled, видимий фокус. Це чотири-п'ять речей, кожну з яких легко забути, тому я прошу нативну кнопку.

If the tag cannot change (a third-party library), the behaviour has to be rebuilt by hand: role=button, tabindex=0, a keydown handler for Enter and Space (Space with preventDefault so the page does not scroll), an aria-disabled state instead of the disabled attribute, a visible focus style. That is four or five things, each easy to forget, so I ask for a native button.

Як ловлю. Tab по сторінці: якщо елемент, який виглядає клікабельним, не отримує фокус — баг. axe правило click-events-have-key-events є лише в деяких лінтерах (eslint-plugin-jsx-a11y), сам axe на сторінці такий div не бачить, бо немає ролі. Тому ручна перевірка тут обов'язкова.

How I catch it. Tab through the page: if an element that looks clickable never gets focus, it is a bug. The click-events-have-key-events rule exists only in some linters (eslint-plugin-jsx-a11y); axe on the live page does not see such a div at all, because it has no role. So a manual check is mandatory here.

Три варіанти однієї кнопки
Three versions of the same button
<!-- ПОГАНО: лише миша, без ролі, без фокусу -->
<div class="btn" onclick="save()">Зберегти</div>

<!-- ТЕРПИМО: відтворена поведінка, легко зламати -->
<div class="btn" role="button" tabindex="0"
     onclick="save()"
     onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); save(); }">
  Зберегти
</div>

<!-- ДОБРЕ: нативна кнопка, стилі скидаються в CSS -->
<button type="button" class="btn" onclick="save()">Зберегти</button>
<!-- BAD: mouse only, no role, no focus -->
<div class="btn" onclick="save()">Save</div>

<!-- TOLERABLE: rebuilt behaviour, easy to break -->
<div class="btn" role="button" tabindex="0"
     onclick="save()"
     onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); save(); }">
  Save
</div>

<!-- GOOD: native button, styles reset in CSS -->
<button type="button" class="btn" onclick="save()">Save</button>
🔧 Розбір прикладу: Другий варіант потребує ролі, tabindex і обробника клавіш одночасно — варто забути одне, і кнопка знову зламана. type=button у третьому важливий усередині форми: без нього кнопка за замовчуванням має тип submit і відправить форму.
🔧 Example breakdown: The second version needs a role, a tabindex and a key handler at once — forget one and the button is broken again. The type=button in the third matters inside a form: without it a button defaults to submit and sends the form.
💡 Простими словами: Якщо на щось можна натиснути, це або кнопка, або посилання — не div.
💡 In plain words: If something can be clicked, it is either a button or a link, never a div.
🗣 Як читати вголос Div з обробником кліку працює тільки для миші. Він не фокусується, не натискається клавіатурою і не має ролі, тож скрінрідер не скаже, що це кнопка. Виправлення — просто взяти тег button, стилі все одно задаються в CSS. Якщо тег змінити неможливо, треба додати роль, tabindex, обробку Enter і Space, але це крихко. І я пам'ятаю, що axe такий div не знайде, тому перевіряю Tab-ом.
🗣 How to say it out loud A div with a click handler works only for the mouse. It does not take focus, cannot be pressed from the keyboard and has no role, so a screen reader will not say it is a button. The fix is simply to use a button tag; styles are set in CSS anyway. If the tag cannot change, you need a role, a tabindex and Enter and Space handling, but that is fragile. And I remember axe will not find such a div, so I check with Tab.
9 Що таке ARIA role і чим ролі відрізняються від станів і властивостей? What is an ARIA role, and how do roles differ from states and properties?
Семантика і ARIASemantics and ARIA Middle ▶

Роль. WAI-ARIA — специфікація атрибутів, які доповнюють семантику HTML для допоміжних технологій. role каже, чим є елемент: tablist, tab, dialog, alert, switch, navigation. Групи ролей: віджети (button, checkbox, slider), структура документа (heading, list, table), орієнтири (banner, main, navigation), live-регіони (status, alert, log) і вікна (dialog, alertdialog).

Role. WAI-ARIA is a specification of attributes that add semantics to HTML for assistive technology. role says what the element is: tablist, tab, dialog, alert, switch, navigation. Role groups: widgets (button, checkbox, slider), document structure (heading, list, table), landmarks (banner, main, navigation), live regions (status, alert, log) and windows (dialog, alertdialog).

Стани і властивості. Стан — те, що змінюється під час взаємодії: aria-expanded, aria-checked, aria-selected, aria-pressed, aria-invalid, aria-current. Властивість — відносно стала характеристика або зв'язок: aria-label, aria-labelledby, aria-describedby, aria-controls, aria-required, aria-haspopup. Межа умовна, але для тестування важливо: стани мусять оновлюватися скриптом при кожній дії.

States and properties. A state is something that changes during interaction: aria-expanded, aria-checked, aria-selected, aria-pressed, aria-invalid, aria-current. A property is a relatively stable characteristic or relationship: aria-label, aria-labelledby, aria-describedby, aria-controls, aria-required, aria-haspopup. The line is fuzzy, but for testing it matters: states must be updated by script on every action.

Головне обмеження. ARIA змінює лише те, що бачить дерево доступності. Вона не додає фокус, обробку клавіш чи поведінку. role=tab обіцяє користувачу скрінрідера, що стрілки перемикатимуть вкладки, — і якщо розробник цього не реалізував, стало гірше, ніж без ролі.

The key limitation. ARIA only changes what the accessibility tree sees. It adds no focus, no key handling and no behaviour. role=tab promises a screen reader user that arrow keys will switch tabs — and if the developer did not build that, it is worse than no role at all.

Як тестую віджет. Беру патерн з ARIA Authoring Practices Guide (APG), звіряю ролі й атрибути, а потім клавіатурну модель: для вкладок це стрілки між tab, Tab — у панель, Home/End. Окремо перевіряю, що aria-selected перемикається разом з візуальним станом.

How I test a widget. I take the pattern from the ARIA Authoring Practices Guide (APG), compare roles and attributes, and then the keyboard model: for tabs that is arrow keys between tabs, Tab into the panel, Home/End. Separately I check that aria-selected switches together with the visual state.

Вкладки: ролі, стани, властивості
Tabs: roles, states, properties
<div role="tablist" aria-label="Налаштування акаунта">
  <!-- роль tab, стан aria-selected, властивість aria-controls -->
  <button role="tab" id="tab-profile" aria-selected="true"
          aria-controls="panel-profile">Профіль</button>
  <!-- неактивні вкладки поза Tab-порядком: між ними ходять стрілками -->
  <button role="tab" id="tab-security" aria-selected="false"
          aria-controls="panel-security" tabindex="-1">Безпека</button>
</div>

<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">...</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>...</div>
<div role="tablist" aria-label="Account settings">
  <!-- role tab, state aria-selected, property aria-controls -->
  <button role="tab" id="tab-profile" aria-selected="true"
          aria-controls="panel-profile">Profile</button>
  <!-- inactive tabs are out of the Tab order: arrows move between them -->
  <button role="tab" id="tab-security" aria-selected="false"
          aria-controls="panel-security" tabindex="-1">Security</button>
</div>

<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">...</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>...</div>
🔧 Розбір прикладу: Скрінрідер прочитає «Профіль, вкладка, вибрано, 1 з 2». Прийом tabindex=-1 на неактивних вкладках називається roving tabindex: Tab заходить лише в активну вкладку, а між іншими переміщують стрілки, які треба реалізувати в JavaScript.
🔧 Example breakdown: A screen reader will say «Profile, tab, selected, 1 of 2». The tabindex=-1 on inactive tabs is the roving tabindex technique: Tab enters only the active tab, and arrow keys move between the others, which has to be built in JavaScript.
💡 Простими словами: Роль каже, що це за річ, стан — у якому вона зараз положенні, а поведінку ARIA не дає зовсім.
💡 In plain words: The role says what the thing is, the state says what condition it is in, and ARIA provides no behaviour at all.
🗣 Як читати вголос ARIA role повідомляє допоміжним технологіям, чим є елемент, коли HTML цього не вміє: вкладка, діалог, перемикач. Стани, як aria-expanded чи aria-selected, змінюються під час роботи, а властивості описують зв'язки, як aria-controls. Головне, що я пам'ятаю: ARIA не додає поведінки. Тому я звіряю віджет з патерном APG і обов'язково перевіряю клавіатуру та оновлення станів.
🗣 How to say it out loud An ARIA role tells assistive technology what an element is when HTML cannot: a tab, a dialog, a switch. States such as aria-expanded or aria-selected change as you use it, and properties describe relationships, like aria-controls. The key thing I remember is that ARIA adds no behaviour. So I compare the widget with the APG pattern and always check the keyboard and that the states update.
10 Що каже перше правило ARIA і які помилки з ARIA ти найчастіше знаходиш на аудитах? What does the first rule of ARIA say, and which ARIA mistakes do you find most often in audits?
Семантика і ARIASemantics and ARIA Senior ▶

Перше правило. «Якщо можна використати нативний HTML-елемент чи атрибут з потрібною семантикою і поведінкою — використовуй його, а не перепрофільовуй інший елемент, додаючи роль, стан чи властивість». Решта правил W3C: не змінювати нативну семантику без потреби; усі інтерактивні ARIA-елементи мають працювати з клавіатури; не ставити role=presentation чи aria-hidden=true на фокусований елемент; кожен інтерактивний елемент має доступне ім'я.

The first rule. «If you can use a native HTML element or attribute with the semantics and behaviour you require already built in, instead of re-purposing an element and adding an ARIA role, state or property, then do so.» The other W3C rules: do not change native semantics unless you must; all interactive ARIA controls must be keyboard operable; do not put role=presentation or aria-hidden=true on a focusable element; every interactive element has an accessible name.

Чому «краще без ARIA, ніж з поганою ARIA». WebAIM Million щороку показує, що на домашніх сторінках з ARIA в середньому більше виявлених помилок, ніж без неї. ARIA — це обіцянка, і невиконана обіцянка гірша за її відсутність.

Why «no ARIA is better than bad ARIA». Every year the WebAIM Million shows that home pages with ARIA have more detected errors on average than those without. ARIA is a promise, and a broken promise is worse than none.

Топ помилок з аудитів. aria-hidden=true на контейнері з кнопкою: фокус туди потрапляє, а скрінрідер мовчить. aria-expanded, що не оновлюється після відкриття меню. role=menu для звичайної навігації сайту — користувач скрінрідера чекає стрілок, а там посилання. aria-label на <div> без ролі — для generic-елементів він не підтримується. aria-labelledby з id, якого немає. role=button на <a href> — Space не спрацює. aria-disabled без реального блокування дії.

Top audit findings. aria-hidden=true on a container with a button: focus goes in and the screen reader stays silent. aria-expanded that never updates after the menu opens. role=menu for plain site navigation — a screen reader user expects arrow keys and finds links. aria-label on a <div> with no role — it is not supported on generic elements. aria-labelledby pointing to an id that does not exist. role=button on an <a href> — Space will not fire. aria-disabled without actually blocking the action.

Як це подати команді. Не «видаліть ARIA», а «замініть на нативний елемент, де можна, і доведіть до APG-патерну, де не можна». Лінт і axe ловлять частину: aria-hidden-focus, aria-valid-attr-value, aria-allowed-attr. Логіку станів перевіряю руками й тестами.

How to pitch it to the team. Not «delete ARIA», but «replace with a native element where you can, and bring it up to the APG pattern where you cannot». Linting and axe catch part of it: aria-hidden-focus, aria-valid-attr-value, aria-allowed-attr. State logic I check by hand and with tests.

Типові зловживання ARIA і виправлення
Common ARIA misuse and fixes
<!-- 1. Надлишкова роль: нативна кнопка вже має role=button -->
<button role="button">Купити</button>          <!-- просто <button> -->

<!-- 2. Прихований, але фокусований контент -->
<div aria-hidden="true"><button>Закрити</button></div>
<!-- виправлення: inert або прибрати aria-hidden -->
<div inert><button>Закрити</button></div>

<!-- 3. Стан не синхронізовано з інтерфейсом -->
<button aria-expanded="false">Меню</button>    <!-- меню вже відкрите! -->

<!-- 4. aria-label на generic div ігнорується -->
<div aria-label="Ціна">250 грн</div>
<!-- виправлення: видимий текст або правильна структура -->
<p>Ціна: 250 грн</p>
<!-- 1. Redundant role: a native button already has role=button -->
<button role="button">Buy</button>             <!-- just <button> -->

<!-- 2. Hidden but focusable content -->
<div aria-hidden="true"><button>Close</button></div>
<!-- fix: use inert or remove aria-hidden -->
<div inert><button>Close</button></div>

<!-- 3. State out of sync with the UI -->
<button aria-expanded="false">Menu</button>    <!-- the menu is already open! -->

<!-- 4. aria-label on a generic div is ignored -->
<div aria-label="Price">$25</div>
<!-- fix: visible text or proper structure -->
<p>Price: $25</p>
🔧 Розбір прикладу: Атрибут inert робить піддерево нефокусованим, некліканим і прихованим від дерева доступності одночасно, тому він безпечніший за aria-hidden для фонового контенту. Приклад 3 axe не зловить: значення атрибута валідне, а невідповідність видно лише після дії.
🔧 Example breakdown: The inert attribute makes a subtree unfocusable, unclickable and hidden from the accessibility tree all at once, which is why it is safer than aria-hidden for background content. Example 3 is invisible to axe: the attribute value is valid, and the mismatch only shows after an action.
💡 Простими словами: Спершу нативний тег; ARIA — лише коли його немає, і тоді разом з повною поведінкою.
💡 In plain words: Native tag first; ARIA only when none exists, and then with the full behaviour.
🗣 Як читати вголос Перше правило ARIA: якщо є нативний елемент з потрібною поведінкою, бери його. На аудитах я найчастіше бачу aria-hidden на контейнері з фокусованими кнопками, aria-expanded, який ніколи не змінюється, role menu для звичайної навігації і посилання на неіснуючі id. Статистика WebAIM показує, що сторінки з ARIA мають більше помилок, тож я пропоную не видаляти ARIA, а замінити на нативне там, де можна.
🗣 How to say it out loud The first rule of ARIA: if there is a native element with the behaviour you need, use it. In audits I most often see aria-hidden on a container with focusable buttons, aria-expanded that never changes, role menu on plain navigation and references to ids that do not exist. WebAIM data shows pages with ARIA have more errors, so I suggest not deleting ARIA but replacing it with native elements wherever possible.
11 Що таке accessible name і як браузер його обчислює? What is an accessible name, and how does the browser compute it?
Семантика і ARIASemantics and ARIA Middle ▶

Що це. Accessible name — текст, яким допоміжні технології називають елемент: скрінрідер читає «Пошук, кнопка», голосове керування реагує на «натисни Пошук», а Playwright шукає getByRole('button', { name: 'Пошук' }). Інтерактивний елемент без імені — провал 4.1.2, бо користувач чує лише «кнопка».

What it is. The accessible name is the text assistive technology uses to refer to an element: a screen reader says «Search, button», voice control responds to «click Search», and Playwright looks for getByRole('button', { name: 'Search' }). An interactive element with no name fails 4.1.2, because the user only hears «button».

Порядок обчислення (спрощено за Accessible Name and Description Computation). 1) aria-labelledby — перемагає все, може посилатися на кілька id (текст склеюється в їхньому порядку) і навіть на прихований елемент. 2) aria-label. 3) Нативне джерело мови розмітки: <label> для поля, alt для зображення, <caption> для таблиці, <legend> для fieldset, значення кнопки input type=submit. 4) Вміст елемента — для ролей, що дозволяють ім'я з вмісту (button, link, heading, cell, option). 5) Останній резерв: атрибут title, а для полів — текст-підказка всередині поля. Приховані через display:none чи aria-hidden частини вмісту в ім'я не входять.

Computation order (simplified from Accessible Name and Description Computation). 1) aria-labelledby — beats everything, can reference several ids (texts joined in their order) and even a hidden element. 2) aria-label. 3) The native host-language source: <label> for a field, alt for an image, <caption> for a table, <legend> for a fieldset, the value of an input type=submit. 4) The element's contents — for roles that allow a name from content (button, link, heading, cell, option). 5) Last resort: the title attribute, and for fields the hint text shown inside the field. Parts of the content hidden with display:none or aria-hidden do not enter the name.

Практичні наслідки. aria-label повністю замінює видимий текст кнопки, тож якщо вони розходяться, людина з голосовим керуванням не зможе натиснути кнопку. Ім'я з title не видно на мобільних і клавіатурою, тому це слабке джерело.

Practical consequences. aria-label completely replaces a button's visible text, so if they differ, a voice control user cannot press the button. A name from title is invisible on mobile and to keyboard users, so it is a weak source.

Як перевіряю. Панель Accessibility у DevTools показує обчислене ім'я і з чого воно взялося; у Playwright — toHaveAccessibleName.

How I check. The Accessibility pane in DevTools shows the computed name and where it came from; in Playwright it is toHaveAccessibleName.

Звідки береться ім'я: від найсильнішого джерела до найслабшого
Where the name comes from: strongest source to weakest
<!-- 1. aria-labelledby: ім'я «Видалити Звіт за березень» -->
<button id="del" aria-labelledby="del row-7">Видалити</button>
<span id="row-7">Звіт за березень</span>

<!-- 2. aria-label перекриває вміст: ім'я «Закрити», а не «X» -->
<button aria-label="Закрити">X</button>

<!-- 3. нативний label: ім'я «Email» -->
<label for="email">Email</label> <input id="email" type="email">

<!-- 4. вміст: ім'я «Кошик 3», іконка прихована -->
<a href="/cart"><svg aria-hidden="true">...</svg>Кошик <span>3</span></a>

<!-- 5. лише title: ім'я є, але зряча клавіатурна людина його не бачить -->
<button title="Друк"><svg aria-hidden="true">...</svg></button>
<!-- 1. aria-labelledby: name «Delete March report» -->
<button id="del" aria-labelledby="del row-7">Delete</button>
<span id="row-7">March report</span>

<!-- 2. aria-label overrides content: name «Close», not «X» -->
<button aria-label="Close">X</button>

<!-- 3. native label: name «Email» -->
<label for="email">Email</label> <input id="email" type="email">

<!-- 4. content: name «Cart 3», icon hidden -->
<a href="/cart"><svg aria-hidden="true">...</svg>Cart <span>3</span></a>

<!-- 5. title only: there is a name, but a sighted keyboard user cannot see it -->
<button title="Print"><svg aria-hidden="true">...</svg></button>
🔧 Розбір прикладу: У першому прикладі кнопка посилається сама на себе і на рядок таблиці, тож серед десяти кнопок «Видалити» кожна отримує унікальне ім'я. Порядок id в атрибуті визначає порядок слів у імені.
🔧 Example breakdown: In the first example the button references itself and the table row, so among ten «Delete» buttons each gets a unique name. The order of ids in the attribute sets the order of words in the name.
💡 Простими словами: Ім'я — це те, що скаже скрінрідер і на що відгукнеться голосова команда; найкраще, коли воно збігається з видимим текстом.
💡 In plain words: The name is what the screen reader says and what a voice command matches; it works best when it equals the visible text.
🗣 Як читати вголос Accessible name — це назва елемента в дереві доступності, те, що почує користувач скрінрідера. Браузер бере її в порядку пріоритету: спершу aria-labelledby, потім aria-label, потім нативні речі на кшталт label чи alt, потім текст усередині елемента, і в самому кінці title. Я перевіряю ім'я в панелі Accessibility у DevTools або в Playwright через toHaveAccessibleName, і стежу, щоб воно збігалося з видимим текстом.
🗣 How to say it out loud The accessible name is the element's name in the accessibility tree, what a screen reader user will hear. The browser takes it in priority order: first aria-labelledby, then aria-label, then native sources like a label or alt, then the text inside the element, and title at the very end. I check the name in the DevTools Accessibility pane or with toHaveAccessibleName in Playwright, and make sure it matches the visible text.
12 Що обрати: видимий label, aria-labelledby чи aria-label, і що вимагає критерій Label in Name? Which to choose: a visible label, aria-labelledby or aria-label, and what does the Label in Name criterion require?
Семантика і ARIASemantics and ARIA Middle ▶

Порядок вибору. 1) Видимий текст, пов'язаний нативно (<label>, текст кнопки) — працює для всіх: зрячих, скрінрідера, голосу, автоперекладу. 2) aria-labelledby — коли видимий текст уже є на сторінці, але HTML не дає зв'язати його нативно (заголовок діалогу, назва рядка таблиці). 3) aria-label — коли видимого тексту немає взагалі: кнопка-іконка, пошук з лупою.

Order of choice. 1) Visible text linked natively (<label>, button text) — works for everyone: sighted users, screen readers, voice, automatic translation. 2) aria-labelledby — when the visible text is already on the page but HTML cannot link it natively (a dialog heading, a table row name). 3) aria-label — when there is no visible text at all: an icon button, a search field with a magnifier.

Мінуси aria-label. Його не видно, тому зрячі люди з когнітивними труднощами не отримують підказки. Деякі інструменти автоперекладу сторінок не перекладають значення атрибутів, і франкомовний користувач почує англійське ім'я. Його легко забути оновити, коли змінюється видимий текст.

Downsides of aria-label. It is invisible, so sighted people with cognitive difficulties get no cue. Some page translation tools do not translate attribute values, so a French-speaking user hears an English name. It is easy to forget to update when the visible text changes.

Label in Name, 2.5.3 (A). Якщо в елемента є видимий текст, доступне ім'я повинне містити цей текст, найкраще — починатися з нього. Кнопка з написом «Надіслати» і aria-label=Відправити форму зворотного зв'язку провалює критерій: користувач Voice Control каже «натисни Надіслати» — і нічого не відбувається.

Label in Name, 2.5.3 (A). If an element has visible text, its accessible name must contain that text and ideally start with it. A button that reads «Send» with aria-label=Submit the feedback form fails the criterion: a Voice Control user says «click Send» — and nothing happens.

Як тестую. Порівнюю видимий текст з обчисленим іменем (DevTools або тест), а на macOS вмикаю Voice Control і кажу видимий текст уголос. Для полів перевіряю, що клік по label ставить фокус у поле — це доказ нативного зв'язку.

How I test. I compare the visible text with the computed name (DevTools or a test), and on macOS I turn on Voice Control and say the visible text out loud. For fields I check that clicking the label moves focus into the field — proof of a native link.

Label in Name: провал і виправлення + перевірка в Playwright
Label in Name: failure and fix + a Playwright check
<!-- ПРОВАЛ 2.5.3: видно «Надіслати», ім'я «Відправити форму» -->
<button aria-label="Відправити форму">Надіслати</button>

<!-- ОК: ім'я починається з видимого тексту -->
<button aria-describedby="send-hint">Надіслати</button>
<p id="send-hint">Відповімо протягом доби</p>
<!-- FAILS 2.5.3: shows «Send», name is «Submit form» -->
<button aria-label="Submit form">Send</button>

<!-- OK: the name starts with the visible text -->
<button aria-describedby="send-hint">Send</button>
<p id="send-hint">We reply within a day</p>
// перевірка: ім'я кнопки містить її видимий текст
const button = page.locator('form#feedback button[type=submit]');
const visible = (await button.innerText()).trim();
await expect(button).toHaveAccessibleName(new RegExp('^' + visible));
// check: the button name contains its visible text
const button = page.locator('form#feedback button[type=submit]');
const visible = (await button.innerText()).trim();
await expect(button).toHaveAccessibleName(new RegExp('^' + visible));
🔧 Розбір прикладу: Додатковий контекст передається через aria-describedby, а не через заміну імені. У тесті innerText бере саме видимий текст, а регулярний вираз з ^ перевіряє, що ім'я з нього починається. Для тексту зі спецсимволами регекс треба екранувати.
🔧 Example breakdown: Extra context goes through aria-describedby, not by replacing the name. In the test innerText takes the visible text, and the regular expression with ^ checks that the name starts with it. Text with special characters needs escaping before building the regex.
💡 Простими словами: Що людина бачить на кнопці, те вона й повинна мати змогу сказати голосом.
💡 In plain words: Whatever a person sees on the button is what they should be able to say by voice.
🗣 Як читати вголос Мій порядок такий: спершу видимий текст, пов'язаний нативно, потім aria-labelledby, коли текст уже є на сторінці, і лише потім aria-label, коли тексту немає, як у кнопки-іконки. aria-label невидимий і часто не перекладається автоперекладом. Критерій 2.5.3 Label in Name вимагає, щоб ім'я містило видимий текст, інакше голосове керування не спрацює. Я це перевіряю буквально: кажу видимий текст у Voice Control.
🗣 How to say it out loud My order is: first visible text linked natively, then aria-labelledby when the text is already on the page, and only then aria-label when there is no text, as with an icon button. aria-label is invisible and often skipped by automatic translation. Criterion 2.5.3 Label in Name requires the name to contain the visible text, or voice control will not work. I test it literally: I say the visible text to Voice Control.
13 Що таке accessible description і чим aria-describedby відрізняється від імені? What is an accessible description, and how does aria-describedby differ from the name?
Семантика і ARIASemantics and ARIA Middle ▶

Що це. Accessible description — додатковий текст, який пояснює елемент, але не називає його. Ім'я відповідає на «що це?», опис — на «що ще треба знати?». Скрінрідер зазвичай читає опис після імені, ролі й стану, іноді з паузою, і користувач може налаштувати, чи чути описи взагалі.

What it is. The accessible description is extra text that explains an element without naming it. The name answers «what is this?», the description answers «what else do I need to know?». A screen reader usually reads the description after the name, role and state, sometimes after a pause, and the user can configure whether descriptions are read at all.

Джерела. Основне — aria-describedby з одним або кількома id (текст склеюється, прихований елемент теж працює). Далі aria-description з рядком (новіший атрибут, підтримка неповна) і title, якщо він не пішов на ім'я.

Sources. The main one is aria-describedby with one or more ids (texts are joined, a hidden element works too). Then aria-description with a string (a newer attribute, support is incomplete) and title if it was not used for the name.

Де потрібен. Вимоги до пароля біля поля, текст помилки валідації, підказка про формат дати, попередження біля кнопки «Видалити акаунт», пояснення в діалозі підтвердження. Помилка форми через aria-describedby разом з aria-invalid=true — стандартний патерн для 3.3.1.

Where it is needed. Password requirements next to a field, a validation error message, a date format hint, a warning next to a «Delete account» button, an explanation in a confirmation dialog. A form error linked via aria-describedby together with aria-invalid=true is the standard pattern for 3.3.1.

Типові помилки. Засунути все в ім'я: aria-label=Пароль мінімум 12 символів з цифрою — довге ім'я читається при кожному переході і ламає getByRole. Посилання на id, якого вже немає після перемальовування компонента. Опис, що дублює ім'я, — користувач чує те саме двічі.

Common mistakes. Stuffing everything into the name: aria-label=Password at least 12 characters with a digit — a long name is read on every visit and breaks getByRole. Pointing to an id that no longer exists after the component re-renders. A description that repeats the name, so the user hears the same thing twice.

Перевірка. DevTools показує Description у панелі Accessibility. У Playwright є toHaveAccessibleDescription.

Checking. DevTools shows Description in the Accessibility pane. Playwright has toHaveAccessibleDescription.

Поле пароля: коротке ім'я, вимоги в описі
Password field: short name, requirements in the description
<label for="pwd">Новий пароль</label>
<input id="pwd" type="password" autocomplete="new-password"
       aria-describedby="pwd-rules pwd-error" aria-invalid="true">
<p id="pwd-rules">Щонайменше 12 символів, одна цифра.</p>
<p id="pwd-error">Пароль закороткий: 8 із 12 символів.</p>
<!-- NVDA: «Новий пароль, поле редагування, пароль, недійсні дані,
     Щонайменше 12 символів, одна цифра. Пароль закороткий: 8 із 12 символів.» -->
<label for="pwd">New password</label>
<input id="pwd" type="password" autocomplete="new-password"
       aria-describedby="pwd-rules pwd-error" aria-invalid="true">
<p id="pwd-rules">At least 12 characters, one digit.</p>
<p id="pwd-error">Password too short: 8 of 12 characters.</p>
<!-- NVDA: «New password, edit, protected, invalid entry,
     At least 12 characters, one digit. Password too short: 8 of 12 characters.» -->
const pwd = page.getByRole('textbox', { name: 'Новий пароль' });
await expect(pwd).toHaveAccessibleDescription(/Щонайменше 12 символів/);
await expect(pwd).toHaveAttribute('aria-invalid', 'true');
const pwd = page.getByRole('textbox', { name: 'New password' });
await expect(pwd).toHaveAccessibleDescription(/At least 12 characters/);
await expect(pwd).toHaveAttribute('aria-invalid', 'true');
🔧 Розбір прикладу: aria-describedby приймає список id через пробіл, тож правила і помилка складаються в один опис. Ім'я лишається коротким, тому локатор getByRole з ним стабільний. Точне формулювання озвучки залежить від скрінрідера і мови. Поле типу password у Playwright має роль textbox.
🔧 Example breakdown: aria-describedby takes a space-separated list of ids, so the rules and the error combine into one description. The name stays short, so a getByRole locator using it stays stable. The exact announcement wording depends on the screen reader and language. A password field has the textbox role in Playwright.
💡 Простими словами: Ім'я — коротка назва, опис — примітка до неї, яку читають слідом.
💡 In plain words: The name is a short title; the description is the footnote read right after it.
🗣 Як читати вголос Accessible description — це додаткове пояснення до елемента, яке скрінрідер читає після імені і ролі. Найчастіше воно задається через aria-describedby, куди можна передати кілька id. Я використовую це для вимог до пароля, підказок формату і текстів помилок, тоді як ім'я лишається коротким. Типова помилка — запхати все в aria-label, тоді ім'я стає довгим і дратівливим.
🗣 How to say it out loud The accessible description is an extra explanation for an element that a screen reader reads after the name and role. Most often it is set with aria-describedby, which can take several ids. I use it for password rules, format hints and error texts, while the name stays short. The typical mistake is stuffing everything into aria-label, so the name becomes long and irritating.
14 Як зробити й протестувати доступну кнопку-іконку без тексту? How do you build and test an accessible icon-only button?
Семантика і ARIASemantics and ARIA Junior ▶

Проблема. Кнопка з самою іконкою (хрестик, кошик, серце, три крапки) без імені оголошується як «кнопка» або «кнопка, графіка». Користувач скрінрідера не знає, що вона зробить, голосове керування не має слова, яке сказати. Порушення 4.1.2, а для кнопки-зображення ще й 1.1.1. axe ловить це правилом button-name, для посилань — link-name.

The problem. A button with only an icon (a cross, a bin, a heart, three dots) and no name is announced as «button» or «button, graphic». A screen reader user does not know what it will do, and voice control has no word to say. It fails 4.1.2, and for an image button 1.1.1 as well. axe catches it with the button-name rule, and link-name for links.

Способи дати ім'я. 1) aria-label на кнопці і aria-hidden=true на SVG, щоб іконка не додавала шуму. 2) Візуально прихований текст усередині кнопки (CSS-клас на кшталт visually-hidden з clip і розміром 1px) — текст читається і перекладається автоперекладом. 3) Для <img> всередині кнопки — alt, що описує дію, а не картинку: «Видалити», не «сміттєвий бак».

Ways to give it a name. 1) aria-label on the button and aria-hidden=true on the SVG so the icon adds no noise. 2) Visually hidden text inside the button (a CSS class such as visually-hidden with clip and 1px size) — the text is read and translated by automatic translation. 3) For an <img> inside the button — an alt describing the action, not the picture: «Delete», not «rubbish bin».

Що ще перевірити. Ім'я описує дію і контекст: у списку з двадцяти «Видалити» краще «Видалити Звіт за березень». Стан перемикача передається aria-pressed («Додати в обране», натиснуто), а не зміною імені. Розмір цілі щонайменше 24×24 CSS px (2.5.8). Іконка має контраст 3:1 з фоном (1.4.11). Підказка-тултіп при наведенні корисна зрячим, але вона має з'являтися й при фокусі клавіатури.

What else to check. The name describes the action and context: in a list of twenty «Delete» buttons, «Delete March report» is better. A toggle state goes into aria-pressed («Add to favourites», pressed), not into a changing name. Target size is at least 24×24 CSS px (2.5.8). The icon has 3:1 contrast with the background (1.4.11). A hover tooltip helps sighted users, but it must also appear on keyboard focus.

Помилка. Покладатися на title: на тачскрінах і для клавіатури він не показується, а деякі скрінрідери його не читають.

The mistake. Relying on title: it does not show on touch screens or for the keyboard, and some screen readers do not read it.

Кнопка-іконка: погано, добре і тест
Icon button: bad, good and a test
<!-- ПОГАНО: «кнопка», і все -->
<button class="icon-btn"><svg viewBox="0 0 24 24">...</svg></button>

<!-- ДОБРЕ: ім'я з дією, іконка прихована, стан перемикача -->
<button type="button" aria-label="Закрити сповіщення">
  <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">...</svg>
</button>
<button type="button" aria-pressed="false">
  <svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
  <span class="visually-hidden">Додати в обране</span>
</button>
<!-- BAD: «button», and that is all -->
<button class="icon-btn"><svg viewBox="0 0 24 24">...</svg></button>

<!-- GOOD: name with the action, icon hidden, toggle state -->
<button type="button" aria-label="Close notification">
  <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">...</svg>
</button>
<button type="button" aria-pressed="false">
  <svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
  <span class="visually-hidden">Add to favourites</span>
</button>
test('кнопка обраного має ім\'я і стан', async ({ page }) => {
  await page.goto('/product/42');
  const fav = page.getByRole('button', { name: 'Додати в обране' });
  await expect(fav).toHaveAttribute('aria-pressed', 'false');
  await fav.click();
  await expect(fav).toHaveAttribute('aria-pressed', 'true');
});
test('favourite button has a name and a state', async ({ page }) => {
  await page.goto('/product/42');
  const fav = page.getByRole('button', { name: 'Add to favourites' });
  await expect(fav).toHaveAttribute('aria-pressed', 'false');
  await fav.click();
  await expect(fav).toHaveAttribute('aria-pressed', 'true');
});
🔧 Розбір прикладу: focusable=false — давній обхід для Internet Explorer, де SVG потрапляв у Tab-порядок; сучасним браузерам він не потрібен, але не шкодить. Тест знаходить кнопку саме за ім'ям: якщо ім'я зникне, локатор не знайде елемент і тест впаде.
🔧 Example breakdown: focusable=false is an old workaround for Internet Explorer, where SVGs landed in the Tab order; modern browsers do not need it, but it does no harm. The test finds the button by its name: if the name disappears, the locator finds nothing and the test fails.
💡 Простими словами: Іконка — для очей, ім'я — для всіх інших; у кнопки мають бути обидва.
💡 In plain words: The icon is for the eyes, the name is for everyone else; a button needs both.
🗣 Як читати вголос Кнопка з самою іконкою без імені для скрінрідера звучить просто як «кнопка», і людина не знає, що вона робить. Я очікую aria-label або прихований текст з назвою дії, а SVG з aria-hidden. Якщо це перемикач, як «обране», стан має бути в aria-pressed. У тестах я шукаю таку кнопку через getByRole з name, тож відсутнє ім'я одразу валить тест, а axe ловить його правилом button-name.
🗣 How to say it out loud An icon-only button with no name just sounds like «button» to a screen reader, and the person has no idea what it does. I expect an aria-label or hidden text naming the action, with the SVG marked aria-hidden. If it is a toggle, like favourites, the state goes into aria-pressed. In tests I find such a button with getByRole and a name, so a missing name fails the test straight away, and axe catches it with the button-name rule.
15 Як переглянути дерево доступності в DevTools і зафіксувати його в Playwright-тесті? How do you inspect the accessibility tree in DevTools and lock it down in a Playwright test?
Семантика і ARIASemantics and ARIA Middle ▶

Що це. Дерево доступності — паралельне DOM дерево, яке браузер будує для допоміжних технологій: лише значущі вузли з роллю, ім'ям, описом, станом і значенням. Декоративні <div> туди не потрапляють, а aria-hidden вирізає піддерево. Скрінрідер бачить саме його, тому це найточніший спосіб зрозуміти, що «почує» користувач, без запуску NVDA.

What it is. The accessibility tree is a structure parallel to the DOM that the browser builds for assistive technology: only meaningful nodes with a role, name, description, state and value. Decorative <div>s do not make it in, and aria-hidden cuts out a subtree. This is exactly what a screen reader sees, so it is the most accurate way to know what the user will «hear» without launching NVDA.

Інструменти. Chrome DevTools → Elements → вкладка Accessibility: обчислені Name, Role, Description і джерело кожного значення; перемикач повного дерева сторінки показує все дерево замість DOM. Firefox Accessibility Inspector має режим «Check for issues» (контраст, клавіатура, текстові альтернативи) і симуляцію порушень кольоросприйняття. Safari на macOS показує роль і ім'я в Web Inspector.

Tools. Chrome DevTools → Elements → Accessibility tab: the computed Name, Role, Description and the source of each value; the full-page tree toggle replaces the DOM view with the whole tree. The Firefox Accessibility Inspector has «Check for issues» (contrast, keyboard, text alternatives) and colour vision deficiency simulation. Safari on macOS shows role and name in Web Inspector.

У тестах. Playwright має locator.ariaSnapshot() і toMatchAriaSnapshot (з версії 1.49): YAML-подібний знімок ролей, імен і атрибутів на кшталт level, checked, expanded. Такий тест ловить регресії семантики, які не видно на скриншоті: кнопка втратила ім'я, заголовок став <div>, з'явився зайвий landmark.

In tests. Playwright has locator.ariaSnapshot() and toMatchAriaSnapshot (since 1.49): a YAML-like snapshot of roles, names and attributes such as level, checked, expanded. Such a test catches semantic regressions that a screenshot cannot: a button lost its name, a heading became a <div>, an extra landmark appeared.

Обережно. Знімок усієї сторінки ламається від будь-якої зміни тексту. Фіксуй невеликі стабільні частини — навігацію, форму, діалог — і використовуй регулярні вирази для динамічних значень.

Careful. A snapshot of the whole page breaks on any text change. Pin small, stable parts — navigation, a form, a dialog — and use regular expressions for dynamic values.

Знімок дерева доступності форми входу
Accessibility tree snapshot of the sign-in form
test('семантика форми входу не регресує', async ({ page }) => {
  await page.goto('/login');
  await expect(page.getByRole('main')).toMatchAriaSnapshot(
    '- heading "Вхід" [level=1]\n' +
    '- textbox "Email"\n' +
    '- textbox "Пароль"\n' +
    '- checkbox "Запам\'ятати мене" [checked=false]\n' +
    '- button "Увійти"\n' +
    '- link "Забули пароль?"'
  );
});
test('sign-in form semantics do not regress', async ({ page }) => {
  await page.goto('/login');
  await expect(page.getByRole('main')).toMatchAriaSnapshot(
    '- heading "Sign in" [level=1]\n' +
    '- textbox "Email"\n' +
    '- textbox "Password"\n' +
    '- checkbox "Remember me" [checked=false]\n' +
    '- button "Sign in"\n' +
    '- link "Forgot password?"'
  );
});
🔧 Розбір прикладу: Кожен рядок знімка — вузол «роль ім'я [атрибути]». Порівняння часткове: вузли, не згадані в шаблоні, не валять тест, тож знімок перевіряє головне і не ламається від кожного декоративного елемента. Знімок можна згенерувати командою npx playwright codegen або з ariaSnapshot().
🔧 Example breakdown: Each line of the snapshot is a node «role name [attributes]». Matching is partial: nodes not mentioned in the template do not fail the test, so the snapshot checks what matters without breaking on every decorative element. You can generate it with npx playwright codegen or from ariaSnapshot().
💡 Простими словами: Дерево доступності — це сторінка очима скрінрідера, і його можна і подивитися, і протестувати.
💡 In plain words: The accessibility tree is the page through a screen reader's eyes, and you can both inspect it and test it.
🗣 Як читати вголос Дерево доступності — це те, що браузер віддає скрінрідеру: вузли з роллю, ім'ям і станом. Я дивлюся його в Chrome DevTools на вкладці Accessibility, там видно і обчислене ім'я, і звідки воно взялося. У Firefox є ще перевірка проблем і симуляція кольоросліпоти. В автотестах я фіксую семантику ключових форм через toMatchAriaSnapshot, але лише невеликих блоків, щоб тест не був крихким.
🗣 How to say it out loud The accessibility tree is what the browser hands to a screen reader: nodes with a role, a name and a state. I look at it in Chrome DevTools on the Accessibility tab, which shows the computed name and where it came from. Firefox also has an issue checker and colour blindness simulation. In automated tests I pin the semantics of key forms with toMatchAriaSnapshot, but only for small blocks so the test is not brittle.
▶ 3 5
16 Чому важлива ієрархія заголовків і як її перевірити? Why does heading hierarchy matter, and how do you check it?
СтруктураStructure Junior ▶

Навіщо. Заголовки — головний спосіб навігації для незрячих: за опитуваннями WebAIM серед користувачів скрінрідерів більшість спершу шукає інформацію саме через заголовки. У NVDA клавіша H стрибає до наступного заголовка, цифри 1–6 — до заголовка певного рівня, а список елементів показує зміст сторінки. Для зрячих заголовки теж важливі: вони дозволяють сканувати сторінку очима.

Why. Headings are the main way blind users navigate: in WebAIM surveys most screen reader users say they look for information through headings first. In NVDA the H key jumps to the next heading, digits 1–6 jump to a heading of that level, and the elements list shows a table of contents. Headings matter for sighted users too: they let you scan the page.

Правила. Один <h1> з назвою сторінки чи головного вмісту. Рівні відображають вкладеність: під <h2> розділу йдуть <h3> підрозділів. Пропуск рівня (<h2> → <h4>) — не формальне порушення WCAG, але плутає користувача: він думає, що пропустив розділ. Заголовок має описувати, що нижче (2.4.6 Headings and Labels, AA), а все, що візуально виглядає як заголовок, має бути розмічене як заголовок (1.3.1 Info and Relationships, A).

Rules. One <h1> with the page or main content title. Levels reflect nesting: <h3> subsections go under a section's <h2>. Skipping a level (<h2> → <h4>) is not a formal WCAG failure, but it confuses users: they think they missed a section. A heading must describe what follows (2.4.6 Headings and Labels, AA), and anything that looks like a heading must be marked up as one (1.3.1 Info and Relationships, A).

Типові баги. Жирний <div> замість заголовка. <h3>, обраний заради розміру шрифту. Порожній заголовок з іконкою. Заголовки в картках товарів, які перетворюють сторінку каталогу на 50 <h2> без структури. Модалка без заголовка.

Typical bugs. A bold <div> instead of a heading. An <h3> picked for its font size. An empty heading holding an icon. Headings inside product tiles that turn a catalogue page into 50 <h2>s with no structure. A modal without a heading.

Як перевірити. Розширення HeadingsMap чи WAVE (вкладка Structure) показують дерево заголовків. Швидко — сніпет у консолі. У NVDA — Insert+F7, список заголовків. axe має правила heading-order і page-has-heading-one, але вони в категорії best-practice, а не WCAG-тегах.

How to check. The HeadingsMap extension or WAVE (Structure tab) show the heading tree. For a quick look, a console snippet. In NVDA, Insert+F7 opens the heading list. axe has the heading-order and page-has-heading-one rules, but they are in the best-practice category rather than WCAG tags.

Сніпет для консолі: контур заголовків і пропуски рівнів
Console snippet: heading outline and skipped levels
// вставити в консоль DevTools на сторінці, що тестується
let prev = 0;
document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=heading]').forEach((h) => {
  const level = Number(h.getAttribute('aria-level') || h.tagName.slice(1));
  const text = h.textContent.trim() || '(ПОРОЖНІЙ ЗАГОЛОВОК)';
  const skip = prev && level > prev + 1 ? '   <-- пропуск рівня' : '';
  console.log('  '.repeat(level - 1) + 'h' + level + ' ' + text + skip);
  prev = level;
});
// paste into the DevTools console on the page under test
let prev = 0;
document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=heading]').forEach((h) => {
  const level = Number(h.getAttribute('aria-level') || h.tagName.slice(1));
  const text = h.textContent.trim() || '(EMPTY HEADING)';
  const skip = prev && level > prev + 1 ? '   <-- skipped level' : '';
  console.log('  '.repeat(level - 1) + 'h' + level + ' ' + text + skip);
  prev = level;
});
🔧 Розбір прикладу: Сніпет бере і нативні заголовки, і role=heading з aria-level, друкує їх з відступом за рівнем і позначає стрибок більше ніж на один рівень. Він не бачить візуальних «заголовків», зроблених з <div>, — їх знаходить лише людина, порівнюючи вивід з екраном.
🔧 Example breakdown: The snippet takes both native headings and role=heading with aria-level, prints them indented by level and flags a jump of more than one level. It cannot see visual «headings» made of <div>s — only a person comparing the output with the screen finds those.
💡 Простими словами: Заголовки — це зміст сторінки, по якому ходять скрінрідером; рівень обирають за структурою, а не за розміром шрифту.
💡 In plain words: Headings are the table of contents screen reader users navigate by; pick the level for structure, not font size.
🗣 Як читати вголос Заголовки для користувачів скрінрідерів — основний спосіб зорієнтуватися, вони стрибають по них клавішею H або відкривають список. Тому я перевіряю, що є один h1, рівні йдуть по вкладеності, без стрибків з другого на четвертий, і що все, що виглядає як заголовок, розмічено як заголовок. Дивлюся через HeadingsMap або простий сніпет у консолі, а потім звіряю з тим, що бачу на екрані.
🗣 How to say it out loud For screen reader users headings are the main way to get oriented; they jump through them with the H key or open a list. So I check there is one h1, the levels follow the nesting with no jumps from two to four, and that everything that looks like a heading is marked up as one. I look with HeadingsMap or a simple console snippet, then compare it with what I see on screen.
17 Що таке landmark-ролі і як ними користуються люди зі скрінрідером? What are landmark roles, and how do screen reader users use them?
СтруктураStructure Junior ▶

Що це. Landmark — область сторінки з роллю-орієнтиром, до якої можна перестрибнути одною клавішею: banner (шапка), navigation, main, complementary (бічна панель), contentinfo (футер), search, form і region (останні два — лише якщо мають ім'я). HTML5 дає їх нативно: <header> верхнього рівня → banner, <nav> → navigation, <main> → main, <aside> → complementary, <footer> верхнього рівня → contentinfo, <search> → search.

What it is. A landmark is a page area with a signpost role that you can jump to with a single key: banner (header), navigation, main, complementary (sidebar), contentinfo (footer), search, form and region (the last two only when they have a name). HTML5 provides them natively: a top-level <header> → banner, <nav> → navigation, <main> → main, <aside> → complementary, a top-level <footer> → contentinfo, <search> → search.

Як ними користуються. У NVDA клавіша D переходить до наступного орієнтира, у JAWS — R, у VoiceOver орієнтири є в ротор-меню. Так людина пропускає меню з сорока посилань і одразу потрапляє в основний вміст, як зрячий користувач, що просто дивиться в центр екрана.

How they are used. In NVDA the D key moves to the next landmark, in JAWS it is R, and in VoiceOver landmarks are in the rotor. That way a person skips a forty-link menu and lands straight in the main content, like a sighted user who simply looks at the middle of the screen.

Правила для тесту. Увесь контент усередині якогось орієнтира (axe region, best-practice). Рівно один main. Якщо орієнтирів одного типу кілька — у кожного унікальне ім'я: <nav aria-label=Головне> і <nav aria-label=Хлібні крихти>, причому слово «навігація» в ім'я не пишуть, скрінрідер додає роль сам. <header> всередині <article> не стає banner — це нормально.

Rules to test. All content sits inside some landmark (axe region, best-practice). Exactly one main. When there are several landmarks of one type, each has a unique name: <nav aria-label=Main> and <nav aria-label=Breadcrumbs> — without the word «navigation» in the name, since the screen reader adds the role itself. A <header> inside an <article> does not become a banner, and that is fine.

Помилки. Кожен блок як <section> з aria-label — десятки region, орієнтири втрачають цінність. Модальне вікно поза main без ролі dialog. Банер cookie без орієнтира, який неможливо знайти.

Mistakes. Every block as a <section> with an aria-label — dozens of regions, and landmarks lose their value. A modal outside main without a dialog role. A cookie banner outside any landmark that nobody can find.

Каркас сторінки з орієнтирами
Page skeleton with landmarks
<header>                                   <!-- banner -->
  <a href="/">Shop</a>
  <nav aria-label="Головне">...</nav>      <!-- navigation «Головне» -->
  <search><form>...</form></search>        <!-- search -->
</header>
<nav aria-label="Хлібні крихти">...</nav>  <!-- друга navigation, інше ім'я -->
<main id="main">                           <!-- main, рівно один -->
  <h1>Ноутбуки</h1>
  <section aria-labelledby="filters-h">    <!-- region, бо має ім'я -->
    <h2 id="filters-h">Фільтри</h2>
  </section>
</main>
<aside>...</aside>                         <!-- complementary -->
<footer>...</footer>                       <!-- contentinfo -->
<header>                                   <!-- banner -->
  <a href="/">Shop</a>
  <nav aria-label="Main">...</nav>         <!-- navigation «Main» -->
  <search><form>...</form></search>        <!-- search -->
</header>
<nav aria-label="Breadcrumbs">...</nav>    <!-- second navigation, different name -->
<main id="main">                           <!-- main, exactly one -->
  <h1>Laptops</h1>
  <section aria-labelledby="filters-h">    <!-- region, because it has a name -->
    <h2 id="filters-h">Filters</h2>
  </section>
</main>
<aside>...</aside>                         <!-- complementary -->
<footer>...</footer>                       <!-- contentinfo -->
🔧 Розбір прикладу: Коментарі показують, яку роль отримає кожен тег. Елемент <search> з'явився в HTML нещодавно; у старіших браузерах той самий результат дає <form role=search>. <section> без імені лишається звичайним generic-блоком і орієнтиром не стає.
🔧 Example breakdown: The comments show the role each tag gets. The <search> element is a recent HTML addition; in older browsers <form role=search> gives the same result. A <section> with no name stays a plain generic block and does not become a landmark.
💡 Простими словами: Орієнтири — це «швидкі стрибки» по великих зонах сторінки, як закладки в книжці.
💡 In plain words: Landmarks are quick jumps between big page zones, like bookmarks in a book.
🗣 Як читати вголос Landmark-и — це великі зони сторінки з ролями: шапка, навігація, основний вміст, бічна панель, футер. Користувач NVDA стрибає між ними клавішею D, у JAWS — R, у VoiceOver через ротор. Я перевіряю, що є рівно один main, увесь контент лежить у якомусь орієнтирі, а дві навігації мають різні імена. І не допускаю, щоб кожна секція ставала region, бо тоді навігація по орієнтирах втрачає сенс.
🗣 How to say it out loud Landmarks are the big page zones with roles: header, navigation, main content, sidebar, footer. An NVDA user jumps between them with D, in JAWS with R, in VoiceOver through the rotor. I check there is exactly one main, all content lives in some landmark, and two navigation areas have different names. And I do not let every section become a region, because then landmark navigation loses its point.
18 Навіщо атрибут lang на сторінці і що таке мова частин контенту? Why does the page need a lang attribute, and what is language of parts?
СтруктураStructure Junior ▶

Навіщо. Скрінрідер обирає голос і правила вимови за мовою. Якщо в <html> немає lang або там en на українській сторінці, NVDA прочитає «Оформлення замовлення» англійським синтезатором, і вийде нерозбірлива каша. Також від мови залежать переклад браузера, перенесення слів (hyphens), лапки в CSS, перевірка правопису і шрифти для деяких мов.

Why. A screen reader picks the voice and pronunciation rules by language. If <html> has no lang, or says en on a Ukrainian page, NVDA reads Ukrainian text with an English synthesiser and the result is gibberish. Language also drives browser translation, hyphenation (hyphens), CSS quotes, spell checking and font choice for some scripts.

Критерії. 3.1.1 Language of Page (A): основна мова вказана в <html lang> коректним кодом BCP 47 (uk, en, en-GB, pt-BR). 3.1.2 Language of Parts (AA): фрагменти іншою мовою позначені власним lang — англійська цитата на українській сторінці, назва мови в перемикачі мов («English» з lang=en, «Українська» з lang=uk). Винятки: власні назви, технічні терміни й слова, що стали частиною мови.

Criteria. 3.1.1 Language of Page (A): the main language is set in <html lang> with a valid BCP 47 code (uk, en, en-GB, pt-BR). 3.1.2 Language of Parts (AA): passages in another language get their own lang — an English quote on a Ukrainian page, language names in a language switcher («English» with lang=en, «Українська» with lang=uk). Exceptions: proper names, technical terms and words that have become part of the language.

Для багатомовних продуктів. При перемиканні мови в SPA треба оновити document.documentElement.lang, а не лише тексти, — частий баг. Невалідний код (ua замість uk — ua це код країни, а не мови) axe ловить правилом valid-lang, відсутній — html-has-lang.

For multilingual products. When the language switches in an SPA, document.documentElement.lang must be updated, not just the texts — a frequent bug. An invalid code (ua instead of uk: ua is a country code, not a language code) is caught by the axe valid-lang rule, a missing one by html-has-lang.

Як перевірити. axe для наявності й валідності, а правильність значення — вручну: перемкни мову інтерфейсу і подивись атрибут, прослухай абзац у NVDA.

How to check. axe for presence and validity; whether the value is right, by hand: switch the UI language and look at the attribute, listen to a paragraph in NVDA.

Мова сторінки, мова частин і тест перемикача
Page language, language of parts and a switcher test
<!-- ПОГАНО: немає lang, або код країни замість коду мови -->
<html>            <html lang="ua">

<!-- ДОБРЕ -->
<html lang="uk">
  <p>Як казав Кент Бек: <q lang="en">Make it work, make it right, make it fast.</q></p>
  <a href="/en/" lang="en" hreflang="en">English</a>
</html>
<!-- BAD: no lang, or a country code instead of a language code -->
<html>            <html lang="ua">

<!-- GOOD -->
<html lang="en">
  <p>As the French say, <q lang="fr">c'est la vie</q>.</p>
  <a href="/de/" lang="de" hreflang="de">Deutsch</a>
</html>
test('перемикання мови оновлює html lang (WCAG 3.1.1)', async ({ page }) => {
  await page.goto('/');
  await expect(page.locator('html')).toHaveAttribute('lang', 'uk');
  await page.getByRole('link', { name: 'English' }).click();
  await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
test('switching language updates html lang (WCAG 3.1.1)', async ({ page }) => {
  await page.goto('/');
  await expect(page.locator('html')).toHaveAttribute('lang', 'uk');
  await page.getByRole('link', { name: 'English' }).click();
  await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
🔧 Розбір прикладу: hreflang підказує мову сторінки за посиланням, а lang на самому посиланні — мову його тексту, тож «English» буде вимовлено англійським голосом навіть на українській сторінці. Цитата англійською в українському абзаці — саме той фрагмент, що вимагає власного lang за 3.1.2.
🔧 Example breakdown: hreflang hints at the language of the target page, while lang on the link sets the language of its text, so «Deutsch» is spoken with a German voice even on an English page. The French phrase inside an English paragraph is exactly the kind of fragment that needs its own lang under 3.1.2.
💡 Простими словами: lang каже скрінрідеру, яким «акцентом» читати; без нього текст стає нерозбірливим.
💡 In plain words: lang tells the screen reader which «accent» to use; without it the text becomes unintelligible.
🗣 Як читати вголос Атрибут lang визначає, яким голосом і з якою вимовою скрінрідер читає текст. Якщо на українській сторінці стоїть en або нічого, звучить каша. Критерій 3.1.1 вимагає мову сторінки, а 3.1.2 — позначати фрагменти іншою мовою, наприклад назви мов у перемикачі. У SPA я окремо перевіряю, що після перемикання мови оновлюється сам атрибут на html, і що там uk, а не ua.
🗣 How to say it out loud The lang attribute decides which voice and pronunciation a screen reader uses. If a Ukrainian page says en or nothing, you hear gibberish. Criterion 3.1.1 requires the page language, and 3.1.2 requires marking passages in another language, like the language names in a switcher. In an SPA I separately check that switching language updates the attribute on html itself, and that it says uk, not ua.
19 Як зробити таблицю даних доступною і як це протестувати? How do you make a data table accessible, and how do you test it?
СтруктураStructure Middle ▶

Що дає правильна розмітка. Коли таблиця розмічена <table>, <th> і scope, скрінрідер при переході між клітинками (у NVDA — Ctrl+Alt+стрілки) озвучує заголовок стовпця чи рядка: «Статус, Оплачено» замість голого «Оплачено». Він також каже розмір таблиці і позицію («рядок 3, стовпець 2»). Без цього незрячий користувач отримує купу чисел без контексту — порушення 1.3.1.

What correct markup gives you. When a table uses <table>, <th> and scope, a screen reader moving between cells (in NVDA, Ctrl+Alt+arrows) announces the column or row header: «Status, Paid» instead of a bare «Paid». It also says the table size and position («row 3, column 2»). Without this a blind user gets a heap of numbers with no context — a 1.3.1 failure.

Правила. <caption> — назва таблиці (стає її accessible name). <th scope=col> для заголовків стовпців, <th scope=row> для заголовка рядка (наприклад, номер замовлення). Для складних таблиць з кількома рівнями заголовків — headers і id, хоча часто краще розбити таблицю на простіші. Таблиці для верстки — анти-патерн; якщо така лишилась, їй потрібен role=presentation.

Rules. <caption> is the table's title (it becomes its accessible name). <th scope=col> for column headers, <th scope=row> for a row header (such as an order number). For complex tables with several header levels — headers and id, though splitting into simpler tables is often better. Layout tables are an anti-pattern; if one remains, it needs role=presentation.

Сучасні пастки. «Таблиця» з <div> у CSS grid виглядає як таблиця, але для скрінрідера це текст — потрібні ролі table/row/columnheader/cell або справжній <table>. display:block чи display:flex на <table> в деяких браузерах історично знищували табличну семантику. Сортування: заголовок-кнопка всередині <th> і aria-sort на <th>. Інтерактивні таблиці з навігацією стрілками — це вже патерн grid з APG.

Modern traps. A «table» made of <div>s in CSS grid looks like a table but is plain text to a screen reader — it needs the table/row/columnheader/cell roles or a real <table>. display:block or display:flex on a <table> historically wiped table semantics in some browsers. Sorting: a button inside the <th> and aria-sort on the <th>. Interactive tables with arrow-key navigation are the APG grid pattern.

Як тестую. axe (td-has-header, th-has-data-cells, empty-table-header), а потім NVDA: T до таблиці, Ctrl+Alt+стрілки, слухаю заголовки.

How I test. axe (td-has-header, th-has-data-cells, empty-table-header), then NVDA: T to the table, Ctrl+Alt+arrows, listening for the headers.

Таблиця замовлень з сортуванням
An orders table with sorting
<table>
  <caption>Мої замовлення за 2026 рік</caption>
  <thead>
    <tr>
      <th scope="col">Номер</th>
      <th scope="col" aria-sort="descending">
        <button type="button">Дата</button>
      </th>
      <th scope="col">Статус</th>
      <th scope="col">Сума</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">#1042</th>
      <td>12.03.2026</td><td>Оплачено</td><td>1 250 грн</td>
    </tr>
  </tbody>
</table>
<!-- NVDA на клітинці «Оплачено»: «Статус, стовпець 3, Оплачено» -->
<table>
  <caption>My orders in 2026</caption>
  <thead>
    <tr>
      <th scope="col">Number</th>
      <th scope="col" aria-sort="descending">
        <button type="button">Date</button>
      </th>
      <th scope="col">Status</th>
      <th scope="col">Total</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">#1042</th>
      <td>2026-03-12</td><td>Paid</td><td>$125.00</td>
    </tr>
  </tbody>
</table>
<!-- NVDA on the «Paid» cell: «Status, column 3, Paid» -->
🔧 Розбір прикладу: aria-sort стоїть лише на одному заголовку — тому, за яким зараз відсортовано, і змінюється при кліку. Кнопка всередині <th> робить сортування доступним з клавіатури, а номер замовлення як <th scope=row> озвучується при русі по рядку.
🔧 Example breakdown: aria-sort sits on one header only — the one currently sorted by — and changes on click. The button inside the <th> makes sorting keyboard accessible, and the order number as a <th scope=row> is announced when moving along the row.
💡 Простими словами: У доступній таблиці кожна клітинка «знає» свій заголовок, і скрінрідер його називає.
💡 In plain words: In an accessible table every cell «knows» its header, and the screen reader says it.
🗣 Як читати вголос Таблиця даних доступна, коли кожна клітинка пов'язана з заголовком. Я очікую справжній table, caption з назвою, th зі scope для стовпців і, де доречно, для рядків. Тоді NVDA на клітинці каже «Статус, Оплачено», а не просто слово. Для сортування — кнопка в заголовку і aria-sort. Окремо шукаю «таблиці» з div у grid: вони виглядають правильно, але для скрінрідера це просто текст.
🗣 How to say it out loud A data table is accessible when every cell is tied to a header. I expect a real table, a caption with its title, th with scope for the columns and, where it fits, for the rows. Then NVDA on a cell says «Status, Paid» and not just the word. For sorting, a button in the header and aria-sort. I also hunt for div-based «tables» in grid layouts: they look right, but to a screen reader they are just text.
20 Як вирішити, який alt потрібен зображенню, і коли alt має бути порожнім? How do you decide what alt an image needs, and when should alt be empty?
СтруктураStructure Junior ▶

Навіщо alt. Атрибут alt — текстова альтернатива зображенню (1.1.1 Non-text Content, A). Скрінрідер читає її замість картинки, браузер показує її, якщо зображення не завантажилось, пошукові системи використовують її для індексації.

Why alt. The alt attribute is the text alternative for an image (1.1.1 Non-text Content, A). A screen reader reads it instead of the picture, the browser shows it if the image fails to load, and search engines use it for indexing.

Дерево рішень. 1) Картинка — єдиний вміст посилання чи кнопки? alt описує дію або ціль: «На головну», а не «логотип». 2) Несе інформацію? Передай ту саму інформацію коротко: для фото товару — що на ньому важливого, для графіка — висновок («продажі зросли на 30 % у березні») плюс таблиця даних поруч. 3) Картинка з текстом? alt містить цей текст. 4) Декоративна або дублює підпис поруч? alt= (порожній), тоді скрінрідер її пропускає. 5) Складна схема? Короткий alt плюс розгорнутий опис поруч чи через aria-describedby.

Decision tree. 1) Is the image the only content of a link or button? The alt describes the action or destination: «Home», not «logo». 2) Does it carry information? Convey the same information briefly: for a product photo, what matters in it; for a chart, the conclusion («sales grew 30 % in March») plus a data table nearby. 3) An image of text? The alt contains that text. 4) Decorative, or duplicating an adjacent caption? alt= (empty), and the screen reader skips it. 5) A complex diagram? A short alt plus a longer description next to it or via aria-describedby.

Порожній проти відсутнього. alt= — свідоме «це декор». Відсутній атрибут — помилка: скрінрідер прочитає ім'я файлу, наприклад «IMG_2034 крапка jpg». axe ловить відсутність (image-alt), але не перевірить, чи alt осмислений.

Empty versus missing. alt= is a deliberate «this is decoration». A missing attribute is a bug: the screen reader reads the file name, such as «IMG underscore 2034 dot jpg». axe catches a missing one (image-alt) but cannot tell whether an alt is meaningful.

Типові помилки. «Зображення …» чи «фото …» на початку — роль і так оголошується. alt=«banner.png». Однаковий alt у всіх товарів. Декоративні SVG без aria-hidden. CSS background-image з важливою інформацією — альтернативи для нього немає зовсім.

Common mistakes. Starting with «image of…» or «photo of…» — the role is announced anyway. alt=«banner.png». The same alt on every product. Decorative SVGs without aria-hidden. A CSS background-image carrying important information — it has no alternative at all.

Рішення щодо alt на прикладах
Alt decisions in examples
<!-- функціональне: зображення — єдиний вміст посилання -->
<a href="/"><img src="logo.svg" alt="Valtive Shop — на головну"></a>

<!-- інформативне: графік з висновком і даними поруч -->
<img src="sales.png" alt="Продажі в березні зросли на 30 % порівняно з лютим"
     aria-describedby="sales-table">

<!-- декоративне: порожній alt, скрінрідер пропускає -->
<img src="divider-wave.svg" alt="">

<!-- дубль підпису: текст уже поруч, alt порожній -->
<figure>
  <img src="ceo.jpg" alt="">
  <figcaption>Олена Коваль, CEO, на конференції QA Fest</figcaption>
</figure>

<!-- ПОГАНО: немає alt, прочитається ім'я файлу -->
<img src="IMG_2034.jpg">
<!-- functional: the image is the only content of a link -->
<a href="/"><img src="logo.svg" alt="Valtive Shop home"></a>

<!-- informative: a chart with the conclusion and data nearby -->
<img src="sales.png" alt="March sales up 30 % compared with February"
     aria-describedby="sales-table">

<!-- decorative: empty alt, the screen reader skips it -->
<img src="divider-wave.svg" alt="">

<!-- duplicates the caption: the text is already next to it, alt is empty -->
<figure>
  <img src="ceo.jpg" alt="">
  <figcaption>Olena Koval, CEO, at the QA Fest conference</figcaption>
</figure>

<!-- BAD: no alt, the file name will be read -->
<img src="IMG_2034.jpg">
🔧 Розбір прикладу: Для кожного типу зображення питання одне: що людина втратить, якщо картинку прибрати? Якщо нічого — alt порожній. Якщо дію — alt описує дію. Якщо дані — alt дає висновок, а повні дані лежать у тексті чи таблиці.
🔧 Example breakdown: For every kind of image the question is the same: what would a person lose if the picture were removed? If nothing, the alt is empty. If an action, the alt describes the action. If data, the alt gives the conclusion and the full data lives in text or a table.
💡 Простими словами: alt — це те, що ти сказав би по телефону людині, яка не бачить картинку; для прикраси — нічого.
💡 In plain words: alt is what you would say on the phone to someone who cannot see the picture; for decoration, nothing.
🗣 Як читати вголос Для alt я думаю про призначення картинки. Якщо вона в посиланні, alt описує, куди воно веде. Якщо несе інформацію, alt передає суть, а для графіка ще й дані поруч. Якщо це декор або підпис уже є поруч, alt порожній, і скрінрідер її пропустить. А от відсутній alt — баг, бо читається ім'я файлу. axe знайде відсутній alt, але чи він змістовний, перевіряю я сам.
🗣 How to say it out loud For alt I think about the image's purpose. If it is inside a link, the alt says where the link goes. If it carries information, the alt gives the point, and for a chart the data sits nearby as well. If it is decoration or the caption is right next to it, the alt is empty and the screen reader skips it. A missing alt, though, is a bug because the file name gets read. axe will find a missing alt, but whether it is meaningful I check myself.
▶ 4 7
21 Що перевіряє клавіатурне тестування і як виглядає сценарій такої перевірки? What does keyboard testing check, and what does such a test script look like?
КлавіатураKeyboard Junior ▶

Навіщо. Клавіатурою користуються незрячі (скрінрідер керується з клавіатури), люди з тремором чи паралічем, користувачі перемикачів і голосового керування (вони емулюють клавіші), а також досвідчені користувачі. 2.1.1 Keyboard (A): уся функціональність доступна з клавіатури без вимог до швидкості натискань.

Why. The keyboard is used by blind people (a screen reader is driven from the keyboard), people with tremors or paralysis, switch and voice control users (they emulate keys) and power users. 2.1.1 Keyboard (A): all functionality is available from the keyboard without timing requirements for keystrokes.

Що саме перевіряю. 1) Досяжність: кожен інтерактивний елемент отримує фокус через Tab, нічого зайвого (декоративні елементи) не фокусується. 2) Порядок (2.4.3): фокус іде логічно, як читається сторінка, без стрибків у футер і назад. 3) Видимість (2.4.7): завжди видно, де фокус. 4) Активація: Enter для посилань і кнопок, Space для кнопок і чекбоксів, стрілки для радіокнопок, вкладок, меню, слайдерів, Esc закриває діалоги й поп-апи. 5) Немає пасток (2.1.2). 6) Фокус не губиться: після закриття модалки чи видалення рядка він потрапляє в осмислене місце, а не на <body>. 7) Немає несподіваних змін при фокусі (3.2.1): фокус на полі не відправляє форму.

What exactly I check. 1) Reachability: every interactive element takes focus via Tab, and nothing irrelevant (decorative elements) does. 2) Order (2.4.3): focus moves logically, the way the page reads, without jumping to the footer and back. 3) Visibility (2.4.7): you can always see where focus is. 4) Activation: Enter for links and buttons, Space for buttons and checkboxes, arrow keys for radios, tabs, menus and sliders, Esc closes dialogs and popups. 5) No traps (2.1.2). 6) Focus is never lost: after closing a modal or deleting a row it lands somewhere meaningful, not on <body>. 7) No surprise changes on focus (3.2.1): focusing a field does not submit the form.

Як. Відкладаю мишу і проходжу ключовий сценарій (реєстрація, пошук, чекаут) від адресного рядка до підтвердження. Tab і Shift+Tab. Записую послідовність фокусу — її легко перетворити на автотест.

How. I put the mouse away and run the key flow (sign-up, search, checkout) from the address bar to the confirmation. Tab and Shift+Tab. I write down the focus sequence — it is easy to turn into an automated test.

Помилка. Перевіряти лише, що «Tab ходить». Важливіше, чи можна завершити сценарій повністю і чи зрозуміло, де ти зараз.

The mistake. Checking only that «Tab moves». What matters more is whether the whole flow can be completed and whether it is clear where you are.

Клавіатурний сценарій у Playwright: порядок фокусу і активація
Keyboard script in Playwright: focus order and activation
import { test, expect } from '@playwright/test';

// роль та ім'я елемента, що зараз у фокусі
async function focused(page) {
  return page.evaluate(() => {
    const el = document.activeElement;
    return (el.getAttribute('aria-label') || el.textContent || el.getAttribute('name') || '').trim();
  });
}

test('форма підписки проходиться лише клавіатурою', async ({ page }) => {
  await page.goto('/subscribe');
  const order = [];
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press('Tab');
    order.push(await focused(page));
  }
  // очікуваний логічний порядок (WCAG 2.4.3)
  expect(order).toEqual(['Перейти до вмісту', 'Головна', 'email', 'Щотижня', 'Підписатися']);

  await page.getByLabel('Email').fill('qa@example.com');
  await page.getByRole('button', { name: 'Підписатися' }).focus();
  await page.keyboard.press('Enter');                 // активація з клавіатури
  await expect(page.getByRole('status')).toHaveText('Дякуємо, підписку оформлено');
});
import { test, expect } from '@playwright/test';

// name of the element that currently has focus
async function focused(page) {
  return page.evaluate(() => {
    const el = document.activeElement;
    return (el.getAttribute('aria-label') || el.textContent || el.getAttribute('name') || '').trim();
  });
}

test('subscribe form works with the keyboard only', async ({ page }) => {
  await page.goto('/subscribe');
  const order = [];
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press('Tab');
    order.push(await focused(page));
  }
  // expected logical order (WCAG 2.4.3)
  expect(order).toEqual(['Skip to content', 'Home', 'email', 'Weekly', 'Subscribe']);

  await page.getByLabel('Email').fill('qa@example.com');
  await page.getByRole('button', { name: 'Subscribe' }).focus();
  await page.keyboard.press('Enter');                 // keyboard activation
  await expect(page.getByRole('status')).toHaveText('Thanks, you are subscribed');
});
🔧 Розбір прикладу: Функція focused спрощено визначає «ім'я» активного елемента: aria-label, текст або атрибут name для поля. Тест фіксує реальну послідовність Tab, тож перестановка елементів у DOM чи додатковий фокусований div одразу його зламають. Активація саме через keyboard.press('Enter'), а не click(), бо клік перевіряє мишу.
🔧 Example breakdown: The focused helper is a simplified way to get the active element's «name»: aria-label, text or the name attribute for a field. The test pins the real Tab sequence, so reordering elements in the DOM or an extra focusable div breaks it immediately. Activation goes through keyboard.press('Enter') and not click(), because a click tests the mouse.
💡 Простими словами: Сховай мишу і пройди головний сценарій до кінця: де застряг або загубився — там баг.
💡 In plain words: Hide the mouse and run the main flow to the end: wherever you get stuck or lost, there is a bug.
🗣 Як читати вголос Клавіатурне тестування перевіряє, що всім можна користуватися без миші. Я проходжу головний сценарій Tab-ом і Shift+Tab-ом і дивлюся на чотири речі: чи досяжні всі елементи, чи логічний порядок, чи видно фокус і чи немає пасток. Плюс правильні клавіші: Enter, Space, стрілки, Esc. Послідовність фокусу для критичних форм я фіксую в Playwright-тесті, щоб регресія ловилась автоматично.
🗣 How to say it out loud Keyboard testing checks that everything can be used without a mouse. I run the main flow with Tab and Shift+Tab and look at four things: are all elements reachable, is the order logical, is focus visible, and are there no traps. Plus the right keys: Enter, Space, arrows, Esc. For critical forms I pin the focus sequence in a Playwright test so a regression is caught automatically.
22 Навіщо видимий фокус і що вимагають критерії 2.4.7 і 2.4.11? Why does focus have to be visible, and what do criteria 2.4.7 and 2.4.11 require?
КлавіатураKeyboard Junior ▶

Навіщо. Для людини, що користується клавіатурою, індикатор фокусу — те саме, що курсор миші. Без нього вона натискає Enter наосліп. 2.4.7 Focus Visible (AA): будь-який інтерфейс, керований клавіатурою, має режим, у якому індикатор фокусу видно.

Why. For a keyboard user the focus indicator is what the mouse pointer is for everyone else. Without it they press Enter blind. 2.4.7 Focus Visible (AA): any keyboard-operable interface has a mode where the focus indicator is visible.

Найчастіший баг. outline: none або outline: 0 у глобальних стилях «бо рамка некрасива» без заміни. Друга причина — стиль фокусу, який зливається з фоном: світло-блакитна тінь на білому.

The most common bug. outline: none or outline: 0 in global styles «because the ring looks ugly», with no replacement. The second cause is a focus style that blends into the background: a pale blue glow on white.

Правильно. :focus-visible показує індикатор при навігації клавіатурою і не показує при кліку мишею — так дизайнерів влаштовує, а доступність зберігається. Індикатор помітний: суцільна обвідка 2px з контрастом щонайменше 3:1 до сусідніх кольорів (це вимога 2.4.13 Focus Appearance рівня AAA, але добрий орієнтир і для AA). Якщо фокус показано лише тінню box-shadow, додай прозорий outline: у режимі Windows High Contrast (forced colors) тіні зникають, а outline лишається.

The right way. :focus-visible shows the indicator during keyboard navigation and hides it on mouse clicks — designers are happy and accessibility is kept. The indicator is noticeable: a solid 2px outline with at least 3:1 contrast against adjacent colours (that is the 2.4.13 Focus Appearance AAA requirement, but a good guide for AA too). If focus is shown only with a box-shadow, add a transparent outline: in Windows High Contrast (forced colours) mode shadows disappear but the outline stays.

2.4.11 Focus Not Obscured (Minimum), AA, новий у 2.2. Елемент у фокусі не повинен бути повністю захований під липкою шапкою, футером, cookie-банером чи чатом. Виправлення — scroll-padding з висотою шапки або відсування банера.

2.4.11 Focus Not Obscured (Minimum), AA, new in 2.2. The focused element must not be entirely hidden under a sticky header, footer, cookie banner or chat. The fix is scroll-padding equal to the header height, or moving the banner out of the way.

Перевірка. Tab через усю сторінку: на кожному кроці бачу, де фокус. Окремо — з відкритим cookie-банером і після прокрутки під липку шапку.

Checking. Tab through the whole page: at every step I can see where focus is. Separately, with the cookie banner open and after scrolling under the sticky header.

CSS: погано, добре і захист від липкої шапки
CSS: bad, good and protection from a sticky header
/* ПОГАНО: фокус зник для всіх, заміни немає (провал 2.4.7) */
*:focus { outline: none; }

/* ДОБРЕ: помітне кільце лише при навігації з клавіатури */
:focus-visible {
  outline: 3px solid #1a73e8;   /* 4.5:1 до білого фону */
  outline-offset: 2px;
}

/* тінь для дизайну + прозорий outline для режиму forced colors */
.btn:focus-visible {
  box-shadow: 0 0 0 4px #1a73e8;
  outline: 2px solid transparent;
}

/* 2.4.11: елемент під липкою шапкою висотою 72px не ховається */
html { scroll-padding-top: 80px; }
/* BAD: focus is gone for everyone, no replacement (fails 2.4.7) */
*:focus { outline: none; }

/* GOOD: a noticeable ring only during keyboard navigation */
:focus-visible {
  outline: 3px solid #1a73e8;   /* 4.5:1 against a white background */
  outline-offset: 2px;
}

/* shadow for the design + transparent outline for forced colours mode */
.btn:focus-visible {
  box-shadow: 0 0 0 4px #1a73e8;
  outline: 2px solid transparent;
}

/* 2.4.11: an element under a 72px sticky header is not hidden */
html { scroll-padding-top: 80px; }
🔧 Розбір прикладу: Прозорий outline здається зайвим, але в режимі forced colors браузер замінює його колір на системний, і кільце з'являється, а box-shadow там просто прибирається. scroll-padding-top змушує браузер прокручувати сфокусований елемент так, щоб над ним лишалось місце для шапки.
🔧 Example breakdown: The transparent outline looks pointless, but in forced colours mode the browser swaps its colour for a system colour and the ring appears, while box-shadow is simply removed. scroll-padding-top makes the browser scroll a focused element so there is room left above it for the header.
💡 Простими словами: Фокус — це курсор клавіатури: якщо його не видно, людина працює наосліп.
💡 In plain words: Focus is the keyboard's pointer: if you cannot see it, the person is working blind.
🗣 Як читати вголос Видимий фокус — це курсор для людини з клавіатурою, без нього вона не знає, що натисне. Найчастіше його ламає outline none у глобальних стилях. Я пропоную focus-visible: кільце видно при Tab і не видно при кліку, і дизайн не страждає. У WCAG 2.2 з'явився ще 2.4.11: фокус не можна повністю ховати під липкою шапкою чи банером, тому я окремо проходжу Tab-ом з відкритим cookie-банером.
🗣 How to say it out loud Visible focus is the pointer for a keyboard user; without it they do not know what they are about to press. It is most often broken by outline none in global styles. I suggest focus-visible: the ring shows on Tab and not on click, and the design does not suffer. WCAG 2.2 also added 2.4.11: focus must not be fully hidden under a sticky header or a banner, so I do a separate Tab pass with the cookie banner open.
23 Як формується порядок фокусу і що роблять значення tabindex 0, -1 і додатні? How is focus order determined, and what do tabindex values 0, -1 and positive numbers do?
КлавіатураKeyboard Middle ▶

Порядок за замовчуванням. Фокус іде в порядку DOM серед нативно фокусованих елементів: посилання з href, кнопки, поля, <select>, <summary>, елементи з contenteditable. 2.4.3 Focus Order (A): порядок має зберігати зміст і дію. Проблеми виникають, коли CSS (flex order, grid, position:absolute, row-reverse) переставляє елементи візуально, а DOM лишається старим: фокус стрибає по екрану.

Default order. Focus follows DOM order across natively focusable elements: links with href, buttons, fields, <select>, <summary>, elements with contenteditable. 2.4.3 Focus Order (A): the order must preserve meaning and operability. Problems arise when CSS (flex order, grid, position:absolute, row-reverse) rearranges elements visually while the DOM stays as it was: focus jumps around the screen.

tabindex=0. Додає нефокусований елемент у природний порядок. Потрібен лише для кастомних віджетів з роллю (наприклад, role=slider) або прокручуваних областей, щоб їх можна було прокрутити клавіатурою.

tabindex=0. Adds a non-focusable element to the natural order. Needed only for custom widgets with a role (such as role=slider) or scrollable areas, so they can be scrolled with the keyboard.

tabindex=-1. Елемент не потрапляє в Tab-порядок, але його можна сфокусувати скриптом через focus(). Застосування: заголовок чи контейнер, куди переносимо фокус після переходу в SPA або закриття діалогу; ціль skip link; неактивні вкладки в roving tabindex.

tabindex=-1. The element is left out of the Tab order but can be focused by script with focus(). Uses: a heading or container that receives focus after an SPA navigation or a dialog closes; a skip link target; inactive tabs in a roving tabindex.

Додатні значення (1, 2, 99). Анти-патерн. Такі елементи отримують фокус раніше за всі інші на сторінці, у порядку номерів, а потім іде решта. Один tabindex=5 у шапці ламає порядок всієї сторінки, а при додаванні нових компонентів це неможливо підтримувати. axe має правило tabindex (best-practice), що попереджає про додатні значення.

Positive values (1, 2, 99). An anti-pattern. Such elements get focus before everything else on the page, in number order, and then the rest follows. One tabindex=5 in the header breaks the order of the whole page, and it becomes impossible to maintain as components are added. axe has a tabindex rule (best-practice) that warns about positive values.

Як тестую. Проходжу Tab і порівнюю з візуальним порядком читання; шукаю в DOM tabindex з додатними значеннями; перевіряю, що tabindex=0 стоїть лише там, де є роль і обробка клавіш.

How I test. I Tab through and compare with the visual reading order; I search the DOM for tabindex with positive values; I check that tabindex=0 appears only where there is a role and key handling.

Три значення tabindex і візуальна перестановка
Three tabindex values and a visual reorder
<!-- tabindex="0": кастомний слайдер у природному порядку -->
<div role="slider" tabindex="0" aria-label="Гучність"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"></div>

<!-- tabindex="-1": ціль для focus() після переходу в SPA -->
<h1 tabindex="-1" id="page-title">Кошик</h1>

<!-- ПОГАНО: цей пошук отримає фокус першим на всій сторінці -->
<input type="search" tabindex="3" aria-label="Пошук">

<!-- ПОГАНО: візуально «Скасувати» праворуч, але в DOM перша -->
<div style="display:flex; flex-direction:row-reverse">
  <button>Скасувати</button> <button>Оплатити</button>
</div>
<!-- tabindex="0": a custom slider in the natural order -->
<div role="slider" tabindex="0" aria-label="Volume"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"></div>

<!-- tabindex="-1": target for focus() after an SPA navigation -->
<h1 tabindex="-1" id="page-title">Cart</h1>

<!-- BAD: this search gets focus first on the whole page -->
<input type="search" tabindex="3" aria-label="Search">

<!-- BAD: visually «Cancel» is on the right, but first in the DOM -->
<div style="display:flex; flex-direction:row-reverse">
  <button>Cancel</button> <button>Pay</button>
</div>
🔧 Розбір прикладу: Слайдер з tabindex=0 ще потребує обробки стрілок і оновлення aria-valuenow, інакше це порожня обіцянка. Останній приклад показує типовий баг: Tab спершу йде на праву кнопку, потім на ліву, всупереч напрямку читання.
🔧 Example breakdown: The slider with tabindex=0 still needs arrow-key handling and aria-valuenow updates, otherwise it is an empty promise. The last example shows a typical bug: Tab goes to the right button first and then the left one, against the reading direction.
💡 Простими словами: 0 — «додай у чергу», -1 — «фокус лише скриптом», додатне число — «пролізь без черги», і так робити не можна.
💡 In plain words: 0 means «join the queue», -1 means «focus by script only», a positive number means «jump the queue», and you must not do that.
🗣 Як читати вголос Порядок фокусу за замовчуванням іде за DOM, тому перше, що я шукаю, — місця, де CSS переставив елементи візуально, а DOM ні. tabindex нуль додає елемент у звичайний порядок, це для кастомних віджетів. Мінус один робить елемент фокусованим лише з коду, наприклад заголовок після переходу в SPA. А додатні значення — анти-патерн, бо такий елемент отримує фокус раніше за всю сторінку.
🗣 How to say it out loud Default focus order follows the DOM, so the first thing I look for is places where CSS moved elements visually but the DOM did not change. tabindex zero adds an element to the normal order, which is for custom widgets. Minus one makes an element focusable from code only, like a heading after an SPA navigation. Positive values are an anti-pattern, because that element gets focus before the entire rest of the page.
24 Що таке skip link, навіщо він потрібен і як його протестувати? What is a skip link, why is it needed, and how do you test it?
КлавіатураKeyboard Junior ▶

Що це. Skip link — перше фокусоване посилання на сторінці, на кшталт «Перейти до основного вмісту», яке веде на <main>. Зазвичай воно приховане і з'являється при фокусі з клавіатури.

What it is. A skip link is the first focusable link on a page, something like «Skip to main content», that points to <main>. It is usually hidden and appears on keyboard focus.

Навіщо. Без нього людина з клавіатурою на кожній сторінці проходить Tab-ом логотип, меню з 30 пунктів, пошук, кошик — десятки натискань до першого товару. Для людини з моторними обмеженнями кожне натискання — зусилля. Критерій 2.4.1 Bypass Blocks (A) вимагає механізм пропуску повторюваних блоків. Правильні landmark-и і заголовки теж вважаються таким механізмом для скрінрідерів, але зрячий користувач клавіатури ними скористатися не може — тому skip link лишається найкращою практикою.

Why. Without it a keyboard user on every page tabs through the logo, a 30-item menu, search and cart — dozens of presses before the first product. For someone with a motor impairment every keystroke takes effort. 2.4.1 Bypass Blocks (A) requires a way to skip repeated blocks. Proper landmarks and headings also count as such a mechanism for screen reader users, but a sighted keyboard user cannot use them — which is why a skip link remains best practice.

Як тестую. 1) Відкриваю сторінку, натискаю Tab один раз — посилання з'являється і його видно (не за межами екрана, не під шапкою). 2) Enter — сторінка прокручується до вмісту. 3) Ще раз Tab — фокус на першому елементі всередині <main>, а не знову в меню. Саме третій крок найчастіше падає: вміст прокрутився, а фокус лишився нагорі.

How I test. 1) Open the page, press Tab once — the link appears and is visible (not off-screen, not under the header). 2) Enter — the page scrolls to the content. 3) Tab again — focus is on the first element inside <main>, not back in the menu. The third step is the one that fails most often: the content scrolled but focus stayed at the top.

Типові баги. Посилання назавжди приховане через display:none — тоді воно недосяжне взагалі. Якір веде на id, якого немає. Посилання стоїть не першим, а після cookie-банера. Текст англійською на українській сторінці.

Typical bugs. The link is permanently hidden with display:none, so it can never be reached. The anchor points to an id that does not exist. The link is not first but comes after the cookie banner. The text is in English on a Ukrainian page.

Розмітка, стиль і тест skip link
Skip link markup, style and test
<body>
  <a class="skip-link" href="#main">Перейти до основного вмісту</a>
  <header>...меню з 30 посилань...</header>
  <main id="main" tabindex="-1">...</main>
</body>
<style>
  .skip-link { position: absolute; left: 8px; top: -100px; }
  .skip-link:focus { top: 8px; }   /* з'являється при фокусі */
</style>
<body>
  <a class="skip-link" href="#main">Skip to main content</a>
  <header>...a menu of 30 links...</header>
  <main id="main" tabindex="-1">...</main>
</body>
<style>
  .skip-link { position: absolute; left: 8px; top: -100px; }
  .skip-link:focus { top: 8px; }   /* appears on focus */
</style>
test('skip link веде фокус у main (WCAG 2.4.1)', async ({ page }) => {
  await page.goto('/catalog');
  await page.keyboard.press('Tab');
  const skip = page.getByRole('link', { name: 'Перейти до основного вмісту' });
  await expect(skip).toBeFocused();
  await expect(skip).toBeInViewport();
  await page.keyboard.press('Enter');
  await page.keyboard.press('Tab');
  // наступний фокус уже всередині main, а не в меню
  expect(await page.evaluate(() => document.activeElement.closest('main') !== null)).toBe(true);
});
test('skip link moves focus into main (WCAG 2.4.1)', async ({ page }) => {
  await page.goto('/catalog');
  await page.keyboard.press('Tab');
  const skip = page.getByRole('link', { name: 'Skip to main content' });
  await expect(skip).toBeFocused();
  await expect(skip).toBeInViewport();
  await page.keyboard.press('Enter');
  await page.keyboard.press('Tab');
  // the next focus is already inside main, not in the menu
  expect(await page.evaluate(() => document.activeElement.closest('main') !== null)).toBe(true);
});
🔧 Розбір прикладу: tabindex=-1 на <main> гарантує, що фокус справді переїде в ціль у всіх браузерах, а не лише прокрутка. toBeInViewport ловить посилання, яке отримало фокус, але лишилося за межами екрана. Останній рядок перевіряє саме той третій крок, який найчастіше ламається.
🔧 Example breakdown: tabindex=-1 on <main> makes sure focus really moves to the target in every browser, not just the scroll position. toBeInViewport catches a link that got focus but stayed off-screen. The last line checks exactly the third step that breaks most often.
💡 Простими словами: Skip link — це «ліфт» повз меню прямо до вмісту для тих, хто ходить Tab-ом.
💡 In plain words: A skip link is a lift past the menu straight to the content for people who move with Tab.
🗣 Як читати вголос Skip link — це перше посилання на сторінці, яке дозволяє людині з клавіатурою одним натисканням пропустити шапку і меню. Це закриває критерій 2.4.1 Bypass Blocks. Я перевіряю три кроки: перший Tab показує посилання, Enter переносить до вмісту, а наступний Tab потрапляє всередину main, а не назад у меню. Саме останній крок найчастіше зламаний, бо прокрутка є, а фокус лишився нагорі.
🗣 How to say it out loud A skip link is the first link on a page that lets a keyboard user skip the header and menu in one press. It covers criterion 2.4.1 Bypass Blocks. I check three steps: the first Tab reveals the link, Enter takes you to the content, and the next Tab lands inside main rather than back in the menu. That last step is the one most often broken: the page scrolled, but focus stayed at the top.
25 Що таке keyboard trap і чим він відрізняється від навмисного утримання фокусу в модальному вікні? What is a keyboard trap, and how is it different from intentionally containing focus in a modal?
КлавіатураKeyboard Middle ▶

Що це. Keyboard trap — стан, коли фокус потрапив у компонент і вийти з нього лише клавіатурою неможливо: Tab і Shift+Tab ходять по колу, Esc нічого не робить. Людина без миші змушена перезавантажувати сторінку. 2.1.2 No Keyboard Trap (A) — один із чотирьох критеріїв, які WCAG вважає «невтручальними»: їх провал ламає всю сторінку, навіть якщо проблемна частина не потрібна для відповідності.

What it is. A keyboard trap is when focus has entered a component and there is no way out with the keyboard alone: Tab and Shift+Tab cycle, Esc does nothing. A person without a mouse has to reload the page. 2.1.2 No Keyboard Trap (A) is one of the four criteria WCAG calls «non-interference»: failing them breaks the whole page, even if the faulty part is not relied on for conformance.

Де трапляється. Вбудовані iframe (карти, відеоплеєри, платіжні форми третіх сторін, капча), текстові редактори, де Tab вставляє символ табуляції, календарі й кастомні дропдауни зі скриптом «повернути фокус у поле», Flash-подібні вбудовування, скрипти, що роблять focus() на полі при втраті фокусу.

Where it happens. Embedded iframes (maps, video players, third-party payment forms, captchas), text editors where Tab inserts a tab character, date pickers and custom dropdowns with a «put focus back into the field» script, plugin-style embeds, scripts that call focus() on a field when it loses focus.

Навмисне утримання. Модальне вікно навмисно тримає фокус у собі: поки воно відкрите, фон неактивний, і Tab ходить по колу всередині — це правильна поведінка з APG. Різниця в наявності виходу: Esc закриває діалог, є кнопка «Закрити» чи «Скасувати», і після закриття фокус повертається на кнопку, що відкрила діалог. Редактор коду теж може перехоплювати Tab, якщо користувачу повідомлено нестандартний спосіб виходу (наприклад, Esc, потім Tab), — WCAG це дозволяє.

Intentional containment. A modal dialog deliberately keeps focus inside: while it is open, the background is inactive and Tab cycles within — that is the correct APG behaviour. The difference is the exit: Esc closes the dialog, there is a Close or Cancel button, and after closing focus returns to the button that opened it. A code editor may also capture Tab if the user is told the non-standard way out (for example, Esc then Tab) — WCAG allows that.

Як тестую. Заходжу Tab-ом у кожен вбудований компонент і намагаюся вийти вперед і назад. Для діалогу — перевіряю, що вихід існує і фокус повертається.

How I test. I Tab into every embedded component and try to leave forwards and backwards. For a dialog I check that an exit exists and focus comes back.

Перевірка «з компонента можна вийти вперед і назад»
Check «you can leave the component forwards and backwards»
test('віджет карти не утримує фокус (WCAG 2.1.2)', async ({ page }) => {
  await page.goto('/stores');
  const map = page.locator('#store-map');
  await map.locator('button').first().focus();      // зайшли всередину

  let left = false;
  for (let i = 0; i < 40 && !left; i++) {            // вперед: до 40 Tab
    await page.keyboard.press('Tab');
    left = await map.evaluate((m) => !m.contains(document.activeElement));
  }
  expect(left, 'Tab не виводить фокус з карти').toBe(true);

  await map.locator('button').first().focus();
  await page.keyboard.press('Shift+Tab');            // назад
  expect(await map.evaluate((m) => !m.contains(document.activeElement))).toBe(true);
});
test('map widget does not trap focus (WCAG 2.1.2)', async ({ page }) => {
  await page.goto('/stores');
  const map = page.locator('#store-map');
  await map.locator('button').first().focus();      // we are inside

  let left = false;
  for (let i = 0; i < 40 && !left; i++) {            // forwards: up to 40 Tabs
    await page.keyboard.press('Tab');
    left = await map.evaluate((m) => !m.contains(document.activeElement));
  }
  expect(left, 'Tab does not move focus out of the map').toBe(true);

  await map.locator('button').first().focus();
  await page.keyboard.press('Shift+Tab');            // backwards
  expect(await map.evaluate((m) => !m.contains(document.activeElement))).toBe(true);
});
🔧 Розбір прикладу: Ліміт у 40 натискань відрізняє пастку від просто довгого компонента: якщо за 40 Tab фокус так і не вийшов, це майже напевно цикл. Для cross-origin iframe contains не бачить внутрішніх елементів, тому там перевіряю, що фокус повернувся в основний документ, або роблю це вручну.
🔧 Example breakdown: The 40-press limit separates a trap from a merely long component: if focus has not left after 40 Tabs, it is almost certainly a loop. For a cross-origin iframe contains cannot see inner elements, so there I check that focus returned to the main document, or do it by hand.
💡 Простими словами: Пастка — коли увійти можна, а вийти ні; модалка тримає фокус, але завжди відпускає через Esc чи «Закрити».
💡 In plain words: A trap is when you can get in but not out; a modal holds focus but always lets go via Esc or Close.
🗣 Як читати вголос Keyboard trap — це коли фокус зайшов у компонент і клавіатурою з нього не вийти, людині доводиться перезавантажувати сторінку. Типові місця — iframe з картами чи платежами, редактори і календарі. Модалка теж тримає фокус, але навмисно і з виходом: Esc чи кнопка «Закрити», і фокус повертається на кнопку, що її відкрила. Тому я для кожного вбудованого віджета пробую вийти Tab-ом вперед і Shift+Tab назад.
🗣 How to say it out loud A keyboard trap is when focus enters a component and you cannot leave it with the keyboard, so the person has to reload the page. Typical places are iframes with maps or payments, editors and date pickers. A modal holds focus too, but on purpose and with an exit: Esc or a Close button, and focus goes back to the button that opened it. So for every embedded widget I try to leave with Tab forwards and Shift+Tab backwards.
26 Які вимоги доступності до модального вікна і як протестувати керування фокусом у ньому? What are the accessibility requirements for a modal dialog, and how do you test its focus management?
КлавіатураKeyboard Middle ▶

Вимоги. 1) Роль і ім'я: role=dialog (або alertdialog для критичних підтверджень), aria-modal=true, ім'я через aria-labelledby на заголовок. 2) При відкритті фокус переходить у діалог: на перший інтерактивний елемент, на заголовок або на найменш руйнівну дію («Скасувати», а не «Видалити»). 3) Tab і Shift+Tab циклічно ходять лише всередині. 4) Esc закриває. 5) Фон неактивний: не фокусується, не читається скрінрідером, не прокручується. 6) Після закриття фокус повертається на елемент, що відкрив діалог, або в логічне місце, якщо той зник.

Requirements. 1) Role and name: role=dialog (or alertdialog for critical confirmations), aria-modal=true, a name via aria-labelledby pointing to the heading. 2) On open, focus moves into the dialog: to the first interactive element, the heading, or the least destructive action («Cancel», not «Delete»). 3) Tab and Shift+Tab cycle inside only. 4) Esc closes it. 5) The background is inert: not focusable, not read by the screen reader, not scrollable. 6) On close, focus returns to the element that opened the dialog, or somewhere logical if that element is gone.

Нативний <dialog>. Метод showModal() дає більшість цього без коду: верхній шар, неактивний фон, Esc, роль dialog. Сучасні браузери також повертають фокус на попередній елемент після закриття, але це варто перевірити тестом. Метод show() відкриває немодальний діалог — без цих гарантій.

Native <dialog>. The showModal() method gives most of this without code: the top layer, an inert background, Esc, the dialog role. Modern browsers also return focus to the previous element on close, but it is worth a test. The show() method opens a non-modal dialog without these guarantees.

Типові баги. Фокус лишається на кнопці під затемненням, і Tab ходить по фону. Діалог без імені — скрінрідер каже просто «діалог». Після закриття фокус падає на <body>, і людина починає зверху сторінки. Хрестик без імені. Діалог, що відкривається при завантаженні, без жодного оголошення.

Typical bugs. Focus stays on the button under the overlay, and Tab walks the background. A dialog with no name — the screen reader just says «dialog». After closing, focus drops to <body> and the person starts from the top of the page. A close cross with no name. A dialog that opens on page load with no announcement at all.

Як тестую. Клавіатурою: відкрити Enter-ом, перевірити, де фокус, пройти коло Tab-ом, закрити Esc, перевірити повернення фокусу. NVDA: при відкритті звучить ім'я діалогу. Та сама послідовність легко автоматизується.

How I test. With the keyboard: open with Enter, check where focus is, Tab around the loop, close with Esc, check focus comes back. With NVDA: the dialog name is announced on open. The same sequence is easy to automate.

Нативний діалог і тест керування фокусом
Native dialog and a focus management test
<button id="delete-btn" type="button">Видалити акаунт</button>

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Видалити акаунт назавжди?</h2>
  <p>Дані неможливо буде відновити.</p>
  <button type="button" autofocus>Скасувати</button>
  <button type="button">Видалити</button>
</dialog>
<!-- JS: button.onclick = () => dialog.showModal(); -->
<button id="delete-btn" type="button">Delete account</button>

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete your account permanently?</h2>
  <p>Your data cannot be restored.</p>
  <button type="button" autofocus>Cancel</button>
  <button type="button">Delete</button>
</dialog>
<!-- JS: button.onclick = () => dialog.showModal(); -->
test('діалог підтвердження: фокус, цикл, Esc, повернення', async ({ page }) => {
  await page.goto('/settings');
  const opener = page.getByRole('button', { name: 'Видалити акаунт' });
  await opener.focus();
  await page.keyboard.press('Enter');

  const dialog = page.getByRole('dialog', { name: 'Видалити акаунт назавжди?' });
  await expect(dialog).toBeVisible();
  await expect(dialog.getByRole('button', { name: 'Скасувати' })).toBeFocused();

  for (let i = 0; i < 3; i++) {                 // цикл лишається всередині
    await page.keyboard.press('Tab');
    expect(await dialog.evaluate((d) => d.contains(document.activeElement))).toBe(true);
  }

  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(opener).toBeFocused();           // фокус повернувся
});
test('confirm dialog: focus, loop, Esc, return', async ({ page }) => {
  await page.goto('/settings');
  const opener = page.getByRole('button', { name: 'Delete account' });
  await opener.focus();
  await page.keyboard.press('Enter');

  const dialog = page.getByRole('dialog', { name: 'Delete your account permanently?' });
  await expect(dialog).toBeVisible();
  await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();

  for (let i = 0; i < 3; i++) {                 // the loop stays inside
    await page.keyboard.press('Tab');
    expect(await dialog.evaluate((d) => d.contains(document.activeElement))).toBe(true);
  }

  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(opener).toBeFocused();           // focus came back
});
🔧 Розбір прикладу: autofocus у діалозі визначає, куди потрапить фокус при showModal(), — тут на безпечну «Скасувати». Тест знаходить діалог за роллю та ім'ям, тож заодно перевіряє aria-labelledby. Три Tab-и з перевіркою contains доводять, що фокус не тікає на фон.
🔧 Example breakdown: autofocus inside the dialog decides where focus lands on showModal() — here, on the safe «Cancel». The test finds the dialog by role and name, so it also checks aria-labelledby. Three Tabs with a contains check prove focus does not escape to the background.
💡 Простими словами: Модалка: зайшов фокусом усередину, кружляєш усередині, вийшов через Esc — і опинився там, звідки прийшов.
💡 In plain words: A modal: focus goes in, circles inside, leaves via Esc — and ends up where it came from.
🗣 Як читати вголос Для модалки я перевіряю шість речей: роль dialog з ім'ям, фокус переходить усередину при відкритті, Tab ходить по колу лише всередині, Esc закриває, фон неактивний і не читається, а після закриття фокус повертається на кнопку, що відкрила вікно. Нативний dialog з showModal дає більшість цього безкоштовно. Найчастіший баг, який я бачу, — фокус після закриття падає на body.
🗣 How to say it out loud For a modal I check six things: the dialog role with a name, focus moves inside on open, Tab cycles only inside, Esc closes it, the background is inert and not read, and after closing focus returns to the button that opened it. A native dialog with showModal gives most of that for free. The most common bug I see is focus dropping to the body after closing.
27 Як зробити drag-and-drop доступним і чому клавіатурної альтернативи недостатньо для критерію 2.5.7? How do you make drag and drop accessible, and why is a keyboard alternative not enough for criterion 2.5.7?
КлавіатураKeyboard Senior ▶

Проблема. Перетягування вимагає точного утримання вказівника і руху — це важко людям з тремором, з церебральним паралічем, користувачам головної миші, трекболу чи eye tracking. Незрячі не бачать, куди перетягувати.

The problem. Dragging requires holding the pointer precisely while moving — hard for people with tremors, cerebral palsy, head mouse, trackball or eye-tracking users. Blind users cannot see where to drop.

Два різні критерії. 2.1.1 Keyboard (A) вимагає, щоб усе робилося з клавіатури: наприклад, Space «бере» картку, стрілки переміщують, Space «кладе», Esc скасовує, а live-регіон оголошує «Картку переміщено на позицію 3 з 5». 2.5.7 Dragging Movements (AA, новий у 2.2) вимагає альтернативу для одного вказівника без перетягування: кнопки «вгору/вниз», меню «Перемістити в…», тап по картці, потім тап по колонці. Клавіатурне рішення не закриває 2.5.7: людина з головною мишею чи тачскріном клавіатурою не користується. Потрібні обидва.

Two different criteria. 2.1.1 Keyboard (A) requires everything to work from the keyboard: for example, Space «picks up» a card, arrows move it, Space «drops» it, Esc cancels, and a live region announces «Card moved to position 3 of 5». 2.5.7 Dragging Movements (AA, new in 2.2) requires a single-pointer alternative without dragging: up/down buttons, a «Move to…» menu, tap the card then tap the column. A keyboard solution does not satisfy 2.5.7: a person with a head mouse or a touch screen is not using a keyboard. Both are needed.

Трейд-офи. Кнопки «вгору/вниз» на кожній картці — найпростіше і найнадійніше, але візуально шумно; можна показувати їх у меню «…». Бібліотеки (наприклад, dnd-kit) дають клавіатурний сенсор і оголошення, але альтернативу для вказівника доведеться зробити самостійно. Вивантаження файлів: зона перетягування завжди дублюється кнопкою «Обрати файл».

Trade-offs. Up/down buttons on each card are the simplest and most robust, but visually noisy; they can live in a «…» menu. Libraries (such as dnd-kit) provide a keyboard sensor and announcements, but the pointer alternative is on you. File upload: a drop zone is always paired with a «Choose file» button.

Як тестую. Клавіатурою переміщую елемент і слухаю оголошення в NVDA. Мишею та на телефоні виконую ту саму дію без перетягування. Перевіряю, що порядок у DOM і в API змінився, а не лише візуально. Автотест будую на альтернативі, а не на dragTo.

How I test. With the keyboard I move an item and listen to the NVDA announcements. With a mouse and on a phone I do the same action without dragging. I check the order changed in the DOM and in the API, not just visually. The automated test drives the alternative, not dragTo.

Сортований список: три способи перемістити елемент
Sortable list: three ways to move an item
<ol id="priorities">
  <li>
    <span id="t1">Оновити тарифи</span>
    <!-- 1. перетягування мишею: ручка для drag -->
    <button type="button" aria-describedby="t1" aria-roledescription="ручка перетягування">⠿</button>
    <!-- 2. один вказівник без drag (2.5.7) і клавіатура (2.1.1) -->
    <button type="button" aria-label="Вище: Оновити тарифи">↑</button>
    <button type="button" aria-label="Нижче: Оновити тарифи">↓</button>
  </li>
</ol>
<!-- 3. оголошення результату будь-якого способу -->
<div role="status" id="dnd-live"></div>
<ol id="priorities">
  <li>
    <span id="t1">Update pricing</span>
    <!-- 1. mouse dragging: a drag handle -->
    <button type="button" aria-describedby="t1" aria-roledescription="drag handle">⠿</button>
    <!-- 2. single pointer without drag (2.5.7) and keyboard (2.1.1) -->
    <button type="button" aria-label="Move up: Update pricing">↑</button>
    <button type="button" aria-label="Move down: Update pricing">↓</button>
  </li>
</ol>
<!-- 3. announcing the result of any method -->
<div role="status" id="dnd-live"></div>
test('пріоритет можна перемістити без перетягування (WCAG 2.5.7)', async ({ page }) => {
  await page.goto('/board');
  await page.getByRole('button', { name: 'Нижче: Оновити тарифи' }).click();
  await expect(page.locator('#priorities li').nth(1)).toContainText('Оновити тарифи');
  await expect(page.getByRole('status')).toHaveText('Оновити тарифи: позиція 2 з 4');
});
test('a priority can be moved without dragging (WCAG 2.5.7)', async ({ page }) => {
  await page.goto('/board');
  await page.getByRole('button', { name: 'Move down: Update pricing' }).click();
  await expect(page.locator('#priorities li').nth(1)).toContainText('Update pricing');
  await expect(page.getByRole('status')).toHaveText('Update pricing: position 2 of 4');
});
🔧 Розбір прикладу: Кнопки «вище/нижче» одночасно закривають 2.5.7 (один клік вказівником) і 2.1.1 (Enter з клавіатури). Імена містять назву елемента, тож серед десяти однакових стрілок кожна унікальна. role=status повідомляє скрінрідеру результат незалежно від способу переміщення.
🔧 Example breakdown: The up/down buttons cover both 2.5.7 (one pointer click) and 2.1.1 (Enter from the keyboard). Their names include the item name, so each of ten identical arrows is unique. role=status tells the screen reader the result whichever method was used.
💡 Простими словами: Перетягування — лише один із шляхів; поруч завжди має бути звичайна кнопка.
💡 In plain words: Dragging is only one of the paths; there should always be a plain button next to it.
🗣 Як читати вголос Для drag-and-drop я розділяю два критерії. 2.1.1 вимагає клавіатурного способу, наприклад, взяти Space-ом, пересунути стрілками і оголосити результат. А 2.5.7 з WCAG 2.2 вимагає альтернативу для вказівника без перетягування, бо людина з головною мишею чи на тачскріні клавіатурою не користується. Найпростіше рішення, яке я пропоную команді, — кнопки вгору і вниз або меню «Перемістити в», і автотест будую саме на них.
🗣 How to say it out loud For drag and drop I separate two criteria. 2.1.1 requires a keyboard route, say pick up with Space, move with arrows and announce the result. And 2.5.7 from WCAG 2.2 requires a pointer alternative without dragging, because a person with a head mouse or on a touch screen is not using a keyboard. The simplest fix I suggest to the team is up and down buttons or a «Move to» menu, and I build the automated test on those.
▶ 5 6
28 Як правильно пов'язати label з полем і чому текст-підказка всередині поля не замінює label? How do you correctly link a label to a field, and why can't hint text inside the field replace a label?
Форми і динамікаForms and dynamics Junior ▶

Зв'язок. Нативний спосіб — <label for=id>, де for збігається з id поля, або поле вкладене всередину <label>. Тоді поле отримує accessible name (скрінрідер читає «Email, поле редагування»), а клік по тексту label ставить фокус у поле чи перемикає чекбокс — більша зона кліку допомагає людям з тремором. Для групи радіокнопок чи чекбоксів — <fieldset> з <legend>: скрінрідер скаже «Спосіб доставки, група, Кур'єр, радіокнопка, 1 з 3».

Linking. The native way is <label for=id>, with for matching the field's id, or the field nested inside the <label>. The field then gets an accessible name (the screen reader says «Email, edit text»), and clicking the label text focuses the field or toggles the checkbox — a bigger click area helps people with tremors. For a group of radios or checkboxes use <fieldset> with <legend>: the screen reader says «Delivery method, grouping, Courier, radio button, 1 of 3».

Чому підказка всередині поля — не label. Атрибут, що показує сірий текст-підказку в порожньому полі, зникає, щойно людина почала друкувати, — і вона вже не пам'ятає, що це було за поле. Це навантаження на пам'ять для людей з когнітивними труднощами. Такий текст зазвичай низькоконтрастний (провал 1.4.3). Браузер використовує його як ім'я лише в крайньому випадку, а автоперекладачі і старі допоміжні технології його часто ігнорують. Візуально поле з підказкою схоже на вже заповнене. Правильно — видимий label над полем, а приклад формату — окремим текстом, пов'язаним через aria-describedby.

Why hint text inside the field is not a label. The attribute that shows grey hint text in an empty field disappears as soon as the person starts typing — and they no longer remember what the field was. That is a memory load for people with cognitive difficulties. This text is usually low contrast (a 1.4.3 failure). The browser uses it as the name only as a last resort, and automatic translators and older assistive technology often ignore it. Visually, a field with hint text looks already filled in. The right way is a visible label above the field, with a format example as separate text linked via aria-describedby.

Типові баги. for посилається на неіснуючий чи дубльований id (часто в компонентах, відрендерених двічі). Текст «Email» поруч із полем, але не пов'язаний. aria-label замість видимого label. Label «Поле 1».

Typical bugs. for pointing to a missing or duplicated id (often in components rendered twice). The word «Email» next to the field but not linked. An aria-label instead of a visible label. A label saying «Field 1».

Перевірка. axe label; вручну — клік по тексту label і прослуховування в NVDA; у тестах — getByLabel.

Checking. axe label; by hand, click the label text and listen in NVDA; in tests, getByLabel.

Погано: текст поруч без зв'язку. Добре: label, fieldset, підказка
Bad: nearby text with no link. Good: label, fieldset, hint
<!-- ПОГАНО: «Телефон» лише візуально поруч, у поля немає імені -->
<div>Телефон</div>
<input type="tel">

<!-- ДОБРЕ: видимий label, формат окремо через aria-describedby -->
<label for="phone">Телефон</label>
<input id="phone" type="tel" autocomplete="tel" aria-describedby="phone-hint">
<p id="phone-hint">Формат: +380 67 123 45 67</p>

<!-- ДОБРЕ: група радіокнопок з назвою -->
<fieldset>
  <legend>Спосіб доставки</legend>
  <label><input type="radio" name="ship" value="courier"> Кур'єр</label>
  <label><input type="radio" name="ship" value="post"> Нова пошта</label>
</fieldset>
<!-- BAD: «Phone» is only visually nearby, the field has no name -->
<div>Phone</div>
<input type="tel">

<!-- GOOD: visible label, format separately via aria-describedby -->
<label for="phone">Phone</label>
<input id="phone" type="tel" autocomplete="tel" aria-describedby="phone-hint">
<p id="phone-hint">Format: +1 555 010 0199</p>

<!-- GOOD: a named group of radio buttons -->
<fieldset>
  <legend>Delivery method</legend>
  <label><input type="radio" name="ship" value="courier"> Courier</label>
  <label><input type="radio" name="ship" value="post"> Post office</label>
</fieldset>
🔧 Розбір прикладу: У першому варіанті getByLabel('Телефон') нічого не знайде, і axe покаже порушення label. У другому ім'я — «Телефон», опис — формат номера. Вкладений <input> у <label> у fieldset працює без for і id.
🔧 Example breakdown: In the first version getByLabel('Phone') finds nothing and axe reports a label violation. In the second the name is «Phone» and the description is the number format. An <input> nested inside a <label> in the fieldset works without for and id.
💡 Простими словами: Label має бути видимим, постійним і пов'язаним з полем; підказка всередині поля зникає разом з першою літерою.
💡 In plain words: A label must be visible, persistent and linked to the field; hint text inside the field vanishes with the first letter typed.
🗣 Як читати вголос Поле має отримати ім'я через label з for, що збігається з id, або через вкладення. Тоді скрінрідер читає назву поля, а клік по тексту ставить фокус. Для груп радіокнопок — fieldset і legend. Сірий текст-підказка всередині поля label не замінює: він зникає при введенні, зазвичай має слабкий контраст і погано підтримується. Я перевіряю це кліком по label і локаторами getByLabel у тестах.
🗣 How to say it out loud A field should get its name from a label whose for matches the id, or by nesting. Then the screen reader reads the field name, and clicking the text focuses the field. For radio groups, fieldset and legend. Grey hint text inside the field does not replace a label: it disappears as you type, usually has weak contrast and is poorly supported. I check it by clicking the label and with getByLabel locators in tests.
29 Як позначати обов'язкові поля і навіщо атрибут autocomplete з погляду WCAG 1.3.5? How should required fields be marked, and why does the autocomplete attribute matter under WCAG 1.3.5?
Форми і динамікаForms and dynamics Middle ▶

Обов'язкові поля. Позначка має бути і візуальною, і програмною. Візуально: зірочка з поясненням на початку форми («Поля з * обов'язкові») або слово «(обов'язково)» в label. Лише червоний колір не підходить (1.4.1). Програмно: атрибут required — скрінрідер скаже «обов'язкове»; для кастомних контролів — aria-required=true. Зірочку часто ховають від скрінрідера (aria-hidden), якщо стан уже оголошується, щоб не чути «зірочка, обов'язкове». Якщо обов'язкові майже всі поля, зручніше позначати необов'язкові.

Required fields. The marking has to be both visual and programmatic. Visually: an asterisk explained at the start of the form («Fields marked * are required») or the word «(required)» in the label. Red colour alone is not enough (1.4.1). Programmatically: the required attribute — the screen reader says «required»; for custom controls, aria-required=true. The asterisk is often hidden from the screen reader (aria-hidden) when the state is already announced, to avoid «star, required». If almost every field is required, it is easier to mark the optional ones.

Autocomplete і 1.3.5 Identify Input Purpose (AA). Поля, що збирають дані про самого користувача, повинні мати значення autocomplete зі списку HTML: name, email, tel, street-address, postal-code, bday, cc-number, username, new-password тощо. Навіщо: браузер заповнить поля сам — менше набору для людей з моторними обмеженнями і менше навантаження на пам'ять для людей з когнітивними; також розширення можуть показати біля поля знайому іконку.

Autocomplete and 1.3.5 Identify Input Purpose (AA). Fields that collect information about the user must carry an autocomplete value from the HTML list: name, email, tel, street-address, postal-code, bday, cc-number, username, new-password and so on. Why: the browser fills them in — less typing for people with motor impairments and less memory load for people with cognitive ones; extensions can also show a familiar icon next to the field.

Типові баги. autocomplete=off на всій формі «з безпеки». Вигадані значення (autocomplete=phone замість tel) — axe ловить правилом autocomplete-valid. Поле дати народження, розбите на три select без autocomplete. Нативна валідація required з англомовним спливаючим повідомленням на українській сторінці.

Typical bugs. autocomplete=off on the whole form «for security». Made-up values (autocomplete=phone instead of tel) — axe catches them with autocomplete-valid. A date of birth split into three selects without autocomplete. Native required validation with an English popup on a Ukrainian page.

Нюанс. 1.3.5 стосується даних про користувача, а не всіх полів: поле «Ім'я одержувача подарунка» не зобов'язане мати autocomplete=name.

Nuance. 1.3.5 covers data about the user, not every field: a «Gift recipient name» field does not have to use autocomplete=name.

Форма доставки з required і autocomplete + перевірка
Delivery form with required and autocomplete + a check
<p>Поля, позначені <span aria-hidden="true">*</span> зірочкою, обов'язкові.</p>

<label for="fullname">Ім'я та прізвище <span aria-hidden="true">*</span></label>
<input id="fullname" required autocomplete="name">

<label for="email">Email <span aria-hidden="true">*</span></label>
<input id="email" type="email" required autocomplete="email">

<label for="zip">Поштовий індекс (необов'язково)</label>
<input id="zip" autocomplete="postal-code" inputmode="numeric">
<p>Fields marked with an <span aria-hidden="true">*</span> asterisk are required.</p>

<label for="fullname">Full name <span aria-hidden="true">*</span></label>
<input id="fullname" required autocomplete="name">

<label for="email">Email <span aria-hidden="true">*</span></label>
<input id="email" type="email" required autocomplete="email">

<label for="zip">Postcode (optional)</label>
<input id="zip" autocomplete="postal-code" inputmode="numeric">
const expected = { 'Ім\'я та прізвище': 'name', 'Email': 'email', 'Поштовий індекс': 'postal-code' };
for (const [label, token] of Object.entries(expected)) {
  await expect(page.getByLabel(label)).toHaveAttribute('autocomplete', token);
}
await expect(page.getByLabel('Email')).toHaveJSProperty('required', true);
const expected = { 'Full name': 'name', 'Email': 'email', 'Postcode': 'postal-code' };
for (const [label, token] of Object.entries(expected)) {
  await expect(page.getByLabel(label)).toHaveAttribute('autocomplete', token);
}
await expect(page.getByLabel('Email')).toHaveJSProperty('required', true);
🔧 Розбір прикладу: Зірочка прихована від скрінрідера, бо атрибут required вже дає оголошення «обов'язкове». getByLabel шукає за частковим збігом, тож «Поштовий індекс» знайде і label з приміткою «(необов'язково)». Мапа label → токен легко розширюється на всю форму чекауту.
🔧 Example breakdown: The asterisk is hidden from the screen reader because the required attribute already produces the «required» announcement. getByLabel matches partially, so «Postcode» also finds the label with the «(optional)» note. The label → token map is easy to extend to the whole checkout form.
💡 Простими словами: Обов'язковість треба і показати, і оголосити; autocomplete дозволяє браузеру заповнити форму за людину.
💡 In plain words: Required status must be both shown and announced; autocomplete lets the browser fill the form for the person.
🗣 Як читати вголос Обов'язкове поле я очікую позначеним двічі: візуально, зірочкою з поясненням чи словом, і програмно, атрибутом required, щоб скрінрідер сказав «обов'язкове». Лише колір не рахується. autocomplete — це критерій 1.3.5: поля з даними про користувача мають стандартні токени на кшталт email, tel, postal-code, тоді браузер заповнює їх сам. Невалідні значення ловить axe, а правильність токенів я фіксую в тесті.
🗣 How to say it out loud I expect a required field to be marked twice: visually, with an explained asterisk or a word, and programmatically, with the required attribute so the screen reader says «required». Colour alone does not count. autocomplete is criterion 1.3.5: fields with user data carry standard tokens such as email, tel, postal-code, and then the browser fills them in. axe catches invalid values, and I pin the correct tokens in a test.
30 Яким має бути доступне повідомлення про помилку у формі і як його перевірити? What makes a form error message accessible, and how do you check it?
Форми і динамікаForms and dynamics Middle ▶

Критерії. 3.3.1 Error Identification (A): помилку виявлено автоматично — поле позначено, а помилку описано текстом. 3.3.3 Error Suggestion (AA): якщо відомо, як виправити, підкажи («Дата у форматі ДД.ММ.РРРР», а не «Невірне значення»). 3.3.4 Error Prevention (AA) для юридичних і фінансових дій: можливість перевірити, скасувати чи підтвердити перед відправкою. 4.1.3 Status Messages (AA): оголошення без переносу фокусу, де це доречно.

Criteria. 3.3.1 Error Identification (A): an error detected automatically is identified — the field is marked and the error is described in text. 3.3.3 Error Suggestion (AA): if the fix is known, suggest it («Date in DD.MM.YYYY format», not «Invalid value»). 3.3.4 Error Prevention (AA) for legal and financial actions: the ability to review, correct or confirm before submitting. 4.1.3 Status Messages (AA): announcements without moving focus where appropriate.

Робочий патерн. Після відправки з помилками: 1) над формою — зведення помилок із заголовком і посиланнями на кожне поле, фокус переходить на зведення (або воно має role=alert); 2) біля кожного поля — текст помилки, пов'язаний через aria-describedby, і aria-invalid=true на полі; 3) значення, які людина ввела, збережені. Так скрінрідер оголосить проблему одразу, а на полі ще раз прочитає «недійсні дані» разом із текстом.

The working pattern. After a submit with errors: 1) above the form, an error summary with a heading and links to each field, and focus moves to the summary (or it has role=alert); 2) next to each field, the error text linked through aria-describedby, and aria-invalid=true on the field; 3) whatever the person typed is kept. That way the screen reader announces the problem at once and reads «invalid entry» with the text again on the field.

Типові баги. Помилка лише червоною рамкою (1.4.1). Текст помилки з'являється, але ніхто його не оголошує, і незряча людина не розуміє, чому форма «не відправляється». Валідація на кожне натискання клавіші з role=alert — скрінрідер перериває введення щоразу. Фокус після відправки лишається на кнопці. Помилка зникає через 3 секунди. Форма очищається.

Typical bugs. The error is only a red border (1.4.1). The error text appears but nothing announces it, so a blind person does not understand why the form «will not submit». Validation on every keystroke with role=alert — the screen reader interrupts typing every time. Focus stays on the button after submitting. The error disappears after 3 seconds. The form is cleared.

Як тестую. Відправляю порожню і некоректну форму клавіатурою з NVDA: що оголошено, де фокус, чи можна дійти до поля з посилання у зведенні, чи зрозуміло, як виправити.

How I test. I submit an empty and an invalid form with the keyboard and NVDA running: what is announced, where focus is, whether a summary link takes me to the field, whether it is clear how to fix it.

Зведення помилок, помилка біля поля і тест
Error summary, inline error and a test
<div id="error-summary" tabindex="-1" role="alert" aria-labelledby="err-h">
  <h2 id="err-h">Виправте 2 помилки</h2>
  <ul>
    <li><a href="#card">Номер картки має містити 16 цифр</a></li>
    <li><a href="#exp">Вкажіть строк дії у форматі ММ/РР</a></li>
  </ul>
</div>

<label for="card">Номер картки</label>
<input id="card" autocomplete="cc-number" inputmode="numeric"
       aria-invalid="true" aria-describedby="card-err">
<p id="card-err">Помилка: номер картки має містити 16 цифр, введено 15.</p>
<div id="error-summary" tabindex="-1" role="alert" aria-labelledby="err-h">
  <h2 id="err-h">Fix 2 errors</h2>
  <ul>
    <li><a href="#card">Card number must have 16 digits</a></li>
    <li><a href="#exp">Enter the expiry date as MM/YY</a></li>
  </ul>
</div>

<label for="card">Card number</label>
<input id="card" autocomplete="cc-number" inputmode="numeric"
       aria-invalid="true" aria-describedby="card-err">
<p id="card-err">Error: card number must have 16 digits, you entered 15.</p>
test('помилки оплати оголошуються і ведуть до поля (WCAG 3.3.1)', async ({ page }) => {
  await page.goto('/checkout/payment');
  await page.getByLabel('Номер картки').fill('411111111111111');
  await page.getByRole('button', { name: 'Оплатити' }).press('Enter');

  const summary = page.getByRole('alert');
  await expect(summary).toContainText('Виправте 2 помилки');
  await summary.getByRole('link', { name: /16 цифр/ }).press('Enter');

  const card = page.getByLabel('Номер картки');
  await expect(card).toBeFocused();
  await expect(card).toHaveAttribute('aria-invalid', 'true');
  await expect(card).toHaveAccessibleDescription(/введено 15/);
  await expect(card).toHaveValue('411111111111111');   // введене не втрачено
});
test('payment errors are announced and lead to the field (WCAG 3.3.1)', async ({ page }) => {
  await page.goto('/checkout/payment');
  await page.getByLabel('Card number').fill('411111111111111');
  await page.getByRole('button', { name: 'Pay' }).press('Enter');

  const summary = page.getByRole('alert');
  await expect(summary).toContainText('Fix 2 errors');
  await summary.getByRole('link', { name: /16 digits/ }).press('Enter');

  const card = page.getByLabel('Card number');
  await expect(card).toBeFocused();
  await expect(card).toHaveAttribute('aria-invalid', 'true');
  await expect(card).toHaveAccessibleDescription(/entered 15/);
  await expect(card).toHaveValue('411111111111111');   // input is not lost
});
🔧 Розбір прикладу: Слово «Помилка:» на початку тексту допомагає тим, хто не розрізняє червоний колір. Посилання у зведенні ведуть на id полів, і тест перевіряє, що фокус справді опиняється в полі, воно позначене як недійсне, має опис з помилкою і зберегло введене значення.
🔧 Example breakdown: The word «Error:» at the start of the text helps people who cannot tell red apart. The summary links point to field ids, and the test checks that focus really lands in the field, which is marked invalid, carries the error as its description and has kept the typed value.
💡 Простими словами: Помилку треба побачити, почути, зрозуміти, як виправити, і швидко дістатися до поля.
💡 In plain words: An error must be seen, heard, understood as fixable, and the field reached quickly.
🗣 Як читати вголос Доступна помилка — це текст, а не лише червона рамка, і текст пояснює, як виправити. Мій улюблений патерн: зведення помилок над формою з посиланнями на поля, на яке переходить фокус, плюс помилка біля поля через aria-describedby і aria-invalid. Перевіряю зі скрінрідером: чи почув я, що сталося, чи можу дістатися до поля і чи не зникло те, що я ввів. І стежу, щоб валідація не кричала на кожну літеру.
🗣 How to say it out loud An accessible error is text, not just a red border, and the text explains how to fix it. My favourite pattern is an error summary above the form with links to the fields, which receives focus, plus an error next to the field through aria-describedby and aria-invalid. I check with a screen reader: did I hear what happened, can I get to the field, and is what I typed still there. And I make sure validation does not shout on every letter.
31 Що таке live region і коли використовувати role=status, а коли role=alert? What is a live region, and when do you use role=status versus role=alert?
Форми і динамікаForms and dynamics Middle ▶

Що це. Live region — область сторінки, зміни в якій скрінрідер оголошує сам, навіть якщо фокус деінде. Без неї незряча людина не дізнається, що товар додано в кошик, що фільтр знайшов 12 результатів чи що збереження завершилось. Це 4.1.3 Status Messages (AA): статусні повідомлення програмно визначені, щоб їх можна було оголосити без отримання фокусу.

What it is. A live region is an area of the page whose changes the screen reader announces on its own, even when focus is elsewhere. Without it a blind person never learns that the item went into the cart, that the filter found 12 results, or that saving finished. This is 4.1.3 Status Messages (AA): status messages are programmatically determined so they can be announced without receiving focus.

Варіанти. aria-live=polite — дочекатися паузи і прочитати (більшість випадків). aria-live=assertive — перервати поточне мовлення (лише для термінового). role=status = polite + aria-atomic=true: для «Збережено», «Знайдено 12 товарів», «Додано в кошик». role=alert = assertive: для помилок і попереджень, що потребують уваги зараз, — «З'єднання втрачено». role=log — для чату, де додаються нові рядки. aria-atomic вирішує, читати всю область чи лише змінений вузол.

Options. aria-live=polite — wait for a pause, then read (most cases). aria-live=assertive — interrupt current speech (urgent only). role=status = polite + aria-atomic=true: for «Saved», «12 products found», «Added to cart». role=alert = assertive: for errors and warnings that need attention now — «Connection lost». role=log — for a chat where new lines are appended. aria-atomic decides whether to read the whole region or only the changed node.

Головна пастка. Регіон має існувати в DOM до зміни вмісту. Якщо контейнер з role=status вставляється разом з текстом (типовий тост), багато скрінрідерів нічого не скажуть. Правильно: порожній контейнер у розмітці з самого початку, потім скрипт кладе в нього текст.

The main trap. The region must exist in the DOM before the content changes. If the container with role=status is inserted together with its text (a typical toast), many screen readers say nothing. The right way: an empty container in the markup from the start, then the script puts text into it.

Інші помилки. aria-live на великому блоці зі списком товарів — скрінрідер зачитує весь список при кожному оновленні. Assertive для всього. Тост, що зникає за 2 секунди — зрячі з повільним читанням не встигають (2.2.1). Однаковий текст двічі поспіль може не оголоситися вдруге.

Other mistakes. aria-live on a large block holding the product list — the screen reader reads the whole list on every update. Assertive for everything. A toast that disappears after 2 seconds — sighted slow readers cannot catch it (2.2.1). The same text twice in a row may not be announced the second time.

Як тестую. Лише зі скрінрідером: NVDA з увімкненим Speech Viewer показує, що саме і коли прозвучало. Автотест перевіряє, що текст потрапив у role=status.

How I test. Only with a screen reader: NVDA with Speech Viewer on shows exactly what was said and when. An automated test checks that the text landed in role=status.

Правильний і неправильний тост + перевірка
A correct and an incorrect toast + a check
<!-- ПОГАНО: контейнер вставляється разом з текстом, оголошення може не бути -->
<script>
  document.body.insertAdjacentHTML('beforeend', '<div role="status">Додано в кошик</div>');
</script>

<!-- ДОБРЕ: порожній регіон є в DOM від початку -->
<div id="cart-status" role="status"></div>
<div id="net-alert" role="alert"></div>
<script>
  // пізніше, після дії користувача
  document.getElementById('cart-status').textContent = 'Навушники додано в кошик. Товарів: 3';
  // для термінового
  document.getElementById('net-alert').textContent = 'З\'єднання втрачено. Зміни не збережено.';
</script>
<!-- BAD: the container is inserted with its text, it may not be announced -->
<script>
  document.body.insertAdjacentHTML('beforeend', '<div role="status">Added to cart</div>');
</script>

<!-- GOOD: the empty region is in the DOM from the start -->
<div id="cart-status" role="status"></div>
<div id="net-alert" role="alert"></div>
<script>
  // later, after a user action
  document.getElementById('cart-status').textContent = 'Headphones added to cart. Items: 3';
  // for something urgent
  document.getElementById('net-alert').textContent = 'Connection lost. Changes not saved.';
</script>
await page.getByRole('button', { name: 'Додати в кошик: Навушники' }).click();
await expect(page.getByRole('status')).toHaveText('Навушники додано в кошик. Товарів: 3');
// фокус лишився на кнопці: оголошення не повинно його забирати
await expect(page.getByRole('button', { name: 'Додати в кошик: Навушники' })).toBeFocused();
await page.getByRole('button', { name: 'Add to cart: Headphones' }).click();
await expect(page.getByRole('status')).toHaveText('Headphones added to cart. Items: 3');
// focus stayed on the button: an announcement must not take it
await expect(page.getByRole('button', { name: 'Add to cart: Headphones' })).toBeFocused();
🔧 Розбір прикладу: Регіони з role=status і role=alert порожні в початковій розмітці, скрипт лише змінює textContent. Тест перевіряє обидві половини 4.1.3: повідомлення програмно визначене, і фокус при цьому не переміщувався. Що саме озвучить скрінрідер, автотест не скаже — це перевіряю руками.
🔧 Example breakdown: The role=status and role=alert regions are empty in the initial markup, and the script only changes textContent. The test checks both halves of 4.1.3: the message is programmatically determined, and focus did not move. What the screen reader actually says, an automated test cannot tell — that I check by hand.
💡 Простими словами: Live region — це «диктор» сторінки: status говорить ввічливо, alert перебиває, а сам диктор має стояти на місці заздалегідь.
💡 In plain words: A live region is the page's announcer: status speaks politely, alert interrupts, and the announcer has to be in place beforehand.
🗣 Як читати вголос Live region — це область, зміни в якій скрінрідер оголошує сам, без переносу фокусу. Для «додано в кошик» чи кількості результатів я очікую role status, він ввічливий. role alert — лише для термінового, як втрата з'єднання чи помилка. Найчастіший баг — регіон вставляється в DOM разом з текстом, і скрінрідер мовчить. Тому я перевіряю в NVDA зі Speech Viewer, що саме прозвучало.
🗣 How to say it out loud A live region is an area whose changes the screen reader announces on its own, without moving focus. For «added to cart» or a result count I expect role status, which is polite. Role alert is only for urgent things like a lost connection or an error. The most common bug is the region being inserted into the DOM together with the text, and the screen reader stays silent. So I check in NVDA with Speech Viewer what was actually said.
32 Як тестувати доступність SPA: зміну маршруту, завантаження і динамічно підвантажений вміст? How do you test accessibility in an SPA: route changes, loading states and dynamically loaded content?
Форми і динамікаForms and dynamics Senior ▶

Чому SPA окремий випадок. У класичному сайті перехід за посиланням перезавантажує сторінку: браузер оновлює заголовок вкладки, скидає фокус на початок, скрінрідер оголошує нову сторінку. У SPA JavaScript просто замінює вміст — скрінрідер мовчить, фокус лишається на посиланні, якого вже немає (і падає на <body>), а document.title часто не змінюється (2.4.2 Page Titled).

Why an SPA is special. On a classic site following a link reloads the page: the browser updates the tab title, resets focus to the top, the screen reader announces the new page. In an SPA JavaScript just swaps the content — the screen reader stays silent, focus stays on a link that no longer exists (and falls to <body>), and document.title often does not change (2.4.2 Page Titled).

Що має відбуватися при зміні маршруту. 1) Оновлено document.title («Кошик — Shop»). 2) Фокус перенесено на <h1> нової сторінки (з tabindex=-1) або на контейнер вмісту — або новий заголовок оголошено через live region; вибір узгоджують з командою, головне — однаково на всіх маршрутах. 3) Прокрутка на початок. 4) Кнопка «Назад» повертає і вміст, і розумний фокус.

What should happen on a route change. 1) document.title is updated («Cart — Shop»). 2) Focus moves to the new page's <h1> (with tabindex=-1) or to the content container — or the new title is announced through a live region; the team agrees on the choice, and what matters is doing it the same way on every route. 3) Scroll resets to the top. 4) The Back button restores both the content and a sensible focus.

Завантаження і динаміка. Під час завантаження — aria-busy=true на області і оголошення «Завантаження…» та «Знайдено 20 товарів» у role=status. Нескінченна прокрутка робить футер недосяжним з клавіатури — потрібна кнопка «Показати ще» з переносом фокусу на перший новий елемент. Перемальовування компонента (зміна key у React) знищує вузол у фокусі — фокус губиться; перевіряю після кожної дії, що змінює список: видалення рядка, фільтр, пагінація.

Loading and dynamic content. While loading — aria-busy=true on the region and «Loading…» and «20 products found» announcements in a role=status. Infinite scroll makes the footer unreachable from the keyboard — a «Show more» button is needed, with focus moved to the first new item. Re-rendering a component (a key change in React) destroys the focused node and focus is lost; I check after every action that changes a list: deleting a row, filtering, paging.

Трейд-оф. Перенос фокусу на <h1> надійний і пояснює контекст, але ховає навігацію «позаду». Оголошення через live region не змінює фокус, але залежить від скрінрідера. У тест-плані я фіксую обраний патерн і перевіряю саме його автотестом на кількох маршрутах.

Trade-off. Moving focus to the <h1> is reliable and explains the context, but puts the navigation «behind» the user. Announcing via a live region leaves focus alone but depends on the screen reader. In the test plan I record the chosen pattern and check exactly that with an automated test on several routes.

Тест зміни маршруту і втрати фокусу після видалення
Route change and focus loss after deletion test
import { test, expect } from '@playwright/test';

test('перехід у SPA оновлює title і переносить фокус (2.4.2, 2.4.3)', async ({ page }) => {
  await page.goto('/catalog');
  await page.getByRole('link', { name: 'Кошик' }).press('Enter');

  await expect(page).toHaveURL(/\/cart$/);
  await expect(page).toHaveTitle('Кошик — Shop');
  await expect(page.getByRole('heading', { level: 1, name: 'Кошик' })).toBeFocused();
});

test('після видалення товару фокус не падає на body', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Видалити: Навушники' }).press('Enter');
  await expect(page.getByRole('status')).toHaveText(/Навушники видалено/);
  const tag = await page.evaluate(() => document.activeElement.tagName);
  expect(tag).not.toBe('BODY');
});
import { test, expect } from '@playwright/test';

test('SPA navigation updates the title and moves focus (2.4.2, 2.4.3)', async ({ page }) => {
  await page.goto('/catalog');
  await page.getByRole('link', { name: 'Cart' }).press('Enter');

  await expect(page).toHaveURL(/\/cart$/);
  await expect(page).toHaveTitle('Cart — Shop');
  await expect(page.getByRole('heading', { level: 1, name: 'Cart' })).toBeFocused();
});

test('focus does not fall to body after removing an item', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Remove: Headphones' }).press('Enter');
  await expect(page.getByRole('status')).toHaveText(/Headphones removed/);
  const tag = await page.evaluate(() => document.activeElement.tagName);
  expect(tag).not.toBe('BODY');
});
🔧 Розбір прикладу: Перший тест фіксує контракт маршруту: URL, заголовок вкладки і фокус на <h1>. Другий ловить найпідступніший баг SPA — втрату фокусу після того, як вузол у фокусі зник. press('Enter') замість click() відтворює поведінку клавіатурного користувача.
🔧 Example breakdown: The first test pins the route contract: URL, tab title and focus on the <h1>. The second catches the sneakiest SPA bug — focus loss after the focused node disappears. press('Enter') instead of click() reproduces keyboard user behaviour.
💡 Простими словами: У SPA браузер нічого не робить за тебе: заголовок, фокус і оголошення нової «сторінки» — відповідальність застосунку.
💡 In plain words: In an SPA the browser does nothing for you: the title, focus and announcing the new «page» are the app's job.
🗣 Як читати вголос В SPA нічого не перезавантажується, тому скрінрідер не дізнається про нову сторінку, фокус губиться, а title часто старий. Я домовляюся з командою про один патерн: оновити title і перенести фокус на h1 нової сторінки або оголосити її через live region. Окремо перевіряю завантаження з aria-busy і статусом, нескінченну прокрутку і втрату фокусу після видалення чи фільтрації. Обраний патерн закріплюю автотестом на кількох маршрутах.
🗣 How to say it out loud Nothing reloads in an SPA, so the screen reader never learns about the new page, focus gets lost and the title is often stale. I agree on one pattern with the team: update the title and move focus to the new page's h1, or announce it through a live region. Separately I check loading with aria-busy and a status message, infinite scroll, and focus loss after deleting or filtering. I lock the chosen pattern in with an automated test on several routes.
33 Як таймаут сесії має працювати з погляду доступності і як це протестувати? How should a session timeout behave from an accessibility point of view, and how do you test it?
Форми і динамікаForms and dynamics Middle ▶

Навіщо. Людям зі скрінрідером, з моторними чи когнітивними обмеженнями, з дислексією потрібно в рази більше часу на форму. Жорсткий таймер у 5 хвилин, після якого все зникає, для них означає неможливість завершити дію.

Why. People using screen readers, people with motor or cognitive impairments, people with dyslexia may need many times longer to complete a form. A hard 5-minute timer after which everything vanishes means they simply cannot finish.

2.2.1 Timing Adjustable (A). Для кожного обмеження часу, яке встановлює контент, виконується хоча б одне: людина може вимкнути ліміт; або змінити його щонайменше в 10 разів; або її попереджають до закінчення і дають щонайменше 20 секунд продовжити простою дією (натиснути кнопку), і так щонайменше 10 разів. Винятки: реальні події (аукціон), коли ліміт суттєвий для дії (іспит на час), і ліміти від 20 годин. Пов'язані критерії рівня AAA: 2.2.5 — після повторного входу дані не втрачаються; 2.2.6 — попередити про тривалість неактивності, що веде до втрати даних.

2.2.1 Timing Adjustable (A). For every time limit set by the content, at least one holds: the user can turn the limit off; or adjust it to at least 10 times the default; or they are warned before it expires and given at least 20 seconds to extend it with a simple action (pressing a button), at least 10 times. Exceptions: real-time events (an auction), when the limit is essential (a timed exam), and limits of 20 hours or more. Related AAA criteria: 2.2.5 — no data loss after re-authenticating; 2.2.6 — warn about the inactivity duration that causes data loss.

Як це виглядає правильно. За 2 хвилини до завершення — діалог «Сесія завершиться через 2 хвилини» з кнопками «Продовжити» і «Вийти»; фокус переходить у діалог, текст оголошується; відлік не оголошується щосекунди. Після таймауту введені дані зберігаються, а після входу людина повертається туди, де була.

What good looks like. Two minutes before expiry — a «Your session ends in 2 minutes» dialog with «Stay signed in» and «Sign out» buttons; focus moves into the dialog and the text is announced; the countdown is not announced every second. After a timeout the typed data is kept, and after signing in the person returns to where they were.

Як тестую. Чекати 15 хвилин не треба: у Playwright є page.clock для керування часом сторінки, а бекенду можна зменшити таймаут у тестовому середовищі. Перевіряю, що попередження з'являється, доступне з клавіатури й оголошується, а «Продовжити» справді продовжує сесію на сервері.

How I test. There is no need to wait 15 minutes: Playwright has page.clock to control page time, and the backend timeout can be shortened in the test environment. I check that the warning appears, is keyboard accessible and announced, and that «Stay signed in» really extends the session on the server.

Перевірка попередження про таймаут з page.clock
Timeout warning check with page.clock
test('попередження про кінець сесії можна продовжити (WCAG 2.2.1)', async ({ page }) => {
  await page.clock.install();                        // керуємо таймерами сторінки
  await page.goto('/checkout/address');
  await page.getByLabel('Місто').fill('Львів');

  await page.clock.fastForward('13:00');             // 13 хвилин неактивності
  const warning = page.getByRole('alertdialog', { name: /Сесія завершиться/ });
  await expect(warning).toBeVisible();
  await expect(warning.getByRole('button', { name: 'Продовжити' })).toBeFocused();

  await page.keyboard.press('Enter');                // проста дія продовжує сесію
  await expect(warning).toBeHidden();
  await expect(page.getByLabel('Місто')).toHaveValue('Львів');  // дані не втрачено
});
test('session end warning can be extended (WCAG 2.2.1)', async ({ page }) => {
  await page.clock.install();                        // we control the page timers
  await page.goto('/checkout/address');
  await page.getByLabel('City').fill('Lviv');

  await page.clock.fastForward('13:00');             // 13 minutes of inactivity
  const warning = page.getByRole('alertdialog', { name: /Your session ends/ });
  await expect(warning).toBeVisible();
  await expect(warning.getByRole('button', { name: 'Stay signed in' })).toBeFocused();

  await page.keyboard.press('Enter');                // a simple action extends it
  await expect(warning).toBeHidden();
  await expect(page.getByLabel('City')).toHaveValue('Lviv');  // data not lost
});
🔧 Розбір прикладу: clock.install() має бути викликаний до goto, щоб перехопити таймери сторінки з самого початку. fastForward('13:00') прокручує час на 13 хвилин і запускає всі таймери, що мали спрацювати. alertdialog — роль діалогу, який вимагає негайної реакції; він оголошується при появі.
🔧 Example breakdown: clock.install() has to be called before goto to take over the page timers from the start. fastForward('13:00') advances time by 13 minutes and fires every timer that was due. alertdialog is the role for a dialog that needs an immediate response; it is announced when it appears.
💡 Простими словами: Таймер не повинен відбирати в людини роботу: попереди, дай продовжити одним натисканням і збережи введене.
💡 In plain words: A timer must not take a person's work away: warn, allow extending with one press, and keep what they typed.
🗣 Як читати вголос Користувачам допоміжних технологій потрібно більше часу, тому 2.2.1 вимагає, щоб ліміт можна було вимкнути, збільшити або продовжити після попередження щонайменше за двадцять секунд простою дією. Я перевіряю, що попередження з'являється, отримує фокус, оголошується, і що кнопка «Продовжити» справді продовжує сесію. Чекати п'ятнадцять хвилин не треба: у Playwright я прокручую час через page.clock.
🗣 How to say it out loud People using assistive technology need more time, so 2.2.1 requires that a limit can be turned off, extended, or renewed after a warning with at least twenty seconds and a simple action. I check that the warning appears, takes focus, is announced, and that the «Stay signed in» button really extends the session. There is no need to wait fifteen minutes: in Playwright I fast-forward time with page.clock.
▶ 6 7
34 Який контраст тексту вимагає WCAG і як рахується коефіцієнт контрасту? What text contrast does WCAG require, and how is the contrast ratio calculated?
Візуальне і медіаVisual and media Junior ▶

Вимоги. 1.4.3 Contrast (Minimum), AA: звичайний текст — щонайменше 4.5:1 до фону, великий текст — 3:1. Великий — це від 18pt (24 CSS px) звичайного накреслення або від 14pt (приблизно 18.66 CSS px) жирного. 1.4.6 Contrast (Enhanced), AAA: 7:1 і 4.5:1. Винятки: неактивні елементи, декоративний текст, логотипи. Коефіцієнт може бути від 1:1 (білий на білому) до 21:1 (чорний на білому).

Requirements. 1.4.3 Contrast (Minimum), AA: normal text needs at least 4.5:1 against its background, large text 3:1. Large means 18pt (24 CSS px) regular or 14pt (about 18.66 CSS px) bold and up. 1.4.6 Contrast (Enhanced), AAA: 7:1 and 4.5:1. Exceptions: inactive elements, decorative text, logos. The ratio ranges from 1:1 (white on white) to 21:1 (black on white).

Формула. Для кожного кольору рахують відносну яскравість L: канали sRGB переводять у лінійні (значення до 0.04045 ділять на 12.92, інші — ((c + 0.055) / 1.055) у степені 2.4) і зважують: L = 0.2126·R + 0.7152·G + 0.0722·B. Коефіцієнт = (L світлішого + 0.05) / (L темнішого + 0.05). Зелений канал важить найбільше, тому жовтий на білому майже нечитабельний.

The formula. For each colour compute relative luminance L: convert the sRGB channels to linear (values up to 0.04045 are divided by 12.92, the rest become ((c + 0.055) / 1.055) to the power 2.4) and weight them: L = 0.2126·R + 0.7152·G + 0.0722·B. Ratio = (L of the lighter + 0.05) / (L of the darker + 0.05). Green carries the most weight, which is why yellow on white is nearly unreadable.

Порогові значення, які варто пам'ятати. #767676 на білому дає 4.54:1 — найсвітліший сірий, що проходить AA. #777777 — вже 4.48:1, провал. Популярний #999999 дає лише 2.85:1.

Thresholds worth remembering. #767676 on white gives 4.54:1 — the lightest grey that passes AA. #777777 is already 4.48:1, a fail. The popular #999999 gives only 2.85:1.

Пастки. Округлення не дозволене: 4.48 — це провал, а не «майже 4.5». Текст на зображенні чи градієнті перевіряють у найгіршій точці. Прозорість (opacity, rgba) змінює фактичний колір. Стани hover, focus і disabled теж мають свій контраст. axe рахує контраст автоматично, але на фоні-картинці чи під накладеними елементами повертає результат «потребує перевірки», а не порушення.

Traps. No rounding is allowed: 4.48 is a fail, not «nearly 4.5». Text on an image or gradient is checked at its worst point. Transparency (opacity, rgba) changes the effective colour. Hover, focus and disabled states have their own contrast too. axe computes contrast automatically, but over a background image or under overlapping elements it returns «needs review» rather than a violation.

Інструменти. DevTools color picker (показує коефіцієнт і лінії AA/AAA), Colour Contrast Analyser від TPGi, WebAIM Contrast Checker.

Tools. The DevTools colour picker (shows the ratio and AA/AAA lines), TPGi Colour Contrast Analyser, WebAIM Contrast Checker.

Розрахунок контрасту: #767676 проти #777777 на білому
Contrast calculation: #767676 versus #777777 on white
// відносна яскравість кольору за WCAG
function luminance(hex) {
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
    .map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

contrast('#767676', '#ffffff');  // 4.54 -> AA для звичайного тексту: так
contrast('#777777', '#ffffff');  // 4.48 -> AA: ні (округлювати не можна)
contrast('#999999', '#ffffff');  // 2.85 -> провал навіть для великого тексту

// вручну для #767676: 0x76 = 118 -> 0.4627 -> лінійне 0.1812
// L = 0.1812 (сірий: усі канали однакові), білий L = 1.0
// (1.0 + 0.05) / (0.1812 + 0.05) = 1.05 / 0.2312 = 4.54
// relative luminance of a colour per WCAG
function luminance(hex) {
  const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
    .map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

contrast('#767676', '#ffffff');  // 4.54 -> AA for normal text: yes
contrast('#777777', '#ffffff');  // 4.48 -> AA: no (no rounding allowed)
contrast('#999999', '#ffffff');  // 2.85 -> fails even for large text

// by hand for #767676: 0x76 = 118 -> 0.4627 -> linear 0.1812
// L = 0.1812 (grey: all channels equal), white L = 1.0
// (1.0 + 0.05) / (0.1812 + 0.05) = 1.05 / 0.2312 = 4.54
🔧 Розбір прикладу: Функція повторює формулу з визначення WCAG: лінеаризація каналів, зважена сума, потім відношення з додаванням 0.05 для обох кольорів. Для сірого всі три канали однакові, тож L дорівнює лінійному значенню одного каналу — зручно рахувати на співбесіді на папері.
🔧 Example breakdown: The function mirrors the WCAG definition: linearise the channels, take the weighted sum, then the ratio with 0.05 added to both colours. For grey all three channels are equal, so L equals the linear value of a single channel — handy for working it out on paper in an interview.
💡 Простими словами: 4.5:1 для звичайного тексту, 3:1 для великого, і 4.49 — це вже провал.
💡 In plain words: 4.5:1 for normal text, 3:1 for large text, and 4.49 is already a fail.
🗣 Як читати вголос Для рівня AA звичайний текст повинен мати контраст щонайменше 4.5 до 1, а великий, від двадцяти чотирьох пікселів або вісімнадцяти з гаком жирним, — 3 до 1. Коефіцієнт рахується з відносної яскравості двох кольорів з додаванням 0.05. Я пам'ятаю, що сірий 767676 на білому ледь проходить, а округлювати не можна. Для тексту на картинках axe дає лише «потребує перевірки», тому там міряю вручну піпеткою.
🗣 How to say it out loud For level AA normal text needs a contrast of at least 4.5 to 1, and large text, from twenty-four pixels or about eighteen and a half bold, needs 3 to 1. The ratio comes from the relative luminance of the two colours plus 0.05. I remember that grey 767676 on white just passes, and rounding is not allowed. For text over images axe only says «needs review», so there I measure by hand with an eyedropper.
35 Що таке нетекстовий контраст (1.4.11) і які елементи він охоплює? What is non-text contrast (1.4.11), and which elements does it cover?
Візуальне і медіаVisual and media Middle ▶

Що це. 1.4.11 Non-text Contrast (AA): візуальна інформація, потрібна, щоб розпізнати елемент керування чи його стан, і значущі графічні об'єкти мають контраст щонайменше 3:1 до сусідніх кольорів.

What it is. 1.4.11 Non-text Contrast (AA): visual information needed to identify a user interface component or its state, and meaningful graphical objects, have at least 3:1 contrast against adjacent colours.

Що охоплює. 1) Межі полів і кнопок, якщо без них не зрозуміло, що це поле: світло-сіра рамка #cccccc на білому дає 1.6:1 — людина зі слабким зором бачить форму як порожню сторінку з підписами. 2) Стани: позначений чекбокс, вибрана радіокнопка, активна вкладка, індикатор фокусу, перемикач увімк/вимк. 3) Іконки, що несуть сенс без тексту: лупа пошуку, хрестик закриття. 4) Частини графіків, потрібні для розуміння: лінії, сегменти діаграми, точки.

What it covers. 1) Borders of fields and buttons when you cannot tell it is a field without them: a light grey #cccccc border on white gives 1.6:1 — a person with low vision sees the form as an empty page with captions. 2) States: a checked checkbox, a selected radio, the active tab, the focus indicator, an on/off toggle. 3) Icons that carry meaning without text: a search magnifier, a close cross. 4) Chart parts needed to understand it: lines, pie segments, points.

Що не охоплює. Неактивні (disabled) елементи; елементи, вигляд яких визначає браузер і автор його не змінював; декоративну графіку; логотипи; графіки, де ті самі дані є поруч у тексті чи таблиці. Кнопка з текстом на кольоровому фоні може не мати контрастної межі — її розпізнають за текстом, який має пройти 1.4.3.

What it does not cover. Disabled elements; elements whose appearance is set by the browser and not changed by the author; decorative graphics; logos; charts whose data is also available as text or a table nearby. A text button on a coloured background may have no contrasting boundary — it is identified by its text, which must pass 1.4.3.

Типові провали. Поле з лише нижньою світлою лінією. Чекбокс, у якому стан «вибрано» відрізняється відтінком синього. Лінії двох серій графіка, розрізнені лише кольором (ще й 1.4.1). Індикатор фокусу — світла тінь.

Typical failures. A field with only a faint bottom line. A checkbox whose checked state differs by a shade of blue. Two chart series told apart only by colour (a 1.4.1 failure too). A focus indicator that is a faint glow.

Як тестую. Піпеткою в Colour Contrast Analyser міряю межу поля, іконку і кожен стан відносно фону. axe цього не перевіряє: його правило color-contrast стосується лише тексту.

How I test. With the Colour Contrast Analyser eyedropper I measure the field border, the icon and each state against the background. axe does not check this: its color-contrast rule covers text only.

Межі полів і стан чекбокса: провал і виправлення
Field borders and checkbox state: failure and fix
/* ПРОВАЛ 1.4.11: межа поля #cccccc на #ffffff = 1.61:1 */
input[type=text] { border: 1px solid #cccccc; }

/* ОК: #8a8a8a на #ffffff = 3.45:1, поле видно */
input[type=text] { border: 1px solid #8a8a8a; }

/* ПРОВАЛ: вибраний і невибраний чекбокс розрізняються відтінком */
.check { background: #dbe7ff; }
.check[aria-checked=true] { background: #c7d9ff; }

/* ОК: вибраний стан має контрастну заливку і галочку */
.check[aria-checked=true] { background: #1a73e8; }  /* 4.5:1 до білого */
.check[aria-checked=true]::after { content: '✓'; color: #ffffff; }
/* FAILS 1.4.11: field border #cccccc on #ffffff = 1.61:1 */
input[type=text] { border: 1px solid #cccccc; }

/* OK: #8a8a8a on #ffffff = 3.45:1, the field is visible */
input[type=text] { border: 1px solid #8a8a8a; }

/* FAILS: checked and unchecked differ by a shade */
.check { background: #dbe7ff; }
.check[aria-checked=true] { background: #c7d9ff; }

/* OK: the checked state has a contrasting fill and a tick */
.check[aria-checked=true] { background: #1a73e8; }  /* 4.5:1 to white */
.check[aria-checked=true]::after { content: '✓'; color: #ffffff; }
🔧 Розбір прикладу: Для нетекстового контрасту межа — 3:1, а не 4.5:1, і міряють її між самим елементом і тим, що безпосередньо поруч. Галочка у вибраному стані додає ще й форму, тож стан не залежить лише від кольору.
🔧 Example breakdown: For non-text contrast the bar is 3:1, not 4.5:1, measured between the element and whatever is directly next to it. The tick in the checked state also adds a shape, so the state does not depend on colour alone.
💡 Простими словами: Якщо без рамки чи іконки не зрозуміло, що це поле чи кнопка, ця рамка чи іконка мусить мати контраст 3:1.
💡 In plain words: If you cannot tell it is a field or a button without its border or icon, that border or icon needs 3:1 contrast.
🗣 Як читати вголос Критерій 1.4.11 про контраст не тексту, а всього, що потрібно для розпізнавання елементів керування і їхніх станів: межі полів, іконки, стан чекбокса, індикатор фокусу, лінії графіків. Поріг тут 3 до 1. Класичний провал — світло-сіра рамка поля, через яку людина зі слабким зором не бачить, де вводити. axe це не перевіряє, тому я міряю піпеткою в Colour Contrast Analyser.
🗣 How to say it out loud Criterion 1.4.11 is about contrast for everything other than text that you need to recognise controls and their states: field borders, icons, checkbox state, the focus indicator, chart lines. The bar here is 3 to 1. The classic failure is a light grey field border, so a person with low vision cannot see where to type. axe does not check this, so I measure with the eyedropper in Colour Contrast Analyser.
36 Чому статус, переданий лише кольором, — проблема і як це перевірити? Why is a status shown only by colour a problem, and how do you check it?
Візуальне і медіаVisual and media Junior ▶

Проблема. Близько 8 % чоловіків і 0.5 % жінок мають порушення кольоросприйняття, найчастіше червоно-зеленого. Для них зелений бейдж «Оплачено» і червоний «Прострочено» однакові. Скрінрідер колір не озвучує взагалі. На сонці чи монохромному дисплеї відтінки теж губляться. Критерій 1.4.1 Use of Color (A): колір не може бути єдиним візуальним способом передати інформацію, вказати дію, попросити реакцію чи розрізнити елементи.

The problem. About 8 % of men and 0.5 % of women have a colour vision deficiency, most often red-green. To them a green «Paid» badge and a red «Overdue» badge look the same. A screen reader does not announce colour at all. In sunlight or on a monochrome display shades get lost too. Criterion 1.4.1 Use of Color (A): colour cannot be the only visual means of conveying information, indicating an action, prompting a response or distinguishing an element.

Де трапляється. Статуси в таблицях, помилки в полях (лише червона рамка), обов'язкові поля (лише червона зірочка без пояснення), лінії графіків і легенда, дні в календарі (зайняті — сірі), посилання в тексті без підкреслення, «правильні» і «неправильні» відповіді в тесті.

Where it happens. Statuses in tables, field errors (a red border only), required fields (a red asterisk with no explanation), chart lines and legends, calendar days (booked ones in grey), links in text without an underline, «right» and «wrong» answers in a quiz.

Як виправити. Додати другий канал: текст («Прострочено»), іконку з різною формою, візерунок чи підпис на графіку, підкреслення посилання. Для посилань без підкреслення WCAG дозволяє варіант з контрастом 3:1 до навколишнього тексту плюс інший візуальний сигнал при наведенні й фокусі, але підкреслення надійніше.

How to fix it. Add a second channel: text («Overdue»), an icon with a different shape, a pattern or label on a chart, an underline on the link. For links without an underline WCAG allows 3:1 contrast against the surrounding text plus another visual cue on hover and focus, but an underline is more robust.

Як тестую. Режим відтінків сірого або симуляція протанопії/дейтеранопії в Chrome DevTools (Rendering → Emulate vision deficiencies) і питання: «чи можу я все ще прочитати статус?». Скрінрідер: чи чую статус словами. Автоматично це не ловиться, хіба що скриншот у відтінках сірого для рев'ю.

How I test. Greyscale mode or protanopia/deuteranopia simulation in Chrome DevTools (Rendering → Emulate vision deficiencies), asking «can I still read the status?». A screen reader: do I hear the status in words. This is not caught automatically, except for a greyscale screenshot to review.

Бейджі статусу: лише колір проти кольору + текст + форма
Status badges: colour only versus colour + text + shape
<!-- ПОГАНО: зміст лише в кольорі кружечка -->
<td><span class="dot dot-green"></span></td>
<td><span class="dot dot-red"></span></td>

<!-- ДОБРЕ: текст і різна форма іконки, колір лише підсилює -->
<td><span class="badge badge-ok"><svg aria-hidden="true">✓</svg> Оплачено</span></td>
<td><span class="badge badge-late"><svg aria-hidden="true">!</svg> Прострочено</span></td>
// скриншот у відтінках сірого для рев'ю 1.4.1
await page.goto('/invoices');
await page.addStyleTag({ content: 'html { filter: grayscale(1); }' });
await page.screenshot({ path: 'invoices-grayscale.png', fullPage: true });
<!-- BAD: the meaning is only in the dot colour -->
<td><span class="dot dot-green"></span></td>
<td><span class="dot dot-red"></span></td>

<!-- GOOD: text and a different icon shape, colour only reinforces -->
<td><span class="badge badge-ok"><svg aria-hidden="true">✓</svg> Paid</span></td>
<td><span class="badge badge-late"><svg aria-hidden="true">!</svg> Overdue</span></td>
// greyscale screenshot for a 1.4.1 review
await page.goto('/invoices');
await page.addStyleTag({ content: 'html { filter: grayscale(1); }' });
await page.screenshot({ path: 'invoices-grayscale.png', fullPage: true });
🔧 Розбір прикладу: У доброму варіанті статус читається словом навіть без кольору і без зору, а іконки різної форми допомагають швидко сканувати таблицю. CSS-фільтр grayscale(1) — простий спосіб подивитися на сторінку «без кольору» в автоматизованому прогоні і приклеїти скриншот до рев'ю дизайну.
🔧 Example breakdown: In the good version the status reads as a word even without colour or sight, and icons with different shapes help scan the table quickly. The grayscale(1) CSS filter is a simple way to see the page «without colour» in an automated run and attach the screenshot to a design review.
💡 Простими словами: Колір може підсилювати сенс, але не нести його сам.
💡 In plain words: Colour can reinforce meaning, but must not carry it alone.
🗣 Як читати вголос Якщо статус передано лише кольором, людина з червоно-зеленою кольоросліпотою, а таких серед чоловіків близько восьми відсотків, його не розрізнить, а скрінрідер колір не читає зовсім. Це провал 1.4.1. Я прошу додати другий канал: текст, іконку іншої форми, підкреслення для посилань. Перевіряю в DevTools через емуляцію порушень кольоросприйняття або просто в відтінках сірого.
🗣 How to say it out loud If a status is shown only by colour, a person with red-green colour blindness, which is around eight percent of men, cannot tell it apart, and a screen reader does not read colour at all. That fails 1.4.1. I ask for a second channel: text, an icon with a different shape, an underline for links. I check it in DevTools with colour vision deficiency emulation or simply in greyscale.
37 Як анімація і рух впливають на доступність і як тестувати prefers-reduced-motion? How do animation and motion affect accessibility, and how do you test prefers-reduced-motion?
Візуальне і медіаVisual and media Middle ▶

Кого зачіпає. Людей з вестибулярними розладами: паралакс, масштабування на весь екран, прокрутка-«стрибок» і анімовані переходи між сторінками викликають нудоту й запаморочення. Людей з СДУГ і когнітивними особливостями: каруселі й анімовані банери відволікають від задачі. Людей з фоточутливою епілепсією: спалахи можуть спричинити напад.

Who it affects. People with vestibular disorders: parallax, full-screen zooms, scroll-jacking and animated page transitions cause nausea and dizziness. People with ADHD and cognitive differences: carousels and animated banners pull attention from the task. People with photosensitive epilepsy: flashing can trigger a seizure.

Критерії. 2.2.2 Pause, Stop, Hide (A): рух, мерехтіння чи автооновлення, що стартує само, триває понад 5 секунд і показується поруч з іншим контентом, можна призупинити, зупинити чи приховати — карусель без кнопки паузи провалює. 2.3.1 Three Flashes or Below Threshold (A): не більше трьох спалахів за секунду. 2.3.3 Animation from Interactions (AAA): рух, спричинений взаємодією, можна вимкнути, якщо він не суттєвий.

Criteria. 2.2.2 Pause, Stop, Hide (A): motion, blinking or auto-updating that starts by itself, lasts over 5 seconds and runs alongside other content can be paused, stopped or hidden — a carousel with no pause button fails. 2.3.1 Three Flashes or Below Threshold (A): no more than three flashes per second. 2.3.3 Animation from Interactions (AAA): motion triggered by interaction can be turned off unless it is essential.

prefers-reduced-motion. Медіазапит, що відображає налаштування ОС: «Зменшити рух» у macOS/iOS, «Показувати анімацію» у Windows, «Вимкнути анімацію» в Android. Сайт має реагувати: прибрати паралакс, автопрокрутку каруселі, великі переміщення; залишити прості зміни прозорості. Це не «вимкнути всі переходи» — зникнення індикатора завантаження теж погано.

prefers-reduced-motion. A media query reflecting the OS setting: «Reduce motion» on macOS/iOS, «Show animations» on Windows, «Remove animations» on Android. The site should respond: drop parallax, carousel auto-advance and big movements; keep simple opacity changes. It is not «switch off every transition» — a loading indicator disappearing is bad too.

Як тестую. Вмикаю налаштування ОС або емулюю медіа-функцію в DevTools (Rendering → prefers-reduced-motion) чи в Playwright через emulateMedia, і перевіряю, що каруселі не крутяться самі, паралакс вимкнений, а тривалість анімацій близька до нуля. Каруселі без reduced motion перевіряю на кнопку паузи.

How I test. I turn on the OS setting or emulate the media feature in DevTools (Rendering → prefers-reduced-motion) or in Playwright via emulateMedia, and check that carousels do not rotate by themselves, parallax is off and animation durations are close to zero. Without reduced motion, I check carousels for a pause button.

CSS для зменшеного руху і тест в Playwright
CSS for reduced motion and a Playwright test
/* повний рух за замовчуванням */
.hero { animation: slide-in 600ms ease-out; }
.parallax { transform: translateY(calc(var(--scroll) * -0.3px)); }

/* користувач попросив менше руху в налаштуваннях ОС */
@media (prefers-reduced-motion: reduce) {
  .hero { animation: fade-in 150ms linear; }   /* лише прозорість */
  .parallax { transform: none; }
  html { scroll-behavior: auto; }
}
/* full motion by default */
.hero { animation: slide-in 600ms ease-out; }
.parallax { transform: translateY(calc(var(--scroll) * -0.3px)); }

/* the user asked for less motion in the OS settings */
@media (prefers-reduced-motion: reduce) {
  .hero { animation: fade-in 150ms linear; }   /* opacity only */
  .parallax { transform: none; }
  html { scroll-behavior: auto; }
}
test('карусель не крутиться сама при reduced motion', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');
  const current = page.getByRole('group', { name: /Слайд 1 з/ });
  await expect(current).toBeVisible();
  await page.waitForTimeout(6000);          // довше за типовий інтервал автоперемикання
  await expect(current).toBeVisible();      // той самий слайд
  await expect(page.locator('.parallax')).toHaveCSS('transform', 'none');
});
test('carousel does not auto-rotate with reduced motion', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('/');
  const current = page.getByRole('group', { name: /Slide 1 of/ });
  await expect(current).toBeVisible();
  await page.waitForTimeout(6000);          // longer than a typical auto-advance interval
  await expect(current).toBeVisible();      // still the same slide
  await expect(page.locator('.parallax')).toHaveCSS('transform', 'none');
});
🔧 Розбір прикладу: Медіазапит замінює рух на коротке проявлення, а не прибирає анімацію повністю. emulateMedia({ reducedMotion: 'reduce' }) вмикає ту саму гілку CSS, що й налаштування ОС. waitForTimeout тут свідомий: ми перевіряємо, що за 6 секунд нічого не сталося; для швидшого тесту можна керувати часом через page.clock.
🔧 Example breakdown: The media query swaps movement for a short fade instead of removing animation entirely. emulateMedia({ reducedMotion: 'reduce' }) turns on the same CSS branch as the OS setting. The waitForTimeout is deliberate here: we check that nothing happened over 6 seconds; for a faster test, control time with page.clock.
💡 Простими словами: Якщо людина в налаштуваннях попросила менше руху, сайт має послухатись; усе, що рухається саме понад 5 секунд, має кнопку паузи.
💡 In plain words: If a person asked for less motion in their settings, the site must listen; anything that moves on its own for over 5 seconds needs a pause button.
🗣 Як читати вголос Рух зачіпає людей з вестибулярними розладами, з розладами уваги та епілепсією. Тому я перевіряю три речі: каруселі й банери, що рухаються понад п'ять секунд, мають паузу, немає частих спалахів, а при prefers-reduced-motion сайт прибирає паралакс і великі переміщення. У Playwright я емулюю цю медіа-функцію через emulateMedia і дивлюся, що карусель не крутиться сама.
🗣 How to say it out loud Motion affects people with vestibular disorders, attention disorders and epilepsy. So I check three things: carousels and banners that move for more than five seconds have a pause control, there is no rapid flashing, and with prefers-reduced-motion the site drops parallax and big movements. In Playwright I emulate that media feature with emulateMedia and check the carousel does not rotate on its own.
38 Як перевірити масштабування до 200 %, reflow на 320 CSS px і text spacing? How do you test zoom to 200 %, reflow at 320 CSS px and text spacing?
Візуальне і медіаVisual and media Middle ▶

Три критерії. 1.4.4 Resize Text (AA): текст збільшується до 200 % без втрати вмісту чи функцій. 1.4.10 Reflow (AA): при ширині 320 CSS px (це 1280 px на 400 % масштабу) вміст читається без горизонтальної прокрутки — лише вертикальна; винятки — таблиці даних, карти, схеми, редактори. 1.4.12 Text Spacing (AA): якщо користувач задасть міжрядковий інтервал 1.5, відступ після абзацу 2, міжлітерний 0.12 і міжслівний 0.16 від розміру шрифту, нічого не обрізається і не накладається.

Three criteria. 1.4.4 Resize Text (AA): text scales up to 200 % without loss of content or function. 1.4.10 Reflow (AA): at a width of 320 CSS px (1280 px at 400 % zoom) content reads without horizontal scrolling — vertical only; exceptions are data tables, maps, diagrams, editors. 1.4.12 Text Spacing (AA): if the user sets line height to 1.5, paragraph spacing to 2, letter spacing to 0.12 and word spacing to 0.16 times the font size, nothing is clipped or overlaps.

Кому це потрібно. Людям зі слабким зором, які збільшують масштаб браузера, а не використовують екранну лупу; людям з дислексією, які застосовують власні стилі з більшими інтервалами.

Who needs it. Low-vision users who zoom the browser rather than use a screen magnifier; people with dyslexia who apply their own styles with wider spacing.

Як тестую. Масштаб 200 % і 400 % (Ctrl/Cmd +) на ширині вікна 1280 px, або одразу ширина 320 px у DevTools. Шукаю: горизонтальну прокрутку сторінки, обрізані кнопки й тексти з фіксованою висотою, липку шапку, що займає пів екрана, модалку, яку неможливо прокрутити до кнопки «Зберегти», меню без бургера. Для text spacing — букмарклет або ін'єкція CSS з чотирма значеннями.

How I test. Zoom to 200 % and 400 % (Ctrl/Cmd +) with a 1280 px window, or set the width straight to 320 px in DevTools. I look for: horizontal page scroll, clipped buttons and fixed-height text boxes, a sticky header eating half the screen, a modal that cannot be scrolled down to its «Save» button, a menu with no hamburger. For text spacing — a bookmarklet or injected CSS with the four values.

Типові баги. height у пікселях на кнопці з текстом, overflow:hidden на картках, white-space:nowrap, <meta viewport> з maximum-scale=1 чи user-scalable=no — забороняє pinch-zoom на мобільному (axe meta-viewport).

Typical bugs. height in pixels on a button with text, overflow:hidden on cards, white-space:nowrap, a <meta viewport> with maximum-scale=1 or user-scalable=no — it blocks pinch zoom on mobile (axe meta-viewport).

Автоматична перевірка reflow і text spacing
Automated reflow and text spacing check
test('чекаут без горизонтального скролу на 320 px (WCAG 1.4.10)', async ({ page }) => {
  await page.setViewportSize({ width: 320, height: 640 });
  await page.goto('/checkout');
  const { scrollWidth, clientWidth } = await page.evaluate(() => ({
    scrollWidth: document.documentElement.scrollWidth,
    clientWidth: document.documentElement.clientWidth,
  }));
  expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
  await expect(page.getByRole('button', { name: 'Оплатити' })).toBeInViewport({ ratio: 1 });
});

test('text spacing не обрізає текст (WCAG 1.4.12)', async ({ page }) => {
  await page.goto('/checkout');
  await page.addStyleTag({ content:
    '* { line-height: 1.5 !important; letter-spacing: 0.12em !important;' +
    ' word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }' });
  // шукаємо елементи, де текст вилазить за межі й обрізається
  const clipped = await page.evaluate(() => [...document.querySelectorAll('button, a, label, p')]
    .filter((el) => el.scrollHeight > el.clientHeight + 1 && getComputedStyle(el).overflow === 'hidden')
    .map((el) => el.textContent.trim()));
  expect(clipped).toEqual([]);
});
test('checkout has no horizontal scroll at 320 px (WCAG 1.4.10)', async ({ page }) => {
  await page.setViewportSize({ width: 320, height: 640 });
  await page.goto('/checkout');
  const { scrollWidth, clientWidth } = await page.evaluate(() => ({
    scrollWidth: document.documentElement.scrollWidth,
    clientWidth: document.documentElement.clientWidth,
  }));
  expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
  await expect(page.getByRole('button', { name: 'Pay' })).toBeInViewport({ ratio: 1 });
});

test('text spacing does not clip text (WCAG 1.4.12)', async ({ page }) => {
  await page.goto('/checkout');
  await page.addStyleTag({ content:
    '* { line-height: 1.5 !important; letter-spacing: 0.12em !important;' +
    ' word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }' });
  // find elements whose text overflows and gets clipped
  const clipped = await page.evaluate(() => [...document.querySelectorAll('button, a, label, p')]
    .filter((el) => el.scrollHeight > el.clientHeight + 1 && getComputedStyle(el).overflow === 'hidden')
    .map((el) => el.textContent.trim()));
  expect(clipped).toEqual([]);
});
🔧 Розбір прикладу: Перший тест емулює 400 % масштабу через ширину 320 px і перевіряє, що документ не ширший за вікно, а ключова кнопка видна повністю. Другий застосовує рівно ті чотири значення, що названі в 1.4.12, і шукає елементи з overflow:hidden, у яких вміст став вищим за контейнер. page.evaluate повертає лише серіалізовані значення, тому з елемента беремо два числа.
🔧 Example breakdown: The first test emulates 400 % zoom via a 320 px width and checks that the document is no wider than the window and the key button is fully visible. The second applies exactly the four values named in 1.4.12 and looks for elements with overflow:hidden whose content grew taller than the box. page.evaluate returns only serialisable values, so we take two numbers from the element.
💡 Простими словами: Збільш сторінку в чотири рази і розсунь текст: нічого не повинно зникнути, обрізатися чи вимагати прокрутки вбік.
💡 In plain words: Zoom the page four times and space the text out: nothing should vanish, get clipped or need sideways scrolling.
🗣 Як читати вголос Тут три критерії: 1.4.4 — текст збільшується до двохсот відсотків, 1.4.10 — на ширині 320 CSS пікселів усе читається без горизонтальної прокрутки, і 1.4.12 — збільшені інтервали тексту нічого не обрізають. Я роблю це руками на 400 відсотках, а в автотестах ставлю вікно 320 пікселів і інжектую CSS з інтервалами. Найчастіше ламаються кнопки з фіксованою висотою і липкі шапки.
🗣 How to say it out loud There are three criteria here: 1.4.4, text scales to two hundred percent; 1.4.10, at 320 CSS pixels wide everything reads without horizontal scrolling; and 1.4.12, larger text spacing clips nothing. I do it by hand at 400 percent, and in automated tests I set a 320-pixel viewport and inject CSS with the spacing values. Fixed-height buttons and sticky headers break most often.
39 Чому автовідтворення відео зі звуком — бар'єр і що вимагає WCAG? Why is autoplaying video with sound a barrier, and what does WCAG require?
Візуальне і медіаVisual and media Junior ▶

Чому бар'єр. Незрячий користувач чує сторінку через синтезатор мовлення. Якщо на сторінці одразу заграє відео зі звуком, воно перекриває голос скрінрідера, і людина не може знайти, де кнопка паузи, бо не чує, що читається. Людям з когнітивними труднощами і сенсорною чутливістю несподіваний звук заважає зосередитися. Плюс звичайний дискомфорт у відкритому офісі.

Why it is a barrier. A blind user hears the page through a speech synthesiser. If a video with sound starts playing immediately, it drowns out the screen reader voice, and the person cannot find the pause button because they cannot hear what is being read. For people with cognitive difficulties and sensory sensitivity unexpected sound makes it hard to focus. Plus the ordinary discomfort in an open-plan office.

Критерії. 1.4.2 Audio Control (A): якщо аудіо на сторінці відтворюється автоматично довше 3 секунд, має бути механізм паузи чи зупинки або регулювання гучності, незалежне від системної гучності. Це один з «невтручальних» критеріїв: його провал ламає відповідність усієї сторінки. 2.2.2 Pause, Stop, Hide (A): беззвучне фонове відео, що рухається довше 5 секунд, теж потребує паузи.

Criteria. 1.4.2 Audio Control (A): if audio on a page plays automatically for more than 3 seconds, there must be a way to pause or stop it, or to control its volume independently of the system volume. It is one of the «non-interference» criteria: failing it breaks conformance of the whole page. 2.2.2 Pause, Stop, Hide (A): a silent background video that moves for more than 5 seconds also needs a pause.

Найкраща практика. Не запускати звук автоматично взагалі. Фонове відео — без звуку, з видимою кнопкою паузи, і зупиняється при prefers-reduced-motion. Кнопка паузи — перший фокусований елемент після відкриття, з доступним ім'ям.

Best practice. Do not start sound automatically at all. Background video is muted, has a visible pause button and stops under prefers-reduced-motion. The pause button is the first focusable element after opening, with an accessible name.

Як тестую. Відкриваю сторінку з увімкненим NVDA: чи чую я скрінрідер. Перевіряю атрибути autoplay і muted. Шукаю кнопку паузи з клавіатури. Браузери часто блокують автозапуск зі звуком, але не завжди (наприклад, після взаємодії з сайтом), тому поведінку в тестовому браузері не можна вважати гарантією.

How I test. I open the page with NVDA running: can I hear the screen reader. I check the autoplay and muted attributes. I look for the pause button with the keyboard. Browsers often block autoplay with sound, but not always (for example after interacting with the site), so behaviour in a test browser is no guarantee.

Погане і прийнятне автовідтворення + перевірка атрибутів
Bad and acceptable autoplay + an attribute check
<!-- ПРОВАЛ 1.4.2: звук стартує сам, керування немає -->
<video src="promo.mp4" autoplay></video>

<!-- ОК: беззвучний фон, видима пауза, звук лише за вибором -->
<video id="bg" src="promo.mp4" autoplay muted loop playsinline aria-hidden="true"></video>
<button type="button" aria-pressed="false" id="bg-pause">Пауза фонового відео</button>
const videos = await page.locator('video[autoplay]').evaluateAll((list) =>
  list.map((v) => ({ src: v.currentSrc, muted: v.muted })));
// кожне автовідтворюване відео має бути без звуку (WCAG 1.4.2)
expect(videos.filter((v) => !v.muted)).toEqual([]);
await expect(page.getByRole('button', { name: /Пауза/ })).toBeVisible();
<!-- FAILS 1.4.2: sound starts by itself, no controls -->
<video src="promo.mp4" autoplay></video>

<!-- OK: silent background, visible pause, sound only by choice -->
<video id="bg" src="promo.mp4" autoplay muted loop playsinline aria-hidden="true"></video>
<button type="button" aria-pressed="false" id="bg-pause">Pause background video</button>
const videos = await page.locator('video[autoplay]').evaluateAll((list) =>
  list.map((v) => ({ src: v.currentSrc, muted: v.muted })));
// every autoplaying video must be muted (WCAG 1.4.2)
expect(videos.filter((v) => !v.muted)).toEqual([]);
await expect(page.getByRole('button', { name: /Pause/ })).toBeVisible();
🔧 Розбір прикладу: Декоративне фонове відео приховане від скрінрідера через aria-hidden, а кнопка паузи — окремий видимий елемент з aria-pressed для стану. Тест читає властивість muted, а не атрибут, бо скрипт міг увімкнути звук уже після завантаження.
🔧 Example breakdown: The decorative background video is hidden from the screen reader with aria-hidden, and the pause button is a separate visible element with aria-pressed for its state. The test reads the muted property, not the attribute, because a script might have unmuted it after load.
💡 Простими словами: Звук має вмикати людина, а не сторінка; все, що рухається само, має паузу.
💡 In plain words: The person turns the sound on, not the page; anything that moves on its own has a pause.
🗣 Як читати вголос Автовідтворення зі звуком глушить голос скрінрідера, і незряча людина навіть не може знайти кнопку, щоб його зупинити. Тому 1.4.2 вимагає, щоб звук довше трьох секунд можна було зупинити чи зробити тихіше, і цей критерій ламає всю сторінку. Я перевіряю, що автовідтворення лише беззвучне, є видима кнопка паузи, і відкриваю сторінку з NVDA, щоб почути, чи нічого не перекриває мовлення.
🗣 How to say it out loud Autoplay with sound drowns out the screen reader voice, and a blind person cannot even find the button to stop it. So 1.4.2 requires that sound lasting over three seconds can be stopped or turned down, and that criterion breaks the whole page. I check that autoplay is muted only, there is a visible pause button, and I open the page with NVDA to hear whether anything covers the speech.
40 Чим відрізняються субтитри, транскрипт і аудіоопис і кому потрібне кожне з них? How do captions, transcripts and audio description differ, and who needs each of them?
Візуальне і медіаVisual and media Junior ▶

Субтитри (captions). Синхронізований текст мовлення і значущих звуків: хто говорить, [сміх], [дзвінок телефону], [тривожна музика]. Для глухих і людей з порушенням слуху, а також для перегляду без звуку. 1.2.2 Captions (Prerecorded), A і 1.2.4 Captions (Live), AA. Субтитри-переклад (subtitles) без звуків і без позначення мовців цих критеріїв не закривають.

Captions. Synchronised text of speech and meaningful sounds: who is speaking, [laughter], [phone ringing], [tense music]. For Deaf and hard-of-hearing people, and for watching with the sound off. 1.2.2 Captions (Prerecorded), A and 1.2.4 Captions (Live), AA. Translation subtitles without sounds or speaker labels do not satisfy these criteria.

Транскрипт. Повний текст усього аудіо і, для відео, важливих візуальних подій, розміщений поруч з медіа. Потрібен людям зі сліпоглухотою, що читають брайлівським дисплеєм, тим, хто читає швидше, ніж слухає, людям з когнітивними особливостями; ще й індексується пошуком. Для подкасту (лише аудіо) транскрипт — вимога 1.2.1 (A).

Transcript. The full text of all audio and, for video, the important visual events, placed next to the media. Needed by deafblind people reading on a braille display, by people who read faster than they listen, and by people with cognitive differences; search engines index it too. For a podcast (audio only) a transcript is a 1.2.1 (A) requirement.

Аудіоопис. Додаткова озвучка того, що відбувається на екрані і не зрозуміло зі звуку: «На слайді графік, продажі ростуть». Для незрячих. 1.2.3 (A) дозволяє аудіоопис або текстову альтернативу медіа, 1.2.5 (AA) вимагає саме аудіоопис для записаного відео. Якщо все важливе проговорюється в кадрі, окремий опис не потрібен — тому доповідачу краще описувати слайди вголос.

Audio description. Extra narration of what happens on screen and is not clear from the sound: «The slide shows a chart, sales are rising». For blind people. 1.2.3 (A) allows either audio description or a text alternative for the media, and 1.2.5 (AA) requires audio description itself for prerecorded video. If everything important is spoken in the video, no separate description is needed — which is why presenters should describe their slides out loud.

Що тестую. Наявність доріжки субтитрів, точність (автогенеровані субтитри з помилками в термінах — дефект), синхронізація, позначення звуків і мовців, контраст субтитрів, керування плеєром з клавіатури, кнопка субтитрів з ім'ям.

What I test. That a caption track exists, its accuracy (auto-generated captions that garble terms are a defect), synchronisation, sound and speaker labels, caption contrast, keyboard control of the player, a named captions button.

Відео з субтитрами, аудіоописом і транскриптом
Video with captions, audio description and a transcript
<video controls>
  <source src="/media/onboarding.mp4" type="video/mp4">
  <track kind="captions" src="/media/onboarding.uk.vtt" srclang="uk" label="Українська" default>
  <track kind="descriptions" src="/media/onboarding.desc.uk.vtt" srclang="uk" label="Аудіоопис">
</video>
<details>
  <summary>Транскрипт відео</summary>
  <p>[Музика] Олена: Вітаю! Покажу, як створити перший проєкт...</p>
</details>

<!-- onboarding.uk.vtt -->
WEBVTT

00:00:01.000 --> 00:00:03.500
[спокійна музика]

00:00:04.000 --> 00:00:07.200
ОЛЕНА: Вітаю! Покажу, як створити перший проєкт.
<video controls>
  <source src="/media/onboarding.mp4" type="video/mp4">
  <track kind="captions" src="/media/onboarding.en.vtt" srclang="en" label="English" default>
  <track kind="descriptions" src="/media/onboarding.desc.en.vtt" srclang="en" label="Audio description">
</video>
<details>
  <summary>Video transcript</summary>
  <p>[Music] Olena: Hi! Let me show you how to create your first project...</p>
</details>

<!-- onboarding.en.vtt -->
WEBVTT

00:00:01.000 --> 00:00:03.500
[calm music]

00:00:04.000 --> 00:00:07.200
OLENA: Hi! Let me show you how to create your first project.
🔧 Розбір прикладу: kind=captions позначає саме субтитри для глухих, з описом звуків. Доріжку kind=descriptions браузери зазвичай не озвучують самі — на практиці аудіоопис частіше роблять окремою версією відео. Транскрипт у <details> не займає місця, але доступний усім, у тому числі на брайлівському дисплеї.
🔧 Example breakdown: kind=captions marks captions for Deaf users, with sounds described. Browsers usually do not voice a kind=descriptions track themselves — in practice audio description is more often a separate version of the video. A transcript in <details> takes no space but is available to everyone, including on a braille display.
💡 Простими словами: Субтитри — для тих, хто не чує; аудіоопис — для тих, хто не бачить; транскрипт — для всіх, зокрема тих, хто не бачить і не чує.
💡 In plain words: Captions are for people who cannot hear, audio description for people who cannot see, and a transcript is for everyone, including people who can do neither.
🗣 Як читати вголос Субтитри — це синхронізований текст мовлення плюс важливі звуки і хто говорить, вони для глухих людей. Аудіоопис озвучує те, що видно на екрані і не зрозуміло зі звуку, він для незрячих. Транскрипт — повний текст поруч з відео, і він єдиний варіант для людей зі сліпоглухотою, які читають брайлем. У тестуванні я перевіряю не лише наявність субтитрів, а й їхню точність і синхронізацію.
🗣 How to say it out loud Captions are synchronised text of the speech plus important sounds and who is speaking; they are for Deaf people. Audio description voices what is visible on screen and not clear from the sound; it is for blind people. A transcript is the full text next to the video, and it is the only option for deafblind people who read braille. When testing I check not just that captions exist, but that they are accurate and in sync.
▶ 7 6
41 Що таке скрінрідер і з якими комбінаціями скрінрідера й браузера варто тестувати? What is a screen reader, and which screen reader and browser combinations should you test with?
Скрінрідери і мобільніScreen readers and mobile Junior ▶

Що це. Скрінрідер — програма, що перетворює інтерфейс на мовлення або брайль. Він читає не пікселі, а дерево доступності: роль, ім'я, стан, значення кожного елемента. Користувач керує ним з клавіатури (десктоп) чи жестами (мобільні) і рухається по заголовках, посиланнях, полях, орієнтирах, а не лише зверху вниз. Досвідчені користувачі слухають мовлення на дуже високій швидкості.

What it is. A screen reader is software that turns the interface into speech or braille. It reads the accessibility tree rather than pixels: the role, name, state and value of each element. The user drives it from the keyboard (desktop) or with gestures (mobile) and moves by headings, links, fields and landmarks, not just top to bottom. Experienced users listen at very high speech rates.

Основні. NVDA — безкоштовний, Windows, з відкритим кодом. JAWS — комерційний, Windows, поширений у корпоративному середовищі. VoiceOver — вбудований у macOS та iOS. TalkBack — вбудований в Android. Narrator — вбудований у Windows. За опитуваннями WebAIM на десктопі лідирують NVDA і JAWS, а на мобільних — VoiceOver на iOS.

The main ones. NVDA — free, Windows, open source. JAWS — commercial, Windows, common in corporate settings. VoiceOver — built into macOS and iOS. TalkBack — built into Android. Narrator — built into Windows. In WebAIM surveys NVDA and JAWS lead on desktop, and VoiceOver on iOS leads on mobile.

Комбінації. Скрінрідери оптимізовані під конкретні браузери, тому тестують пари: NVDA + Chrome або Firefox, JAWS + Chrome, VoiceOver + Safari на macOS та iOS, TalkBack + Chrome на Android. VoiceOver з Chrome на Mac — не репрезентативна пара.

Combinations. Screen readers are tuned to specific browsers, so pairs are tested: NVDA + Chrome or Firefox, JAWS + Chrome, VoiceOver + Safari on macOS and iOS, TalkBack + Chrome on Android. VoiceOver with Chrome on a Mac is not a representative pair.

Як обрати мінімум. Дивлюсь аналітику продукту (ОС і браузери) і беру 2–3 пари: зазвичай NVDA + Chrome на кожен реліз критичних сценаріїв, VoiceOver + Safari на iOS для мобільного вебу, а JAWS — для B2B чи держсектору, де він стандарт.

Choosing a minimum. I look at product analytics (OS and browsers) and pick 2–3 pairs: usually NVDA + Chrome on every release for critical flows, VoiceOver + Safari on iOS for mobile web, and JAWS for B2B or public sector, where it is the standard.

Помилка. Вважати, що якщо працює в одному скрінрідері, працює скрізь. Підтримка ARIA відрізняється, і різниця між NVDA і VoiceOver — нормальна причина окремого бага.

The mistake. Assuming that if it works in one screen reader, it works everywhere. ARIA support differs, and a difference between NVDA and VoiceOver is a legitimate reason for a separate bug.

Матриця підтримки для тест-плану
Support matrix for a test plan
# Пара                     Коли                    Сценарії
NVDA + Chrome (Windows)    кожен реліз             вхід, пошук, кошик, оплата
VoiceOver + Safari (iOS)   кожен реліз             мобільний чекаут, меню, модалки
TalkBack + Chrome (Android) раз на спринт          мобільний чекаут
VoiceOver + Safari (macOS) нові компоненти         дизайн-система, віджети
JAWS + Chrome (Windows)    перед великим релізом   B2B-кабінет, звіти, таблиці

# Фіксуй версії: «NVDA 2025.x, Chrome 14x» — поведінка змінюється між релізами
# Pair                     When                    Flows
NVDA + Chrome (Windows)    every release           sign-in, search, cart, payment
VoiceOver + Safari (iOS)   every release           mobile checkout, menu, modals
TalkBack + Chrome (Android) once per sprint        mobile checkout
VoiceOver + Safari (macOS) new components          design system, widgets
JAWS + Chrome (Windows)    before a major release  B2B dashboard, reports, tables

# Record versions: «NVDA 2025.x, Chrome 14x» — behaviour changes between releases
🔧 Розбір прикладу: Матриця поєднує пару «скрінрідер + браузер», частоту і сценарії. Частота залежить від ризику: критичні сценарії — щорелізу на найпопулярнішій парі, рідші пари — перед великими релізами. Версії обов'язково йдуть у баг-репорт.
🔧 Example breakdown: The matrix combines the «screen reader + browser» pair, the frequency and the flows. Frequency follows risk: critical flows every release on the most popular pair, rarer pairs before major releases. Versions always go into the bug report.
💡 Простими словами: Скрінрідер «читає» дерево доступності вголос, і кожен працює найкраще зі своїм браузером.
💡 In plain words: A screen reader reads the accessibility tree aloud, and each one works best with its own browser.
🗣 Як читати вголос Скрінрідер перетворює інтерфейс на мовлення чи брайль і читає дерево доступності, а не екран. Основні — NVDA і JAWS на Windows, VoiceOver на Apple і TalkBack на Android. Я тестую парами зі «своїм» браузером: NVDA з Chrome, VoiceOver з Safari. Мінімальний набір обираю за аналітикою, і критичні сценарії проходжу на найпопулярнішій парі кожен реліз, а версії завжди пишу в баг.
🗣 How to say it out loud A screen reader turns the interface into speech or braille and reads the accessibility tree, not the screen. The main ones are NVDA and JAWS on Windows, VoiceOver on Apple devices and TalkBack on Android. I test in pairs with the «native» browser: NVDA with Chrome, VoiceOver with Safari. I choose the minimum set from analytics, run critical flows on the most popular pair every release, and always put versions in the bug.
42 Які базові команди NVDA і JAWS потрібні тестувальнику і як виглядає сесія перевірки? Which basic NVDA and JAWS commands does a tester need, and what does a checking session look like?
Скрінрідери і мобільніScreen readers and mobile Middle ▶

Режими. У браузері NVDA і JAWS мають режим перегляду (browse/virtual cursor): стрілки читають текст, а літери — швидкі клавіші навігації. У полях вводу вони переходять у режим фокусу (forms mode), де літери вводяться як текст. NVDA перемикає режими вручну через NVDA+Space. Типовий баг, який тут видно: кастомний віджет, що не перемикає режим, і людина не може в ньому друкувати або керувати стрілками.

Modes. In a browser NVDA and JAWS have a browse mode (virtual cursor): arrows read text and letters are quick navigation keys. In input fields they switch to focus mode (forms mode), where letters are typed as text. NVDA toggles modes manually with NVDA+Space. A typical bug you see here: a custom widget that does not switch modes, so the person cannot type in it or use the arrow keys.

NVDA (клавіша NVDA — Insert). Ctrl — зупинити мовлення. H / Shift+H — наступний / попередній заголовок, 1–6 — заголовок рівня. D — орієнтир. K — посилання, B — кнопка, F — поле форми, T — таблиця, L — список, G — графіка. Ctrl+Alt+стрілки — рух по клітинках таблиці. NVDA+F7 — список елементів (заголовки, посилання, орієнтири). NVDA+↓ — читати все. Speech Viewer (меню NVDA → Інструменти) показує текстом усе, що сказано, — зручно для скриншотів у баг.

NVDA (the NVDA key is Insert). Ctrl stops speech. H / Shift+H — next / previous heading, 1–6 — heading of that level. D — landmark. K — link, B — button, F — form field, T — table, L — list, G — graphic. Ctrl+Alt+arrows — move through table cells. NVDA+F7 — elements list (headings, links, landmarks). NVDA+Down arrow — say all. Speech Viewer (NVDA menu → Tools) shows everything spoken as text — handy for screenshots in a bug.

JAWS (клавіша JAWS — Insert). H, T, F, B — як у NVDA; R — наступний регіон/орієнтир. Insert+F6 — список заголовків, Insert+F7 — список посилань, Insert+F5 — список полів форми. Enter на полі — режим форм.

JAWS (the JAWS key is Insert). H, T, F, B work as in NVDA; R — next region/landmark. Insert+F6 — headings list, Insert+F7 — links list, Insert+F5 — form fields list. Enter on a field — forms mode.

Сесія. 1) Заголовок вкладки (NVDA+T). 2) Список заголовків — чи складається зміст. 3) Орієнтири (D). 4) Tab по сценарію: що оголошується на кожному елементі — роль, ім'я, стан. 5) Дія з динамікою (додати в кошик) — чи є оголошення. 6) Помилка форми — чи чути її.

A session. 1) Tab title (NVDA+T). 2) Heading list — does it read as a table of contents. 3) Landmarks (D). 4) Tab through the flow: what is announced on each element — role, name, state. 5) A dynamic action (add to cart) — is there an announcement. 6) A form error — can you hear it.

Шпаргалка NVDA / JAWS для тестувальника
NVDA / JAWS cheat sheet for testers
Дія                          NVDA              JAWS
Зупинити мовлення            Ctrl              Ctrl
Наступний заголовок          H (1-6 рівень)    H (1-6 рівень)
Список заголовків            Insert+F7         Insert+F6
Наступний орієнтир           D                 R
Наступне поле / кнопка       F / B             F / B
Таблиця, рух по клітинках    T, Ctrl+Alt+стр.  T, Ctrl+Alt+стр.
Список посилань              Insert+F7         Insert+F7
Режим перегляду/фокусу       Insert+Space      Enter / Num Plus
Читати все                   Insert+стр. вниз  Insert+стр. вниз
Заголовок вікна              Insert+T          Insert+T
Лог мовлення для бага        Speech Viewer     (запис екрана зі звуком)
Action                       NVDA              JAWS
Stop speech                  Ctrl              Ctrl
Next heading                 H (1-6 level)     H (1-6 level)
Headings list                Insert+F7         Insert+F6
Next landmark                D                 R
Next field / button          F / B             F / B
Table, move through cells    T, Ctrl+Alt+arr.  T, Ctrl+Alt+arr.
Links list                   Insert+F7         Insert+F7
Browse / focus mode          Insert+Space      Enter / Num Plus
Say all                      Insert+Down arrow Insert+Down arrow
Window title                 Insert+T          Insert+T
Speech log for a bug         Speech Viewer     (screen recording with sound)
🔧 Розбір прикладу: Однакові літери для швидкої навігації (H, F, B, T) у двох скрінрідерах спрощують навчання, а головні відмінності — клавіша орієнтирів (D проти R) і списки елементів. Shift з будь-якою літерою рухає назад. На ноутбуці без Insert обидва дозволяють обрати розкладку з CapsLock як модифікатором.
🔧 Example breakdown: The shared quick-navigation letters (H, F, B, T) in both screen readers make learning easier; the main differences are the landmark key (D versus R) and the element lists. Shift with any letter moves backwards. On a laptop without Insert both allow a layout that uses CapsLock as the modifier.
💡 Простими словами: Кілька літер — H, D, F, B, T — і Tab дають 90 % того, що потрібно для перевірки сторінки скрінрідером.
💡 In plain words: A few letters — H, D, F, B, T — plus Tab give you 90 % of what you need to check a page with a screen reader.
🗣 Як читати вголос У NVDA я користуюся невеликим набором: Ctrl зупиняє мовлення, H стрибає по заголовках, D по орієнтирах, F по полях, B по кнопках, T до таблиці, а Insert+F7 відкриває список елементів. Важливо розуміти два режими, перегляду і фокусу, бо багато багів кастомних віджетів саме там. Для бага вмикаю Speech Viewer, щоб показати, що саме прозвучало. У JAWS усе схоже, лише орієнтири на R.
🗣 How to say it out loud In NVDA I use a small set: Ctrl stops speech, H jumps through headings, D through landmarks, F through fields, B through buttons, T to a table, and Insert+F7 opens the elements list. It is important to understand the two modes, browse and focus, because many custom widget bugs live there. For a bug I turn on Speech Viewer to show exactly what was said. JAWS is similar, only landmarks are on R.
43 Які базові жести й команди VoiceOver і TalkBack потрібні для тестування? Which basic VoiceOver and TalkBack gestures and commands do you need for testing?
Скрінрідери і мобільніScreen readers and mobile Middle ▶

VoiceOver на macOS. Увімкнення — Cmd+F5. Модифікатор VO = Control+Option. VO+→ / VO+← — наступний / попередній елемент. VO+Space — активувати. VO+U — ротор: списки заголовків, посилань, орієнтирів, полів, між якими перемикаються стрілками. VO+Cmd+H — наступний заголовок. VO+Shift+↓ — зайти в групу, VO+Shift+↑ — вийти. Тестувати з Safari.

VoiceOver on macOS. Toggle with Cmd+F5. The VO modifier is Control+Option. VO+Right / VO+Left — next / previous element. VO+Space — activate. VO+U — the rotor: lists of headings, links, landmarks and form controls, switched with the arrow keys. VO+Cmd+H — next heading. VO+Shift+Down — interact with a group, VO+Shift+Up — stop interacting. Test with Safari.

VoiceOver на iOS. Увімкнення — Налаштування → Доступність → VoiceOver або потрійне натискання бічної кнопки, якщо налаштовано швидку команду (варто зробити до початку, бо з увімкненим VoiceOver керувати телефоном складніше). Свайп вправо / вліво — наступний / попередній елемент. Подвійний тап — активувати. Ротор — обертання двома пальцями, потім свайп вгору/вниз рухає по вибраному типу (заголовки, посилання). Свайп двома пальцями вгору — читати все. Прокрутка — три пальці. Жест «Z» двома пальцями — назад / закрити.

VoiceOver on iOS. Turn on via Settings → Accessibility → VoiceOver, or triple-click the side button if the shortcut is set up (worth doing first, because the phone is harder to operate once VoiceOver is on). Swipe right / left — next / previous element. Double tap — activate. The rotor — rotate two fingers, then swipe up/down to move by the chosen type (headings, links). Two-finger swipe up — read all. Scrolling — three fingers. A two-finger «Z» scrub — back / dismiss.

TalkBack на Android. Увімкнення — налаштування доступності або утримання обох клавіш гучності кілька секунд, якщо ярлик увімкнено. Свайп вправо / вліво — наступний / попередній елемент, подвійний тап — активувати, прокрутка — два пальці. Меню TalkBack і керування деталізацією читання (заголовки, посилання, символи) відкриваються жестами, які відрізняються між версіями, тож перевір їх у підручнику TalkBack на своєму пристрої.

TalkBack on Android. Turn on in accessibility settings, or hold both volume keys for a few seconds if the shortcut is enabled. Swipe right / left — next / previous element, double tap — activate, scroll — two fingers. The TalkBack menu and reading granularity controls (headings, links, characters) are opened with gestures that differ between versions, so check them in the TalkBack tutorial on your device.

Що перевіряю. Порядок свайпів логічний. У кожного елемента зрозуміле ім'я і роль. Декоративне пропускається. Модалка не дає свайпнути на фон. Кастомні жести застосунку мають альтернативу.

What I check. The swipe order is logical. Every element has a clear name and role. Decorative items are skipped. A modal does not let you swipe to the background. The app's custom gestures have an alternative.

Чекліст сесії на телефоні (VoiceOver / TalkBack)
Phone session checklist (VoiceOver / TalkBack)
Підготовка
  [ ] швидка команда увімкнення налаштована ДО старту
  [ ] швидкість мовлення знижена, мовлення не вимкнене «шторкою»
Проходження екрану (свайп вправо від початку до кінця)
  [ ] порядок читання = візуальний порядок
  [ ] кожна кнопка: «Назва, кнопка», а не «кнопка» чи ім'я файлу
  [ ] стан перемикачів звучить: «увімкнено» / «вимкнено»
  [ ] декоративні іконки пропускаються
Ротор / деталізація
  [ ] заголовки знаходяться, їх порядок складається у зміст
Дії
  [ ] подвійний тап активує; помилка форми оголошується
  [ ] модалка: свайпом не вийти на фон, закриття повертає фокус
  [ ] жест «назад» (Z двома пальцями / системний) закриває модалку
Preparation
  [ ] the toggle shortcut is set up BEFORE starting
  [ ] speech rate lowered, speech not muted by screen curtain
Walk the screen (swipe right from start to end)
  [ ] reading order = visual order
  [ ] every button: «Name, button», not «button» or a file name
  [ ] toggle state is spoken: «on» / «off»
  [ ] decorative icons are skipped
Rotor / granularity
  [ ] headings are found and read as a table of contents
Actions
  [ ] double tap activates; a form error is announced
  [ ] modal: cannot swipe out to the background, closing returns focus
  [ ] the back gesture (two-finger Z / system) dismisses the modal
🔧 Розбір прикладу: Чекліст іде в порядку реальної сесії: спершу безпека (як вимкнути), потім лінійне проходження свайпами, потім швидка навігація, потім дії. Пункт про «шторку» (Screen Curtain в iOS вимикає екран) — щоб не злякатися чорного екрана посеред тесту.
🔧 Example breakdown: The checklist follows a real session: safety first (how to turn it off), then a linear swipe pass, then quick navigation, then actions. The «screen curtain» item (Screen Curtain on iOS blanks the display) is there so a black screen mid-test does not scare you.
💡 Простими словами: На телефоні скрінрідер керується свайпом вліво-вправо і подвійним тапом; усе інше — ротор і меню для швидкої навігації.
💡 In plain words: On a phone the screen reader runs on left-right swipes and double taps; everything else is the rotor and menus for quick navigation.
🗣 Як читати вголос На Mac VoiceOver вмикається Cmd+F5, модифікатор Control+Option, а ротор VO+U дає списки заголовків і посилань. На iPhone я свайпаю вправо-вліво по елементах, активую подвійним тапом, а ротор крутиться двома пальцями. TalkBack на Android працює так само по свайпах і подвійному тапу. Перед сесією я завжди налаштовую швидке вимкнення. І перевіряю порядок читання, імена кнопок і поведінку модалок.
🗣 How to say it out loud On a Mac VoiceOver toggles with Cmd+F5, the modifier is Control+Option, and the rotor on VO+U gives lists of headings and links. On an iPhone I swipe right and left through elements, activate with a double tap, and turn the rotor with two fingers. TalkBack on Android works the same with swipes and double taps. Before a session I always set up a quick way to turn it off. And I check reading order, button names and how modals behave.
44 Що перевіряти в мобільній доступності і який мінімальний розмір цілі дотику? What do you check in mobile accessibility, and what is the minimum touch target size?
Скрінрідери і мобільніScreen readers and mobile Middle ▶

Розмір цілей. WCAG 2.5.8 Target Size (Minimum), AA: щонайменше 24×24 CSS px або достатній відступ, щоб коло діаметром 24 px навколо цілі не перетинало інших; винятки — посилання в тексті, елементи, розмір яких визначає браузер, еквівалентна більша ціль поруч. 2.5.5 Target Size (Enhanced), AAA — 44×44. Платформні настанови суворіші: Apple — 44×44 pt, Material Design — 48×48 dp. Маленькі хрестики закриття і тісні іконки в тулбарі — найчастіший провал; страждають люди з тремором, літні, будь-хто в транспорті.

Target size. WCAG 2.5.8 Target Size (Minimum), AA: at least 24×24 CSS px, or enough spacing that a 24 px circle around the target does not overlap others; exceptions are links in text, browser-sized elements and an equivalent larger target nearby. 2.5.5 Target Size (Enhanced), AAA is 44×44. Platform guidelines are stricter: Apple says 44×44 pt, Material Design 48×48 dp. Small close crosses and cramped toolbar icons are the most common failure; people with tremors, older people and anyone on a bumpy bus suffer.

Інші мобільні критерії. 1.3.4 Orientation (AA): не блокувати портретну чи альбомну орієнтацію без потреби (планшет на візку закріплений горизонтально). 2.5.1 Pointer Gestures (A): жести кількома пальцями чи по траєкторії (свайп карусель, pinch) мають альтернативу одним дотиком. 2.5.4 Motion Actuation (A): «потрусити, щоб скасувати» має кнопку-альтернативу і вимикається. 1.4.4 і системне збільшення шрифту: iOS Dynamic Type, Android font scale 200 % не повинні обрізати тексти.

Other mobile criteria. 1.3.4 Orientation (AA): do not lock portrait or landscape without need (a tablet mounted sideways on a wheelchair). 2.5.1 Pointer Gestures (A): multi-finger or path-based gestures (swipe carousels, pinch) have a single-tap alternative. 2.5.4 Motion Actuation (A): «shake to undo» has a button alternative and can be turned off. 1.4.4 and system font scaling: iOS Dynamic Type and Android font scale at 200 % must not clip text.

Нативні застосунки. Ім'я в iOS — accessibilityLabel, роль — traits; в Android — contentDescription, importantForAccessibility. Інструменти: Xcode Accessibility Inspector, Android Accessibility Scanner, перевірки в Espresso; в Appium доступне ім'я часто і є локатором accessibility id — зручна синергія автотестів і a11y.

Native apps. On iOS the name is accessibilityLabel and the role is traits; on Android it is contentDescription and importantForAccessibility. Tools: Xcode Accessibility Inspector, Android Accessibility Scanner, Espresso checks; in Appium the accessible name is often the accessibility id locator — a nice synergy between automation and a11y.

Мобільний веб. Перевірка на реальному пристрої з VoiceOver і TalkBack, плюс автоматичний пошук малих цілей на емуляції телефона.

Mobile web. Checks on a real device with VoiceOver and TalkBack, plus an automated hunt for small targets on phone emulation.

Пошук цілей, менших за 24×24 CSS px, на емуляції телефона
Finding targets smaller than 24×24 CSS px on phone emulation
import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['iPhone 13'] });

test('цілі дотику не менші за 24x24 (WCAG 2.5.8)', async ({ page }) => {
  await page.goto('/product/42');
  const small = await page.evaluate(() => {
    const sel = 'a, button, input, select, textarea, [role=button], [role=link], [tabindex]';
    return [...document.querySelectorAll(sel)]
      .filter((el) => el.offsetParent !== null && !el.closest('p'))  // посилання в тексті — виняток
      .map((el) => ({ el, r: el.getBoundingClientRect() }))
      .filter(({ r }) => r.width < 24 || r.height < 24)
      .map(({ el, r }) => (el.getAttribute('aria-label') || el.textContent.trim()) +
        ' ' + Math.round(r.width) + 'x' + Math.round(r.height));
  });
  // кандидати на провал: далі вручну перевірити виняток за відступом
  expect(small).toEqual([]);
});
import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['iPhone 13'] });

test('touch targets are at least 24x24 (WCAG 2.5.8)', async ({ page }) => {
  await page.goto('/product/42');
  const small = await page.evaluate(() => {
    const sel = 'a, button, input, select, textarea, [role=button], [role=link], [tabindex]';
    return [...document.querySelectorAll(sel)]
      .filter((el) => el.offsetParent !== null && !el.closest('p'))  // links in text are exempt
      .map((el) => ({ el, r: el.getBoundingClientRect() }))
      .filter(({ r }) => r.width < 24 || r.height < 24)
      .map(({ el, r }) => (el.getAttribute('aria-label') || el.textContent.trim()) +
        ' ' + Math.round(r.width) + 'x' + Math.round(r.height));
  });
  // failure candidates: then check the spacing exception by hand
  expect(small).toEqual([]);
});
🔧 Розбір прикладу: Скрипт бере всі видимі інтерактивні елементи (offsetParent відсікає приховані), пропускає посилання в абзацах і повертає ім'я та розмір тих, що менші за 24 px. Це список кандидатів: виняток за відступом автоматично не перевіряється. axe має правило target-size з тегом wcag22aa, яке враховує і відступи.
🔧 Example breakdown: The script takes all visible interactive elements (offsetParent filters out hidden ones), skips links inside paragraphs and returns the name and size of anything under 24 px. It is a candidate list: the spacing exception is not checked automatically. axe has a target-size rule tagged wcag22aa that also accounts for spacing.
💡 Простими словами: На телефоні палець — не курсор: цілі мають бути досить великими, а кожен складний жест — мати просту альтернативу.
💡 In plain words: On a phone a finger is not a cursor: targets must be big enough, and every complex gesture needs a simple alternative.
🗣 Як читати вголос Мінімальний розмір цілі за WCAG 2.2 — двадцять чотири на двадцять чотири CSS пікселі або достатній відступ, а платформи рекомендують більше: сорок чотири пункти в Apple і сорок вісім dp в Android. Крім цього, я перевіряю, що орієнтацію не заблоковано, складні жести мають альтернативу одним дотиком, а текст не обрізається при системному збільшенні шрифту. Малі цілі шукаю скриптом на емуляції телефона або правилом target-size в axe.
🗣 How to say it out loud The minimum target size in WCAG 2.2 is twenty-four by twenty-four CSS pixels or enough spacing, and the platforms recommend more: forty-four points at Apple and forty-eight dp on Android. Beyond that I check that orientation is not locked, complex gestures have a single-tap alternative, and text is not clipped with system font scaling. I find small targets with a script on phone emulation or with the axe target-size rule.
45 Як перевірити доступність PDF та інших документів, які генерує продукт? How do you check the accessibility of PDFs and other documents the product generates?
Скрінрідери і мобільніScreen readers and mobile Middle ▶

Чому це частина продукту. Рахунки, виписки, договори, квитки, звіти, які продукт генерує, — теж інтерфейс. EAA і EN 301 549 охоплюють документи, а WCAG застосовують до них через WCAG2ICT. Незрячий клієнт банку, що отримав виписку-картинку, не може її прочитати.

Why it is part of the product. Invoices, statements, contracts, tickets and reports the product generates are interface too. The EAA and EN 301 549 cover documents, and WCAG is applied to them through WCAG2ICT. A blind bank customer who receives a statement as an image cannot read it.

Що робить PDF доступним. 1) Теги (tagged PDF): структура заголовків, абзаців, списків, таблиць, як у HTML; стандарт — PDF/UA (ISO 14289). 2) Логічний порядок читання, що не залежить від розташування блоків на сторінці. 3) Alt-текст для зображень і графіків, декоративне позначено як артефакт. 4) Таблиці з заголовками. 5) Мова документа і назва, що показується в заголовку вікна замість імені файлу. 6) Справжній текст, а не скан (без OCR скан — це одна велика картинка). 7) Контраст і кольори за тими ж правилами. 8) Поля форм з назвами і правильним порядком Tab. 9) Закладки для довгих документів.

What makes a PDF accessible. 1) Tags (tagged PDF): a structure of headings, paragraphs, lists and tables, as in HTML; the standard is PDF/UA (ISO 14289). 2) A logical reading order independent of block positions on the page. 3) Alt text for images and charts, with decoration marked as artefacts. 4) Tables with headers. 5) The document language, and a title shown in the window title bar instead of the file name. 6) Real text, not a scan (without OCR a scan is one big image). 7) Contrast and colour under the same rules. 8) Form fields with names and a correct Tab order. 9) Bookmarks for long documents.

Як тестую. PAC (PDF Accessibility Checker) — безкоштовна перевірка PDF/UA і WCAG. Acrobat Pro — Accessibility Checker і панель порядку читання. NVDA — чи читається документ осмислено. Для згенерованих документів перевіряю шаблон і генератор: одне виправлення в шаблоні лікує тисячі файлів, тож автоматична перевірка тегів у CI дешевша за ручну перевірку кожного файлу.

How I test. PAC (PDF Accessibility Checker) — a free PDF/UA and WCAG check. Acrobat Pro — the Accessibility Checker and the reading order panel. NVDA — does the document read in a meaningful way. For generated documents I test the template and the generator: one fix in the template heals thousands of files, so an automated tag check in CI is cheaper than checking every file by hand.

Альтернатива. Якщо PDF неможливо зробити доступним швидко, можна дати доступну HTML-версію того самого вмісту.

Alternative. If a PDF cannot be made accessible quickly, an accessible HTML version of the same content is an option.

Швидка перевірка згенерованого PDF у CI (Python, pikepdf)
Quick check of a generated PDF in CI (Python, pikepdf)
import pikepdf

def check_pdf(path):
    problems = []
    with pikepdf.open(path) as pdf:
        root = pdf.Root
        mark = root.get('/MarkInfo')
        if mark is None or not mark.get('/Marked', False):
            problems.append('PDF не позначено як tagged (/MarkInfo /Marked)')
        if '/StructTreeRoot' not in root:
            problems.append('немає дерева структури (/StructTreeRoot)')
        if '/Lang' not in root:
            problems.append('не вказано мову документа (/Lang)')
        prefs = root.get('/ViewerPreferences')
        if prefs is None or not prefs.get('/DisplayDocTitle', False):
            problems.append('у заголовку вікна буде ім\'я файлу, а не назва')
    return problems

# приклад: assert check_pdf('out/invoice-1042.pdf') == []
import pikepdf

def check_pdf(path):
    problems = []
    with pikepdf.open(path) as pdf:
        root = pdf.Root
        mark = root.get('/MarkInfo')
        if mark is None or not mark.get('/Marked', False):
            problems.append('PDF is not marked as tagged (/MarkInfo /Marked)')
        if '/StructTreeRoot' not in root:
            problems.append('no structure tree (/StructTreeRoot)')
        if '/Lang' not in root:
            problems.append('document language is not set (/Lang)')
        prefs = root.get('/ViewerPreferences')
        if prefs is None or not prefs.get('/DisplayDocTitle', False):
            problems.append('the window title will show the file name, not the title')
    return problems

# example: assert check_pdf('out/invoice-1042.pdf') == []
🔧 Розбір прикладу: Скрипт перевіряє чотири ознаки в каталозі документа: позначку tagged, наявність дерева структури, мову і налаштування показу назви. Це «димова» перевірка шаблону: вона не скаже, чи правильний порядок читання й alt-тексти, — для цього PAC і прослуховування в NVDA.
🔧 Example breakdown: The script checks four markers in the document catalogue: the tagged flag, a structure tree, the language and the setting to show the title. It is a smoke check for the template: it cannot tell whether reading order and alt texts are right — that takes PAC and a listen in NVDA.
💡 Простими словами: Доступний PDF — це PDF з тегами, мовою, назвою, alt-текстами і справжнім текстом замість скану.
💡 In plain words: An accessible PDF has tags, a language, a title, alt texts and real text instead of a scan.
🗣 Як читати вголос Документи, які генерує продукт, як рахунки чи виписки, — теж частина інтерфейсу, і закон EAA їх охоплює. Доступний PDF має теги зі структурою, логічний порядок читання, alt для зображень, мову і назву, і в ньому справжній текст, а не скан. Я перевіряю PAC-ом і NVDA, а для згенерованих документів додаю в CI швидку перевірку шаблону, бо одна правка в шаблоні лікує всі файли.
🗣 How to say it out loud Documents the product generates, like invoices or statements, are part of the interface too, and the EAA covers them. An accessible PDF has tags with structure, a logical reading order, alt for images, a language and a title, and it contains real text rather than a scan. I check with PAC and NVDA, and for generated documents I add a quick template check to CI, because one template fix heals every file.
46 Що таке VPAT і ACR і яка роль QA у їх підготовці? What are a VPAT and an ACR, and what is QA's role in preparing them?
Скрінрідери і мобільніScreen readers and mobile Senior ▶

Що це. VPAT (Voluntary Product Accessibility Template) — шаблон від ITI (Information Technology Industry Council) для опису відповідності продукту стандартам доступності. Заповнений VPAT називається ACR (Accessibility Conformance Report). Редакції шаблону: WCAG, 508 (США), EU (EN 301 549) та INT, що поєднує всі. Замовники в держсекторі, освіті й великому бізнесі вимагають ACR у тендерах і закупівлях.

What it is. A VPAT (Voluntary Product Accessibility Template) is a template from ITI (the Information Technology Industry Council) for describing how a product conforms to accessibility standards. A completed VPAT is called an ACR (Accessibility Conformance Report). Template editions: WCAG, 508 (US), EU (EN 301 549) and INT, which combines all of them. Customers in the public sector, education and big business require an ACR in tenders and procurement.

Структура. Для кожного критерію: рівень відповідності — Supports, Partially Supports, Does Not Support, Not Applicable (і Not Evaluated лише для AAA) — та «Remarks and Explanations». Плюс опис продукту, версія, дата, методи оцінки.

Structure. For each criterion: a conformance level — Supports, Partially Supports, Does Not Support, Not Applicable (and Not Evaluated for AAA only) — plus «Remarks and Explanations». Also a product description, version, date and evaluation methods.

Роль QA. 1) План аудиту: які сценарії й сторінки репрезентують продукт, які інструменти й пари скрінрідерів. 2) Докази по кожному критерію: результати axe, ручні перевірки, записи сесій. 3) Чесні формулювання: «Partially Supports — у редакторі звітів таблиці не мають заголовків рядків; виправлення в Q3». 4) Оновлення ACR з кожним великим релізом і регресійний контроль, щоб звіт не застарів.

QA's role. 1) The audit plan: which flows and pages represent the product, which tools and screen reader pairs. 2) Evidence per criterion: axe results, manual checks, session recordings. 3) Honest wording: «Partially Supports — in the report editor, tables have no row headers; fix planned for Q3». 4) Updating the ACR with every major release, plus regression control so the report does not go stale.

Ризики. ACR — юридично значущий документ, і «Supports» скрізь без тестування — це введення в оману, яке може обернутися позовом чи втратою контракту. Звіт від незалежного аудитора має більшу вагу, але внутрішній QA все одно дає йому дані і перевіряє виправлення.

Risks. An ACR is a legally significant document, and «Supports» everywhere without testing is misrepresentation that can lead to a lawsuit or a lost contract. A report from an independent auditor carries more weight, but internal QA still feeds it data and verifies the fixes.

Типова помилка. Писати Not Applicable там, де функції просто не тестували, — наприклад, для 1.2.x, коли в продукті є навчальні відео.

The typical mistake. Writing Not Applicable where a feature simply was not tested — for example for 1.2.x when the product has training videos.

Фрагмент ACR (редакція WCAG 2.2, рівень AA)
ACR excerpt (WCAG 2.2 edition, level AA)
Продукт: Valtive Billing Web, версія 4.12   Дата: 2026-09-15
Методи: axe-core 4.x у CI, ручний аудит 14 сценаріїв,
        NVDA + Chrome, VoiceOver + Safari (iOS), масштаб 400 %

Критерій                         Рівень              Примітки
1.1.1 Non-text Content (A)       Supports            -
1.3.1 Info and Relationships (A) Partially Supports  Звіт «Витрати»: у таблиці немає
                                                     заголовків рядків. Виправлення: 4.13
1.4.3 Contrast (Minimum) (AA)    Supports            -
2.1.1 Keyboard (A)               Partially Supports  Сортування віджетів на дашборді
                                                     лише перетягуванням. Обхід: меню
                                                     «Перемістити». Виправлення: 4.14
2.5.8 Target Size (Minimum) (AA) Supports            -
1.2.2 Captions (Prerecorded) (A) Does Not Support    Навчальні відео без субтитрів
Product: Valtive Billing Web, version 4.12   Date: 2026-09-15
Methods: axe-core 4.x in CI, manual audit of 14 flows,
         NVDA + Chrome, VoiceOver + Safari (iOS), zoom 400 %

Criterion                        Level               Remarks
1.1.1 Non-text Content (A)       Supports            -
1.3.1 Info and Relationships (A) Partially Supports  «Expenses» report: the table has no
                                                     row headers. Fix: 4.13
1.4.3 Contrast (Minimum) (AA)    Supports            -
2.1.1 Keyboard (A)               Partially Supports  Dashboard widgets can be reordered
                                                     only by dragging. Workaround: «Move»
                                                     menu. Fix: 4.14
2.5.8 Target Size (Minimum) (AA) Supports            -
1.2.2 Captions (Prerecorded) (A) Does Not Support    Training videos have no captions
🔧 Розбір прикладу: У примітках завжди є що саме не працює, де, чи є обхід і коли виправлення. Такий рядок корисний і замовнику, і команді: він одночасно чесний звіт і беклог. Методи оцінки на початку показують, на чому ґрунтуються висновки.
🔧 Example breakdown: The remarks always say what does not work, where, whether there is a workaround and when the fix lands. A row like that serves both the customer and the team: it is an honest report and a backlog at once. The evaluation methods at the top show what the conclusions rest on.
💡 Простими словами: ACR — це чесна таблиця «критерій → наскільки ми відповідаємо → чому», і QA дає для неї докази.
💡 In plain words: An ACR is an honest table of «criterion → how well we conform → why», and QA supplies the evidence for it.
🗣 Як читати вголос VPAT — це шаблон від ITI, а заповнений він називається ACR, звіт про відповідність доступності. Для кожного критерію там вказано Supports, Partially Supports чи Does Not Support з поясненням. Його вимагають у тендерах, особливо в держсекторі. Моя роль як QA — спланувати аудит, зібрати докази по кожному критерію і наполягати на чесних формулюваннях, бо «Supports» без тестування — це юридичний ризик.
🗣 How to say it out loud A VPAT is a template from ITI, and once filled in it is called an ACR, an accessibility conformance report. For each criterion it states Supports, Partially Supports or Does Not Support with an explanation. Tenders require it, especially in the public sector. My role as QA is to plan the audit, gather evidence for every criterion and insist on honest wording, because «Supports» without testing is a legal risk.
▶ 8 11
47 Що таке axe-core, як він працює і де його можна запускати? What is axe-core, how does it work, and where can you run it?
Автоматизація і процесAutomation and process Junior ▶

Що це. axe-core — рушій правил доступності з відкритим кодом від компанії Deque, написаний на JavaScript. Він запускається всередині сторінки, аналізує DOM і обчислені стилі та перевіряє близько сотні правил, прив'язаних до критеріїв WCAG і best practices: color-contrast, image-alt, label, button-name, aria-valid-attr, html-has-lang, target-size тощо.

What it is. axe-core is an open-source accessibility rules engine from Deque, written in JavaScript. It runs inside the page, analyses the DOM and computed styles, and checks around a hundred rules mapped to WCAG criteria and best practices: color-contrast, image-alt, label, button-name, aria-valid-attr, html-has-lang, target-size and so on.

Результат. Чотири групи: violations (порушення), passes, incomplete (потребує ручної перевірки — наприклад, контраст тексту на картинці) і inapplicable. Кожне порушення має id правила, impact (minor, moderate, serious, critical), теги (wcag2aa, wcag22aa, best-practice), посилання на пояснення helpUrl і список вузлів з CSS-селектором і HTML. Deque позиціює axe як інструмент без хибних спрацювань: усе сумнівне йде в incomplete, а не у violations.

Output. Four groups: violations, passes, incomplete (needs manual review — for example text contrast over an image) and inapplicable. Every violation has a rule id, an impact (minor, moderate, serious, critical), tags (wcag2aa, wcag22aa, best-practice), a helpUrl explanation link and a list of nodes with a CSS selector and HTML. Deque positions axe as a tool with no false positives: anything uncertain goes to incomplete rather than violations.

Де запускати. Розширення браузера axe DevTools — для ручного аудиту. Автотести: @axe-core/playwright, cypress-axe, @axe-core/webdriverjs і бібліотеки для Selenium на Java, jest-axe для компонентів. Storybook addon-a11y запускає axe для кожної історії. Lighthouse використовує набір правил axe для своєї оцінки доступності.

Where to run it. The axe DevTools browser extension — for manual audits. Automated tests: @axe-core/playwright, cypress-axe, @axe-core/webdriverjs and Java libraries for Selenium, jest-axe for components. The Storybook addon-a11y runs axe for every story. Lighthouse uses a set of axe rules for its accessibility score.

Межі. axe бачить лише поточний стан сторінки: закрите меню, модалку чи стан помилки треба спершу відкрити. І він не оцінює сенс: alt=«зображення» формально є, а корисності нуль.

Limits. axe only sees the current page state: a closed menu, a modal or an error state has to be opened first. And it does not judge meaning: alt=«image» is formally there but useless.

Як виглядає одне порушення в результаті axe
What a single violation looks like in axe output
// results.violations[0] після new AxeBuilder({ page }).analyze()
{
  id: 'button-name',
  impact: 'critical',
  tags: ['cat.name-role-value', 'wcag2a', 'wcag412', 'section508'],
  help: 'Buttons must have discernible text',
  helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/button-name',
  nodes: [{
    target: ['.cart-summary > button.icon-close'],
    html: '<button class="icon-close"><svg>...</svg></button>',
    failureSummary: 'Fix any of the following: Element does not have inner text ...'
  }]
}
// читаємо так: критичне порушення 4.1.2 (wcag412), кнопка закриття в підсумку кошика без імені
// results.violations[0] after new AxeBuilder({ page }).analyze()
{
  id: 'button-name',
  impact: 'critical',
  tags: ['cat.name-role-value', 'wcag2a', 'wcag412', 'section508'],
  help: 'Buttons must have discernible text',
  helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/button-name',
  nodes: [{
    target: ['.cart-summary > button.icon-close'],
    html: '<button class="icon-close"><svg>...</svg></button>',
    failureSummary: 'Fix any of the following: Element does not have inner text ...'
  }]
}
// read it as: a critical 4.1.2 violation (wcag412), the close button in the cart summary has no name
🔧 Розбір прикладу: Тег wcag412 — номер критерію без крапок, wcag2a — рівень і версія, де він з'явився. target дає селектор для відтворення в DevTools, а helpUrl веде до пояснення з прикладами виправлення — його зручно вставляти в баг-репорт.
🔧 Example breakdown: The wcag412 tag is the criterion number without dots, wcag2a is the level and the version where it appeared. target gives a selector to reproduce it in DevTools, and helpUrl leads to an explanation with fix examples — handy to paste into a bug report.
💡 Простими словами: axe — це лінтер для доступності сторінки: швидкий, точний, але бачить лише те, що зараз у DOM, і не розуміє сенсу.
💡 In plain words: axe is a linter for page accessibility: fast and precise, but it only sees what is in the DOM right now and does not understand meaning.
🗣 Як читати вголос axe-core — це рушій правил доступності від Deque з відкритим кодом. Він запускається прямо в сторінці і повертає порушення з id правила, рівнем impact, тегами WCAG і селекторами вузлів, а сумнівні випадки кладе в incomplete. Я користуюся ним і як розширенням у браузері для ручного аудиту, і в Playwright-тестах. Але пам'ятаю, що він бачить лише поточний стан сторінки і не оцінює, чи текст змістовний.
🗣 How to say it out loud axe-core is Deque's open-source accessibility rules engine. It runs right in the page and returns violations with the rule id, impact, WCAG tags and node selectors, and puts uncertain cases into incomplete. I use it both as a browser extension for manual audits and in Playwright tests. But I remember it only sees the current page state and does not judge whether text is meaningful.
48 Яку частку проблем знаходять автоматичні інструменти, що вони не можуть знайти і чим відрізняються axe, Lighthouse, WAVE і pa11y? What share of issues do automated tools find, what can't they find, and how do axe, Lighthouse, WAVE and pa11y differ?
Автоматизація і процесAutomation and process Middle ▶

Цифри. Лише частину критеріїв WCAG можна перевірити автоматично повністю; зазвичай називають 30–40 % критеріїв, які машина покриває хоча б частково. Експеримент британської GDS з сторінкою, на якій навмисно зробили 142 бар'єри, показав, що найкращий інструмент знайшов близько 40 % з них. Deque, рахуючи не критерії, а кількість реальних порушень в аудитах, оцінює частку автоматики в 57 % — бо поширені проблеми на кшталт контрасту дуже часті. Обидві цифри правдиві, але висновок один: без ручної перевірки не обійтися.

The numbers. Only some WCAG criteria can be fully checked automatically; the usual figure is 30–40 % of criteria that a machine covers at least partly. The UK GDS experiment with a page carrying 142 deliberate barriers found that the best tool caught about 40 % of them. Deque, counting real violations in audits rather than criteria, puts automation's share at 57 % — because common issues such as contrast are extremely frequent. Both numbers are true, and the conclusion is the same: you cannot skip manual testing.

Що машина не бачить. Чи alt осмислений. Чи логічний порядок фокусу. Чи видно фокус (частково). Чи можна пройти сценарій клавіатурою. Чи є пастка. Чи оголошуються динамічні зміни. Чи зрозумілі тексти помилок. Чи відповідають ролі поведінці віджета. Субтитри і їх точність. Label in Name — лише частково. Стан, що з'являється після дії.

What machines miss. Whether alt is meaningful. Whether focus order is logical. Whether focus is visible (partly). Whether the flow can be completed by keyboard. Whether there is a trap. Whether dynamic changes are announced. Whether error texts are clear. Whether roles match widget behaviour. Captions and their accuracy. Label in Name — only partly. State that appears after an action.

Інструменти. axe-core — рушій для автотестів і розширення, мінімум хибних спрацювань. Lighthouse (у Chrome DevTools і як CLI) — оцінка 0–100 на базі підмножини правил axe; 100 балів не означає доступність. WAVE від WebAIM — розширення, що малює іконки просто на сторінці, зручне для навчання і показу дизайнерам. pa11y — CLI і pa11y-ci для списку URL, з рушіями HTML_CodeSniffer або axe; зручний для швидкого сканування сайту за sitemap.

The tools. axe-core — the engine for automated tests and the extension, with minimal false positives. Lighthouse (in Chrome DevTools and as a CLI) — a 0–100 score based on a subset of axe rules; a score of 100 does not mean accessible. WAVE from WebAIM — an extension that draws icons right on the page, good for learning and showing designers. pa11y — a CLI plus pa11y-ci for a list of URLs, with the HTML_CodeSniffer or axe runner; handy for a quick site-wide scan from a sitemap.

Висновок. Автоматика — сітка безпеки від регресій, а не доказ відповідності.

Takeaway. Automation is a safety net against regressions, not proof of conformance.

Три інструменти з командного рядка
Three tools from the command line
# Lighthouse: лише категорія доступності, звіт у JSON
npx lighthouse https://staging.shop.test/cart --only-categories=accessibility \
  --output=json --output-path=./lighthouse-a11y.json --chrome-flags="--headless"

# pa11y з рушієм axe і рівнем WCAG AA для однієї сторінки
npx pa11y --runner axe --standard WCAG2AA https://staging.shop.test/cart

# pa11y-ci по всіх URL із sitemap, падає, якщо є помилки
npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml

# жоден з них не пройде за вас сценарій клавіатурою і не послухає NVDA
# Lighthouse: accessibility category only, JSON report
npx lighthouse https://staging.shop.test/cart --only-categories=accessibility \
  --output=json --output-path=./lighthouse-a11y.json --chrome-flags="--headless"

# pa11y with the axe runner and WCAG AA level for a single page
npx pa11y --runner axe --standard WCAG2AA https://staging.shop.test/cart

# pa11y-ci over every URL in the sitemap, fails when there are errors
npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml

# none of them will walk the flow by keyboard or listen with NVDA for you
🔧 Розбір прикладу: Lighthouse корисний для тренду й швидкого погляду, pa11y-ci — для широкого покриття сторінок, а axe в Playwright — для перевірки станів усередині сценарію (відкрита модалка, помилка форми). Останній коментар — головна думка картки.
🔧 Example breakdown: Lighthouse is useful for a trend and a quick look, pa11y-ci for broad page coverage, and axe in Playwright for checking states inside a flow (an open modal, a form error). The last comment is the main point of the card.
💡 Простими словами: Інструменти знаходять приблизно третину проблем за кількістю критеріїв; решта — на людині з клавіатурою і скрінрідером.
💡 In plain words: Tools find roughly a third of problems by number of criteria; the rest is down to a person with a keyboard and a screen reader.
🗣 Як читати вголос Автоматичні інструменти покривають приблизно тридцять–сорок відсотків критеріїв WCAG. У Deque рахують інакше, за кількістю порушень, і виходить близько п'ятдесяти семи відсотків, бо контраст і відсутні імена трапляються дуже часто. Але порядок фокусу, зрозумілість помилок, оголошення динаміки і сенс alt машина не оцінить. axe я беру для автотестів, pa11y-ci для широкого скану, Lighthouse для тренду, WAVE для навчання команди.
🗣 How to say it out loud Automated tools cover roughly thirty to forty percent of WCAG criteria. Deque counts differently, by number of violations, and gets about fifty-seven percent, because contrast and missing names are so common. But a machine cannot judge focus order, clear error messages, announcements of dynamic content or whether alt makes sense. I use axe for automated tests, pa11y-ci for a broad scan, Lighthouse for a trend and WAVE to teach the team.
49 Як написати перевірку доступності в Playwright з @axe-core/playwright: withTags, include і exclude? How do you write an accessibility check in Playwright with @axe-core/playwright: withTags, include and exclude?
Автоматизація і процесAutomation and process Middle ▶

Основа. Пакет @axe-core/playwright експортує клас AxeBuilder. Створюєш його з { page }, налаштовуєш ланцюжком і викликаєш analyze(), який повертає ті самі результати axe. Перевірка — expect(results.violations).toEqual([]): у разі падіння звіт Playwright покаже весь масив порушень.

The basics. The @axe-core/playwright package exports the AxeBuilder class. You create it with { page }, configure it by chaining and call analyze(), which returns the usual axe results. The check is expect(results.violations).toEqual([]): on failure the Playwright report shows the whole violations array.

Налаштування. withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) — обмежити правила критеріями WCAG A і AA усіх версій (best-practice окремо, якщо команда домовилась). include('#checkout') — сканувати лише частину сторінки, наприклад компонент, за який відповідає команда. exclude('#third-party-chat') — виключити фрагмент, який ви не контролюєте. withRules([...]) — запустити лише конкретні правила. options — передати сирі опції axe.

Configuration. withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) limits rules to WCAG A and AA criteria of every version (best-practice separately, if the team agrees). include('#checkout') scans only part of the page, such as the component your team owns. exclude('#third-party-chat') leaves out a fragment you do not control. withRules([...]) runs only specific rules. options passes raw axe options.

Стани. axe перевіряє лише поточний DOM, тож сканувати треба після дій: відкрито меню, показано модалку, відображено помилки валідації. Якщо сторінка анімує появу, спершу дочекайся стабільного стану через expect(...).toBeVisible().

States. axe checks only the current DOM, so scan after actions: menu opened, modal shown, validation errors rendered. If the page animates in, wait for a stable state first with expect(...).toBeVisible().

Для зручності. Документація Playwright радить виносити налаштований AxeBuilder у фікстуру, щоб теги й виключення були однаковими в усіх тестах, і прикріплювати JSON результатів до звіту через testInfo.attach. Тоді в HTML-звіті є повний список вузлів навіть для тесту, що впав на CI.

For convenience. The Playwright docs suggest putting a configured AxeBuilder into a fixture so tags and exclusions are the same in every test, and attaching the results JSON to the report with testInfo.attach. Then the HTML report has the full node list even for a test that failed on CI.

Помилка. Сканувати лише головну сторінку одразу після goto — це найменш цікавий стан продукту.

The mistake. Scanning only the home page right after goto — the least interesting state of the product.

Фікстура з AxeBuilder і тест станів чекауту
A fixture with AxeBuilder and a checkout states test
// fixtures.js — однакові налаштування axe для всіх тестів
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

export const test = base.extend({
  makeAxeBuilder: async ({ page }, use) => {
    await use(() => new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
      .exclude('#third-party-chat'));                 // не наш код, окремий тікет вендору
  },
});
export { expect };

// checkout.a11y.spec.js
import { test, expect } from './fixtures.js';

test('оплата без порушень WCAG AA, зокрема зі станом помилки', async ({ page, makeAxeBuilder }, testInfo) => {
  await page.goto('/checkout/payment');
  await page.getByRole('button', { name: 'Оплатити' }).click();       // показати помилки
  await expect(page.getByRole('alert')).toBeVisible();

  const results = await makeAxeBuilder().include('#checkout').analyze();
  await testInfo.attach('axe-results', {
    body: JSON.stringify(results.violations, null, 2),
    contentType: 'application/json',
  });
  expect(results.violations).toEqual([]);
});
// fixtures.js — the same axe settings for every test
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

export const test = base.extend({
  makeAxeBuilder: async ({ page }, use) => {
    await use(() => new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
      .exclude('#third-party-chat'));                 // not our code, separate vendor ticket
  },
});
export { expect };

// checkout.a11y.spec.js
import { test, expect } from './fixtures.js';

test('payment has no WCAG AA violations, including the error state', async ({ page, makeAxeBuilder }, testInfo) => {
  await page.goto('/checkout/payment');
  await page.getByRole('button', { name: 'Pay' }).click();           // show the errors
  await expect(page.getByRole('alert')).toBeVisible();

  const results = await makeAxeBuilder().include('#checkout').analyze();
  await testInfo.attach('axe-results', {
    body: JSON.stringify(results.violations, null, 2),
    contentType: 'application/json',
  });
  expect(results.violations).toEqual([]);
});
🔧 Розбір прикладу: Фікстура повертає фабрику, а не готовий об'єкт: кожен виклик makeAxeBuilder() створює новий AxeBuilder, до якого тест може додати свої include чи withRules. Скан іде після кліку, тож перевіряється і стан з помилками. toEqual([]) замість перевірки довжини показує в звіті зміст порушень.
🔧 Example breakdown: The fixture returns a factory rather than a ready object: each makeAxeBuilder() call creates a fresh AxeBuilder, to which a test can add its own include or withRules. The scan runs after the click, so the error state is checked too. toEqual([]) instead of a length check shows the violations' content in the report.
💡 Простими словами: AxeBuilder = axe у Playwright: задай теги WCAG, обмеж область, приведи сторінку в потрібний стан і тільки тоді аналізуй.
💡 In plain words: AxeBuilder is axe in Playwright: set the WCAG tags, narrow the area, bring the page into the state you care about, and only then analyse.
🗣 Як читати вголос У Playwright я використовую AxeBuilder з пакета @axe-core/playwright. Налаштовую його у фікстурі: теги WCAG A і AA для версій 2.0, 2.1 і 2.2, виключення для сторонніх віджетів. У тесті спершу привожу сторінку в цікавий стан, наприклад з помилками форми чи відкритою модалкою, потім include для своєї області, analyze і очікую порожній масив violations. JSON результатів прикріплюю до звіту.
🗣 How to say it out loud In Playwright I use AxeBuilder from the @axe-core/playwright package. I set it up in a fixture: WCAG A and AA tags for versions 2.0, 2.1 and 2.2, and exclusions for third-party widgets. In the test I first bring the page into an interesting state, like form errors or an open modal, then include for my area, analyze, and expect an empty violations array. I attach the results JSON to the report.
50 Коли допустимо використовувати disableRules чи exclude і як жити з відомими порушеннями в CI? When is it acceptable to use disableRules or exclude, and how do you live with known violations in CI?
Автоматизація і процесAutomation and process Senior ▶

Проблема. На легасі-продукті перший запуск axe дає сотні порушень. Якщо CI стане червоним назавжди, тест вимкнуть. Якщо все вимкнути через disableRules, тест нічого не ловитиме. Потрібна середина.

The problem. On a legacy product the first axe run returns hundreds of violations. If CI goes red forever, the test gets switched off. If everything is switched off with disableRules, the test catches nothing. You need a middle ground.

Коли disableRules прийнятний. 1) Хибне спрацювання, підтверджене вручну, — з посиланням на issue в axe-core чи внутрішній тікет. 2) Правило, яке перевіряється іншим, точнішим способом (наприклад, контраст дизайн-токенів перевіряють окремим тестом). 3) Тимчасово, під конкретний тікет з датою, — і вимикати для конкретного тесту, а не глобально. Неприйнятно: «щоб CI позеленів», глобально, без коментаря і терміну.

When disableRules is acceptable. 1) A false positive confirmed by hand — with a link to an axe-core issue or an internal ticket. 2) A rule covered by another, more precise method (for example design token contrast checked by a separate test). 3) Temporarily, tied to a specific ticket with a date — and disabled for a specific test, not globally. Not acceptable: «to make CI green», globally, with no comment and no deadline.

Коли exclude прийнятний. Сторонній вміст, який ви не контролюєте (чат, карта, рекламний iframe): виключити і завести окремий процес з вендором, бо для користувача це все одно ваш продукт. Не прийнятно виключати свій компонент, бо він «ще не готовий».

When exclude is acceptable. Third-party content you do not control (chat, map, ad iframe): exclude it and run a separate process with the vendor, because for the user it is still your product. It is not acceptable to exclude your own component because it is «not ready yet».

Базова лінія замість вимикання. Документація Playwright пропонує знімок «відбитків» порушень: список rule id + селектор зберігається у snapshot, і тест падає лише при появі нового порушення. Відомі порушення видно в файлі, їх кількість зменшується з виправленнями, а нові регресії не проходять. Альтернатива — пороги за impact: не допускати нових critical і serious.

A baseline instead of disabling. The Playwright docs suggest snapshotting violation «fingerprints»: a list of rule id + selector is stored in a snapshot, and the test fails only when a new violation appears. Known violations are visible in the file, their number shrinks as fixes land, and new regressions do not get through. An alternative is impact thresholds: allow no new critical and serious issues.

Процес. Щомісяця переглядаю базову лінію і список вимкнених правил: що виправлено, що прострочено. Кожне вимкнення — з власником.

Process. Every month I review the baseline and the disabled rules list: what got fixed, what is overdue. Every disable has an owner.

Базова лінія відбитків і точкове вимкнення правила
Fingerprint baseline and a targeted rule disable
// відбиток порушення: правило + селектори вузлів, без мінливого HTML
function violationFingerprints(results) {
  return JSON.stringify(results.violations.map((v) => ({
    rule: v.id,
    targets: v.nodes.map((n) => n.target),
  })), null, 2);
}

test('каталог: нових порушень немає відносно базової лінії', async ({ page, makeAxeBuilder }) => {
  await page.goto('/catalog');
  const results = await makeAxeBuilder()
    // A11Y-412: хибне спрацювання color-contrast на тексті поверх відео, до 2026-12-01
    .disableRules(['color-contrast'])
    .include('#hero-video')
    .analyze();

  // critical і serious не пропускаємо взагалі, навіть відомі
  const blocking = results.violations.filter((v) => ['critical', 'serious'].includes(v.impact));
  expect(blocking).toEqual([]);
  expect(violationFingerprints(results)).toMatchSnapshot();
});
// a violation fingerprint: rule + node selectors, without volatile HTML
function violationFingerprints(results) {
  return JSON.stringify(results.violations.map((v) => ({
    rule: v.id,
    targets: v.nodes.map((n) => n.target),
  })), null, 2);
}

test('catalogue: no new violations against the baseline', async ({ page, makeAxeBuilder }) => {
  await page.goto('/catalog');
  const results = await makeAxeBuilder()
    // A11Y-412: false positive color-contrast on text over video, until 2026-12-01
    .disableRules(['color-contrast'])
    .include('#hero-video')
    .analyze();

  // critical and serious never pass, not even known ones
  const blocking = results.violations.filter((v) => ['critical', 'serious'].includes(v.impact));
  expect(blocking).toEqual([]);
  expect(violationFingerprints(results)).toMatchSnapshot();
});
🔧 Розбір прикладу: Правило вимкнене лише в одному тесті і лише для області #hero-video, з номером тікета і датою в коментарі. toMatchSnapshot зберігає файл з відомими порушеннями поруч з тестом: коли порушення виправлено, знімок оновлюють свідомо, і кількість рядків у ньому — метрика прогресу.
🔧 Example breakdown: The rule is disabled in one test only and only for the #hero-video area, with a ticket number and a date in the comment. toMatchSnapshot stores a file of known violations next to the test: when one is fixed, the snapshot is updated deliberately, and its line count is a progress metric.
💡 Простими словами: Не вимикай перевірку, а заморозь відомі проблеми: старе видно і воно зменшується, нове не проходить.
💡 In plain words: Do not switch the check off, freeze the known issues: the old ones stay visible and shrink, the new ones do not get through.
🗣 Як читати вголос disableRules я вважаю прийнятним лише для підтвердженого хибного спрацювання або тимчасово під тікет з датою, і лише в конкретному тесті, а не глобально. exclude — для стороннього коду, з окремою роботою з вендором. А на легасі краще не вимикати, а зробити базову лінію відбитків порушень: відомі лежать у снапшоті, нові валять CI. Плюс critical і serious не пропускаю ніколи, і раз на місяць переглядаю список винятків.
🗣 How to say it out loud I consider disableRules acceptable only for a confirmed false positive, or temporarily under a ticket with a date, and only in a specific test, not globally. exclude is for third-party code, with separate work with the vendor. On a legacy product it is better not to switch things off but to create a baseline of violation fingerprints: the known ones live in the snapshot and new ones fail CI. Plus I never let critical and serious through, and I review the exceptions list monthly.
51 Чим getByRole у Playwright корисний для доступності і як перевіряти доступне ім'я в тестах? How does getByRole in Playwright help accessibility, and how do you check accessible names in tests?
Автоматизація і процесAutomation and process Junior ▶

Як працює. page.getByRole('button', { name: 'Оплатити' }) шукає елемент так, як його бачать допоміжні технології: за роллю в дереві доступності і за accessible name. За замовчуванням ім'я збігається частково і без урахування регістру; exact: true вимагає точного збігу; можна передати регулярний вираз. Є фільтри стану: checked, expanded, pressed, selected, level для заголовків.

How it works. page.getByRole('button', { name: 'Pay' }) finds an element the way assistive technology sees it: by role in the accessibility tree and by accessible name. By default the name matches partially and case-insensitively; exact: true requires an exact match; a regular expression works too. There are state filters: checked, expanded, pressed, selected, level for headings.

Плюси для тестів. Локатор стійкий до змін верстки й CSS-класів: поки кнопка лишається кнопкою з тим самим ім'ям, тест живий. Тест читається як сценарій користувача. Playwright рекомендує такі локатори як пріоритетні.

Benefits for tests. The locator survives layout and CSS class changes: as long as the button stays a button with the same name, the test lives. The test reads like a user scenario. Playwright recommends these locators as the first choice.

Плюси для доступності. Якщо локатор за роллю не знаходить елемент, це часто баг доступності: div замість кнопки, поле без label, кнопка-іконка без імені. Команда, що пише тести через getByRole і getByLabel, мимоволі тримає семантику в порядку. Додаткові перевірки — toHaveAccessibleName, toHaveAccessibleDescription, toHaveRole.

Benefits for accessibility. If a role locator cannot find an element, that is often an accessibility bug: a div instead of a button, a field with no label, an icon button with no name. A team that writes tests with getByRole and getByLabel keeps semantics tidy almost by accident. Extra assertions: toHaveAccessibleName, toHaveAccessibleDescription, toHaveRole.

Межі. getByRole знайде і <div role=button> без обробки клавіатури — він перевіряє дерево доступності, а не поведінку. Він не перевіряє контраст, фокус і порядок читання. І якщо тест клацає через click(), він не доводить, що елемент працює з клавіатури. Тож це корисний побічний ефект, а не заміна a11y-тестування.

Limits. getByRole will also find a <div role=button> with no keyboard handling — it checks the accessibility tree, not behaviour. It does not check contrast, focus or reading order. And a test that clicks via click() proves nothing about keyboard operation. So it is a useful side effect, not a replacement for a11y testing.

Помилка. Обходити «незручну» відсутність ролі через locator('.btn-pay') замість того, щоб завести баг.

The mistake. Working around an «inconvenient» missing role with locator('.btn-pay') instead of filing a bug.

CSS-локатор проти локатора за роллю і перевірки імені
CSS locator versus role locator and name checks
// крихко і сліпо до доступності: пройде навіть для div без імені
await page.locator('.checkout .btn-primary').click();

// як бачить користувач скрінрідера: роль + ім'я
await page.getByRole('button', { name: 'Оплатити 1 250 грн' }).click();
await page.getByRole('checkbox', { name: 'Погоджуюсь з умовами' }).check();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Оплата');
await expect(page.getByRole('button', { name: 'Кошик', expanded: false })).toBeVisible();

// явні a11y-перевірки для кнопки-іконки
const close = page.locator('[data-test=close-cart]');
await expect(close).toHaveRole('button');
await expect(close).toHaveAccessibleName('Закрити кошик');
// brittle and blind to accessibility: passes even for a nameless div
await page.locator('.checkout .btn-primary').click();

// how a screen reader user sees it: role + name
await page.getByRole('button', { name: 'Pay $125.00' }).click();
await page.getByRole('checkbox', { name: 'I agree to the terms' }).check();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Payment');
await expect(page.getByRole('button', { name: 'Cart', expanded: false })).toBeVisible();

// explicit a11y assertions for an icon button
const close = page.locator('[data-test=close-cart]');
await expect(close).toHaveRole('button');
await expect(close).toHaveAccessibleName('Close cart');
🔧 Розбір прикладу: Фільтр expanded: false знаходить кнопку лише тоді, коли в неї правильний стан aria-expanded — побічно перевіряє ARIA. Останній блок показує патерн «знайти за тестовим атрибутом, перевірити роль і ім'я явно»: корисно, коли ім'я кнопки саме і є предметом тесту.
🔧 Example breakdown: The expanded: false filter only finds the button when its aria-expanded state is right — an indirect ARIA check. The last block shows the «find by test attribute, assert role and name explicitly» pattern: useful when the button's name is itself what the test is about.
💡 Простими словами: getByRole шукає елементи так, як їх «бачить» скрінрідер; якщо не знаходить — перевір, чи це не баг доступності.
💡 In plain words: getByRole finds elements the way a screen reader «sees» them; if it finds nothing, check whether that is an accessibility bug.
🗣 Як читати вголос getByRole шукає елемент за роллю і доступним ім'ям, тобто так, як його бачить скрінрідер. Для тестів це стійкі й читабельні локатори, а для доступності — ранній сигнал: якщо кнопку не можна знайти за роллю, у неї, найімовірніше, немає ролі чи імені. Але я пам'ятаю межі: getByRole знайде і div з role button без клавіатури, і не перевіряє ні контраст, ні фокус. Тому це доповнення, а не заміна.
🗣 How to say it out loud getByRole finds an element by role and accessible name, the way a screen reader sees it. For tests these are stable, readable locators, and for accessibility they are an early signal: if you cannot find a button by role, it most likely has no role or name. But I know the limits: getByRole will find a div with role button that has no keyboard support, and it checks neither contrast nor focus. So it is a complement, not a replacement.
52 Як вбудувати перевірки доступності в CI і компонентні тести так, щоб вони не стали шумом? How do you build accessibility checks into CI and component tests without them turning into noise?
Автоматизація і процесAutomation and process Senior ▶

Рівні. 1) Редактор і лінт: eslint-plugin-jsx-a11y (чи аналоги для Vue і Angular) ловить відсутній alt, клік без клавіатури, невалідні ARIA ще до коміту. 2) Компонентні тести: axe на кожному компоненті дизайн-системи в усіх станах — Storybook з addon-a11y і test-runner, або Playwright/Vitest у браузерному режимі. Виправлення тут дешеве і лікує всі сторінки, де компонент використано. jest-axe у jsdom корисний, але jsdom не рахує стилі, тож контраст там не перевіряється. 3) E2E: axe у ключових сценаріях і станах, плюс клавіатурні тести для критичних шляхів. 4) Регулярний скан: pa11y-ci чи Lighthouse по всіх сторінках за sitemap раз на ніч — для тренду, без блокування PR.

Layers. 1) Editor and lint: eslint-plugin-jsx-a11y (or equivalents for Vue and Angular) catches a missing alt, a click without keyboard support and invalid ARIA before the commit. 2) Component tests: axe on every design-system component in all its states — Storybook with addon-a11y and the test runner, or Playwright/Vitest in browser mode. Fixing here is cheap and heals every page that uses the component. jest-axe in jsdom is useful, but jsdom does not compute styles, so contrast is not checked there. 3) E2E: axe in key flows and states, plus keyboard tests for critical paths. 4) A regular scan: pa11y-ci or Lighthouse over every sitemap page nightly — for the trend, without blocking PRs.

Щоб не було шуму. Блокувати PR лише за новими порушеннями (базова лінія) і лише за WCAG A/AA з impact serious і critical; решту — у звіт. Одна фікстура з однаковими тегами для всієї команди. Результати — артефактом у PR, з helpUrl, щоб розробник бачив, що і як виправити. Власник у кожного винятку. Тести стабільні: скан після очікування стабільного стану, без анімацій.

Avoiding noise. Block a PR only on new violations (a baseline) and only for WCAG A/AA with serious and critical impact; everything else goes into the report. One fixture with the same tags for the whole team. Results as a PR artefact, with helpUrl, so the developer sees what to fix and how. Every exception has an owner. Tests are stable: scan after waiting for a stable state, with animations off.

Метрики. Кількість порушень за правилами в часі, частка компонентів дизайн-системи з зеленими a11y-тестами, кількість сценаріїв з ручним аудитом за квартал.

Metrics. Violations per rule over time, the share of design-system components with green a11y tests, the number of flows manually audited per quarter.

Межа. Навіть ідеальний CI не замінює ручний аудит перед великими релізами.

The limit. Even a perfect CI does not replace a manual audit before major releases.

GitHub Actions: a11y-тести на PR і нічний скан сайту
GitHub Actions: a11y tests on PRs and a nightly site scan
name: accessibility
on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'          # щоночі о 02:00 UTC — повний скан

jobs:
  a11y-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      # лише тести з тегом @a11y: axe у ключових станах + клавіатура
      - run: npx playwright test --grep @a11y --project=chromium
      - uses: actions/upload-artifact@v4
        if: always()             # звіт потрібен саме тоді, коли впало
        with:
          name: a11y-report
          path: playwright-report/

  site-scan:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # не блокує PR: результат іде в тренд і тікети
      - run: npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml --json > pa11y.json || true
      - uses: actions/upload-artifact@v4
        with:
          name: pa11y-nightly
          path: pa11y.json
name: accessibility
on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'          # nightly at 02:00 UTC — full scan

jobs:
  a11y-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      # only tests tagged @a11y: axe in key states + keyboard
      - run: npx playwright test --grep @a11y --project=chromium
      - uses: actions/upload-artifact@v4
        if: always()             # the report matters exactly when it failed
        with:
          name: a11y-report
          path: playwright-report/

  site-scan:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # does not block PRs: the result feeds the trend and tickets
      - run: npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml --json > pa11y.json || true
      - uses: actions/upload-artifact@v4
        with:
          name: pa11y-nightly
          path: pa11y.json
🔧 Розбір прикладу: Два завдання з різною метою: a11y-e2e на кожен PR блокує злиття за новими порушеннями, site-scan вночі дає широку картину і свідомо не падає (|| true). if: always() вивантажує звіт і для червоного прогону. Тег @a11y у назві тесту дозволяє запускати ці тести окремо.
🔧 Example breakdown: Two jobs with different goals: a11y-e2e on every PR blocks merging on new violations, and site-scan at night gives the wide picture and deliberately never fails (|| true). if: always() uploads the report for a red run too. The @a11y tag in the test title lets these tests run separately.
💡 Простими словами: Лінт ловить на письмі, компонентні тести — у дизайн-системі, e2e — у сценаріях, нічний скан — тренд; PR блокують лише нові серйозні проблеми.
💡 In plain words: Lint catches it while typing, component tests in the design system, e2e in flows, the nightly scan shows the trend; PRs are blocked only by new serious problems.
🗣 Як читати вголос Я будую кілька рівнів: лінт у редакторі, axe на компонентах дизайн-системи у всіх станах, axe і клавіатурні тести в ключових e2e-сценаріях і нічний скан усіх сторінок для тренду. Щоб це не стало шумом, PR блокують лише нові порушення рівнів A і AA з impact serious чи critical, решта йде у звіт. Звіт завжди прикріплений до PR. І я чесно кажу, що CI не замінює ручний аудит.
🗣 How to say it out loud I build several layers: lint in the editor, axe on design-system components in all their states, axe and keyboard tests in key e2e flows, and a nightly scan of every page for the trend. To keep it from becoming noise, PRs are blocked only by new A and AA violations with serious or critical impact; the rest goes into a report. The report is always attached to the PR. And I say plainly that CI does not replace a manual audit.
53 Як виглядає чекліст ручного тестування доступності сторінки? What does a manual accessibility testing checklist for a page look like?
Автоматизація і процесAutomation and process Middle ▶

Навіщо чекліст. Ручна перевірка покриває те, чого не бачить axe, але без структури вона залежить від настрою тестувальника. Чекліст робить її повторюваною: двоє людей отримають схожі результати, а нова людина в команді швидко почне перевіряти.

Why a checklist. A manual check covers what axe cannot see, but without structure it depends on the tester's mood. A checklist makes it repeatable: two people get similar results, and a newcomer to the team can start checking quickly.

Порядок. Від дешевого до дорогого: спершу автоматичний скан (щоб не витрачати час на очевидне), далі клавіатура, далі масштаб і візуальні перевірки, наприкінці скрінрідер. Кожен пункт посилається на критерій WCAG, тож знахідку одразу можна оформити.

Order. From cheap to expensive: first an automated scan (so you do not spend time on the obvious), then keyboard, then zoom and visual checks, and finally a screen reader. Every item points to a WCAG criterion, so a finding can be filed immediately.

Тривалість. На одну типову сторінку чи крок сценарію досвідченому тестувальнику потрібно 30–60 хвилин. Для регресії достатньо «швидкого» підмножини: Tab-прохід, масштаб 400 %, прослуховування ключових дій. Повний прохід — для нових функцій і перед аудитом.

Duration. For one typical page or flow step an experienced tester needs 30–60 minutes. For regression a «quick» subset is enough: a Tab pass, 400 % zoom, listening to key actions. The full pass is for new features and before an audit.

Нюанс. Чекліст — не весь аудит: він не замінює тестування з реальними користувачами з інвалідністю, які знаходять проблеми зручності, яких немає в жодному критерії.

Nuance. A checklist is not the whole audit: it does not replace testing with real users with disabilities, who find usability problems that no criterion describes.

Як використовую. Копія чекліста на кожну сторінку в тест-менеджменті, статус pass/fail/n.a. по пунктах, посилання на баги. Так результат ручної перевірки стає даними для ACR.

How I use it. A copy of the checklist per page in test management, a pass/fail/n.a. status per item, links to bugs. That turns the manual check into data for an ACR.

Ручний чекліст на одну сторінку
Manual checklist for a single page
0. Автоматика
  [ ] axe DevTools / WAVE: порушення заведені або пояснені
1. Клавіатура
  [ ] усе інтерактивне досяжне Tab / Shift+Tab           2.1.1
  [ ] порядок фокусу логічний                            2.4.3
  [ ] фокус завжди видно, не ховається під шапкою        2.4.7, 2.4.11
  [ ] немає пасток; Esc закриває поп-апи                 2.1.2
  [ ] skip link працює                                   2.4.1
2. Масштаб і візуальне
  [ ] 200 % тексту і 400 % (320 px) без горизонт. скролу 1.4.4, 1.4.10
  [ ] text spacing нічого не обрізає                     1.4.12
  [ ] контраст тексту 4.5:1, елементів і станів 3:1      1.4.3, 1.4.11
  [ ] нічого не передано лише кольором                   1.4.1
  [ ] reduced motion поважається, у каруселі є пауза     2.2.2
3. Структура і вміст
  [ ] title сторінки, lang, один h1, логічні заголовки   2.4.2, 3.1.1, 1.3.1
  [ ] alt осмислені, декор з alt=""                      1.1.1
  [ ] поля з label, помилки текстом і з підказкою        3.3.1, 3.3.2, 3.3.3
4. Скрінрідер (NVDA + Chrome)
  [ ] у кнопок і посилань зрозумілі імена                4.1.2, 2.4.4
  [ ] стани оголошуються (expanded, checked, invalid)    4.1.2
  [ ] статуси й помилки оголошуються без фокусу          4.1.3
  [ ] модалка: фокус усередині, повернення після закриття
0. Automation
  [ ] axe DevTools / WAVE: violations filed or explained
1. Keyboard
  [ ] everything interactive reachable with Tab / Shift+Tab 2.1.1
  [ ] focus order is logical                             2.4.3
  [ ] focus always visible, never hidden under the header 2.4.7, 2.4.11
  [ ] no traps; Esc closes popups                        2.1.2
  [ ] skip link works                                    2.4.1
2. Zoom and visual
  [ ] 200 % text and 400 % (320 px) with no sideways scroll 1.4.4, 1.4.10
  [ ] text spacing clips nothing                         1.4.12
  [ ] text contrast 4.5:1, components and states 3:1     1.4.3, 1.4.11
  [ ] nothing conveyed by colour alone                   1.4.1
  [ ] reduced motion respected, carousel has a pause     2.2.2
3. Structure and content
  [ ] page title, lang, one h1, logical headings         2.4.2, 3.1.1, 1.3.1
  [ ] alt texts meaningful, decoration with alt=""       1.1.1
  [ ] fields labelled, errors in text with a suggestion  3.3.1, 3.3.2, 3.3.3
4. Screen reader (NVDA + Chrome)
  [ ] buttons and links have clear names                 4.1.2, 2.4.4
  [ ] states announced (expanded, checked, invalid)      4.1.2
  [ ] statuses and errors announced without focus        4.1.3
  [ ] modal: focus inside, returns after closing
🔧 Розбір прикладу: Блоки йдуть від дешевих перевірок до дорогих, а праворуч стоять номери критеріїв, тож кожен «fail» одразу має адресу у WCAG. Пункт 0 існує, щоб ручний час не йшов на те, що машина знаходить за секунди.
🔧 Example breakdown: The blocks run from cheap checks to expensive ones, and the criterion numbers on the right give every «fail» an address in WCAG straight away. Item 0 exists so manual time is not spent on what a machine finds in seconds.
💡 Простими словами: Спершу машина, потім клавіатура, потім масштаб і очі, наприкінці вуха — і кожен пункт з номером критерію.
💡 In plain words: First the machine, then the keyboard, then zoom and eyes, and ears last — with a criterion number on every item.
🗣 Як читати вголос Мій ручний чекліст іде від дешевого до дорогого. Спершу axe чи WAVE, щоб не витрачати час на очевидне. Потім клавіатура: досяжність, порядок, видимий фокус, пастки, skip link. Потім масштаб до чотирьохсот відсотків, text spacing, контраст і колір. Потім структура: заголовки, lang, alt, форми. І наприкінці NVDA з Chrome: імена, стани, оголошення. Біля кожного пункту стоїть номер критерію, тож результати одразу йдуть у баги чи ACR.
🗣 How to say it out loud My manual checklist goes from cheap to expensive. First axe or WAVE, so I do not waste time on the obvious. Then keyboard: reachability, order, visible focus, traps, the skip link. Then zoom to four hundred percent, text spacing, contrast and colour. Then structure: headings, lang, alt, forms. And finally NVDA with Chrome: names, states, announcements. Every item has a criterion number next to it, so the results go straight into bugs or an ACR.
54 Як написати якісний баг-репорт про проблему доступності? How do you write a good accessibility bug report?
Автоматизація і процесAutomation and process Middle ▶

Що відрізняє a11y-баг. Розробник часто не може «побачити» проблему, бо сам не користується клавіатурою чи скрінрідером. Тому репорт має пояснити, хто і як постраждає, і дати точний спосіб відтворення з тим самим інструментом.

What makes an a11y bug different. A developer often cannot «see» the problem, because they do not use a keyboard or a screen reader themselves. So the report has to explain who is affected and how, and give an exact way to reproduce it with the same tool.

Обов'язкові частини. 1) Заголовок з місцем і суттю, можна з критерієм: «Чекаут, оплата: кнопка закриття без доступного імені (WCAG 4.1.2)». 2) Середовище: ОС, браузер, допоміжна технологія з версіями (NVDA 2025.x + Chrome 14x), масштаб, налаштування. 3) Кроки: клавіатурою, а не мишею. 4) Фактичний результат: що саме озвучено (цитата зі Speech Viewer), куди потрапив фокус. 5) Очікуваний результат з посиланням на критерій WCAG і рівень. 6) Вплив на користувача: хто і чи блокує завершення сценарію. 7) Докази: скриншот, відео зі звуком скрінрідера, id правила axe і селектор. 8) Підказка виправлення, якщо очевидна, — без нав'язування реалізації.

Mandatory parts. 1) A title with the location and the essence, optionally with the criterion: «Checkout, payment: close button has no accessible name (WCAG 4.1.2)». 2) Environment: OS, browser, assistive technology with versions (NVDA 2025.x + Chrome 14x), zoom, settings. 3) Steps: by keyboard, not mouse. 4) Actual result: exactly what was announced (a quote from Speech Viewer), where focus went. 5) Expected result with a link to the WCAG criterion and level. 6) User impact: who is affected and whether it blocks completing the flow. 7) Evidence: a screenshot, a video with the screen reader audio, the axe rule id and selector. 8) A fix hint if obvious — without dictating the implementation.

Серйозність. Визначається впливом, а не рівнем A/AA: неможливість оплатити клавіатурою — блокер, навіть якщо це «лише» один критерій. Low — незручність з обхідним шляхом.

Severity. It comes from impact, not from level A/AA: being unable to pay with the keyboard is a blocker, even if it is «only» one criterion. Low is an inconvenience with a workaround.

Масштаб. Якщо проблема в компоненті дизайн-системи, одного бага на компонент достатньо, з переліком сторінок, — а не сорок однакових.

Scope. If the problem lives in a design-system component, one bug per component is enough, with a list of pages — not forty identical ones.

Помилки. «Не працює зі скрінрідером» без деталей. Відсутність версій. Посилання на правило axe без пояснення, що відчуває людина.

Mistakes. «Does not work with a screen reader» with no details. Missing versions. An axe rule link without explaining what the person experiences.

Шаблон a11y баг-репорту на прикладі
An a11y bug report template with an example
Заголовок: [A11y] Оплата: після помилки фокус лишається на «Оплатити»,
           помилка не оголошується (WCAG 3.3.1 A, 4.1.3 AA)

Середовище: Windows 11, Chrome 14x, NVDA 2025.x, масштаб 100 %
Кроки:
  1. /checkout/payment, лише клавіатура
  2. Tab до «Номер картки», ввести 15 цифр
  3. Tab до «Оплатити», Enter
Фактично: NVDA каже лише «Оплатити, кнопка». Фокус лишається на кнопці.
          Червоний текст під полем з'являється, але не оголошується (Speech Viewer
          у вкладенні). Поле не має aria-invalid.
Очікувано: помилку оголошено (зведення з role=alert або перенос фокусу),
           поле позначене aria-invalid і пов'язане з текстом помилки.
           WCAG 3.3.1 Error Identification (A), 4.1.3 Status Messages (AA)
Вплив: незрячий користувач не дізнається, чому оплата не проходить, і не може
       завершити покупку. Серйозність: Blocker.
Докази: video-nvda-payment.mp4 (зі звуком), speech-viewer.png
Підказка: патерн зведення помилок, див. картку про помилки форм
Масштаб: компонент PaymentForm, також /subscriptions/renew
Title: [A11y] Payment: after an error focus stays on «Pay»,
       the error is not announced (WCAG 3.3.1 A, 4.1.3 AA)

Environment: Windows 11, Chrome 14x, NVDA 2025.x, zoom 100 %
Steps:
  1. /checkout/payment, keyboard only
  2. Tab to «Card number», type 15 digits
  3. Tab to «Pay», Enter
Actual: NVDA only says «Pay, button». Focus stays on the button.
        Red text appears under the field but is not announced (Speech Viewer
        attached). The field has no aria-invalid.
Expected: the error is announced (a summary with role=alert or a focus move),
          the field is marked aria-invalid and linked to the error text.
          WCAG 3.3.1 Error Identification (A), 4.1.3 Status Messages (AA)
Impact: a blind user never learns why the payment fails and cannot
        complete the purchase. Severity: Blocker.
Evidence: video-nvda-payment.mp4 (with audio), speech-viewer.png
Hint: error summary pattern, see the form errors card
Scope: PaymentForm component, also /subscriptions/renew
🔧 Розбір прикладу: Кожен блок відповідає на питання розробника: як відтворити (середовище і кроки клавіатурою), що саме не так (цитата озвучки), чому це важливо (критерій і вплив), де ще (масштаб). Серйозність обґрунтована впливом на завершення покупки, а не рівнем критерію.
🔧 Example breakdown: Every block answers a developer's question: how to reproduce (environment and keyboard steps), what exactly is wrong (the announcement quote), why it matters (criterion and impact), where else (scope). Severity is justified by the impact on completing a purchase, not by the criterion level.
💡 Простими словами: Добрий a11y-баг дає розробнику почути і відчути проблему: точні кроки, цитату скрінрідера, критерій і вплив на людину.
💡 In plain words: A good a11y bug lets the developer hear and feel the problem: exact steps, a screen reader quote, the criterion and the impact on a person.
🗣 Як читати вголос В a11y-баг я обов'язково пишу середовище з версіями скрінрідера і браузера, кроки клавіатурою, фактичний результат як цитату того, що сказав скрінрідер, і очікуваний результат з номером критерію WCAG. Окремо описую вплив: хто постраждає і чи блокує це сценарій, бо серйозність залежить від цього, а не від рівня A чи AA. Додаю відео зі звуком. Якщо проблема в компоненті, заводжу один баг на компонент.
🗣 How to say it out loud In an a11y bug I always include the environment with screen reader and browser versions, keyboard steps, the actual result as a quote of what the screen reader said, and the expected result with the WCAG criterion number. I describe the impact separately: who is affected and whether it blocks the flow, because severity depends on that, not on level A or AA. I attach a video with sound. If the problem is in a component, I file one bug per component.
55 Як впровадити доступність у команді з великим легасі-застосунком, де раніше про неї не думали? How do you introduce accessibility into a team with a large legacy application where nobody considered it before?
Автоматизація і процесAutomation and process Senior ▶

Мета QA-команди. Не «знайти всі баги доступності», а зробити так, щоб продукт ставав доступнішим з кожним релізом і нові проблеми не з'являлися. Якість включає доступність так само, як безпеку чи продуктивність.

The QA team's goal. Not «find every accessibility bug», but make the product more accessible with each release and stop new problems appearing. Quality includes accessibility just as it includes security or performance.

Крок 1: зупинити кровотечу. Нові проблеми дешевше не допустити, ніж виправити. axe у CI з базовою лінією (нові порушення блокують PR), lint для фреймворку, a11y-критерії в Definition of Done і в критеріях приймання історій («форма проходиться клавіатурою, помилки оголошуються»), дизайн-рев'ю на контраст і стани фокусу.

Step 1: stop the bleeding. Preventing new issues is cheaper than fixing them. axe in CI with a baseline (new violations block PRs), framework lint, a11y criteria in the Definition of Done and in story acceptance criteria («the form works by keyboard, errors are announced»), design review for contrast and focus states.

Крок 2: зрозуміти масштаб. Аудит не всього сайту, а 5–10 ключових сценаріїв (реєстрація, пошук, покупка, особистий кабінет): автоматика плюс ручний прохід за чеклістом. Результат — список проблем за кореневими причинами, а не за сторінками.

Step 2: understand the scale. Audit not the whole site but 5–10 key flows (sign-up, search, purchase, account area): automation plus a manual checklist pass. The output is a list of issues by root cause, not by page.

Крок 3: виправляти там, де ефект найбільший. Компоненти дизайн-системи (одна кнопка-іконка — сотні сторінок), дизайн-токени кольорів, спільний layout (skip link, landmarks, lang). Потім блокери в ключових сценаріях.

Step 3: fix where the effect is biggest. Design-system components (one icon button covers hundreds of pages), colour design tokens, the shared layout (skip link, landmarks, lang). Then blockers in key flows.

Крок 4: люди. Коротке навчання для розробників і дизайнерів (30 хвилин з NVDA переконують краще за документ), a11y-чемпіони в командах, публічна метрика прогресу. Раз на квартал — тестування з користувачами з інвалідністю.

Step 4: people. Short training for developers and designers (30 minutes with NVDA convinces better than any document), a11y champions in teams, a public progress metric. Once a quarter, testing with users with disabilities.

Трейд-оф. Повний рефакторинг «під WCAG» ніхто не профінансує; поступовий план з помітними результатами за 1–2 квартали — профінансують.

Trade-off. Nobody will fund a full «WCAG rewrite»; an incremental plan with visible results in 1–2 quarters will get funded.

План впровадження на два квартали
A two-quarter rollout plan
Тиждень 1-2   Стоп новим проблемам
              - axe у CI з базовою лінією, блок лише на нових serious/critical
              - eslint-plugin-jsx-a11y на весь репозиторій (warn -> error для нового коду)
              - DoD: «клавіатура + axe + назва/роль у нових компонентах»
Тиждень 3-4   Аудит 8 ключових сценаріїв (авто + ручний чекліст + NVDA)
              - результат: 23 кореневі причини замість 410 вузлів
Місяць 2      Дизайн-система: Button, IconButton, Modal, Input, Select, Tabs
              - токени кольорів під 4.5:1 / 3:1, стиль :focus-visible
Місяць 3      Блокери в чекауті й реєстрації, skip link, landmarks, lang
Квартал 2     Решта ключових сценаріїв, ACR для продажів, тест з користувачами
Метрики       порушення axe за правилами (тренд), % компонентів із зеленими
              a11y-тестами, % ключових сценаріїв, що пройшли ручний аудит
Week 1-2      Stop new problems
              - axe in CI with a baseline, block only on new serious/critical
              - eslint-plugin-jsx-a11y on the whole repo (warn -> error for new code)
              - DoD: «keyboard + axe + name/role in new components»
Week 3-4      Audit 8 key flows (automated + manual checklist + NVDA)
              - output: 23 root causes instead of 410 nodes
Month 2       Design system: Button, IconButton, Modal, Input, Select, Tabs
              - colour tokens to 4.5:1 / 3:1, a :focus-visible style
Month 3       Blockers in checkout and sign-up, skip link, landmarks, lang
Quarter 2     Remaining key flows, an ACR for sales, testing with users
Metrics       axe violations per rule (trend), % of components with green
              a11y tests, % of key flows that passed a manual audit
🔧 Розбір прикладу: План починається не з виправлень, а з запобігання, бо інакше команда виправлятиме старе, поки нове ламається. Рядок «23 кореневі причини замість 410 вузлів» — ключ до переконання менеджменту: обсяг роботи виявляється в рази меншим, ніж здається зі звіту сканера.
🔧 Example breakdown: The plan starts with prevention, not fixes, because otherwise the team repairs the old while the new breaks. The «23 root causes instead of 410 nodes» line is the key to convincing management: the amount of work turns out many times smaller than the scanner report suggests.
💡 Простими словами: Спершу перекрий кран (CI, DoD, лінт), потім лагодь спільні компоненти, потім сценарії — і показуй прогрес цифрами.
💡 In plain words: First turn off the tap (CI, DoD, lint), then fix shared components, then flows — and show progress in numbers.
🗣 Як читати вголос Я б почав не з виправлень, а з того, щоб не з'являлися нові проблеми: axe у CI з базовою лінією, лінт, критерії доступності в Definition of Done. Далі аудит кількох ключових сценаріїв, згрупований за кореневими причинами, бо сотні порушень зазвичай зводяться до двох десятків компонентів. Потім виправлення в дизайн-системі, що лікує одразу багато сторінок, навчання команди з NVDA і метрики прогресу, які бачить менеджмент.
🗣 How to say it out loud I would not start with fixes but with stopping new problems from appearing: axe in CI with a baseline, lint, accessibility criteria in the Definition of Done. Then an audit of a few key flows grouped by root cause, because hundreds of violations usually come down to a couple of dozen components. Then fixes in the design system, which heal many pages at once, team training with NVDA, and progress metrics that management can see.
56 Сканер показав 300 порушень axe. Як ти їх пріоритизуєш? A scanner reported 300 axe violations. How do you prioritise them?
Автоматизація і процесAutomation and process Senior ▶

Спершу згрупувати. 300 — це зазвичай кількість вузлів, а не проблем. Групую за id правила, потім за компонентом (спільний префікс селектора, клас компонента). Типова картина: 120 вузлів color-contrast від одного сірого токена, 80 button-name від однієї кнопки-іконки, 40 label у фільтрах каталогу. Після групування лишається 15–25 кореневих причин.

Group first. 300 is usually a count of nodes, not problems. I group by rule id, then by component (a shared selector prefix, a component class). A typical picture: 120 color-contrast nodes from one grey token, 80 button-name from one icon button, 40 label in the catalogue filters. After grouping, 15–25 root causes are left.

Перевірити. Відсіюю дублікати між сторінками, підтверджую вручну кілька вузлів з кожного правила, переглядаю incomplete — там можуть бути справжні проблеми з контрастом на картинках.

Verify. I remove duplicates across pages, confirm a few nodes of each rule by hand, and review incomplete — real contrast problems over images can hide there.

Критерії пріоритету. 1) Вплив на користувача: чи блокує завершення сценарію (кнопка без імені в оплаті — блокер, контраст футера — ні). Impact з axe — хороша підказка, але не остаточна. 2) Критичність сценарію: чекаут, логін, реєстрація вище за блог. 3) Рівень: A і AA вище за best-practice. 4) Охоплення: скільки сторінок лікує одне виправлення. 5) Вартість: заміна токена кольору — година роботи на 120 вузлів. Юридичний ризик додає вагу публічним сторінкам і документам.

Priority criteria. 1) Impact on the user: does it block completing a flow (a nameless button in payment is a blocker, footer contrast is not). axe's impact is a good hint but not final. 2) Flow criticality: checkout, sign-in and sign-up rank above the blog. 3) Level: A and AA rank above best-practice. 4) Reach: how many pages one fix heals. 5) Cost: swapping a colour token is an hour of work for 120 nodes. Legal risk adds weight to public pages and documents.

Результат. Матриця «вплив × зусилля»: спершу блокери в ключових сценаріях і дешеві масові виправлення (токени, спільні компоненти), потім решта A/AA, потім best-practice. Кожна коренева причина — окремий тікет з переліком сторінок.

Result. An «impact × effort» matrix: first blockers in key flows and cheap mass fixes (tokens, shared components), then the remaining A/AA, then best-practice. Each root cause is a separate ticket with a list of pages.

І не забути. 300 порушень від сканера — лише автоматична частина; ручний прохід тих самих сценаріїв майже напевно додасть проблем з клавіатурою і фокусом, які теж треба зважити.

And remember. 300 violations from a scanner are only the automated part; a manual pass over the same flows will almost certainly add keyboard and focus problems, which need weighing too.

Групування результатів axe за правилом і компонентом
Grouping axe results by rule and component
// node group-axe.mjs axe-results/*.json — зводить сотні вузлів до кореневих причин
import { readFileSync } from 'node:fs';

const groups = new Map();
for (const file of process.argv.slice(2)) {
  const { url, violations } = JSON.parse(readFileSync(file, 'utf8'));
  for (const v of violations) {
    for (const node of v.nodes) {
      // компонент = перший клас у селекторі, наприклад .icon-btn або .filter-panel
      const component = (String(node.target[0]).match(/\.[\w-]+/) || ['(без класу)'])[0];
      const key = v.id + ' | ' + component;
      const g = groups.get(key) || { rule: v.id, impact: v.impact, component, nodes: 0, pages: new Set() };
      g.nodes += 1;
      g.pages.add(url);
      groups.set(key, g);
    }
  }
}

const weight = { critical: 4, serious: 3, moderate: 2, minor: 1 };
[...groups.values()]
  .sort((a, b) => weight[b.impact] - weight[a.impact] || b.nodes - a.nodes)
  .forEach((g) => console.log(g.impact.padEnd(9), g.rule.padEnd(22), g.component.padEnd(18),
    'вузлів:', g.nodes, 'сторінок:', g.pages.size));
// node group-axe.mjs axe-results/*.json — folds hundreds of nodes into root causes
import { readFileSync } from 'node:fs';

const groups = new Map();
for (const file of process.argv.slice(2)) {
  const { url, violations } = JSON.parse(readFileSync(file, 'utf8'));
  for (const v of violations) {
    for (const node of v.nodes) {
      // component = the first class in the selector, e.g. .icon-btn or .filter-panel
      const component = (String(node.target[0]).match(/\.[\w-]+/) || ['(no class)'])[0];
      const key = v.id + ' | ' + component;
      const g = groups.get(key) || { rule: v.id, impact: v.impact, component, nodes: 0, pages: new Set() };
      g.nodes += 1;
      g.pages.add(url);
      groups.set(key, g);
    }
  }
}

const weight = { critical: 4, serious: 3, moderate: 2, minor: 1 };
[...groups.values()]
  .sort((a, b) => weight[b.impact] - weight[a.impact] || b.nodes - a.nodes)
  .forEach((g) => console.log(g.impact.padEnd(9), g.rule.padEnd(22), g.component.padEnd(18),
    'nodes:', g.nodes, 'pages:', g.pages.size));
🔧 Розбір прикладу: Скрипт читає збережені результати axe (по файлу на сторінку з полями url і violations), групує вузли за парою «правило + компонент» і сортує за impact, а всередині — за кількістю вузлів. Вивід одразу показує кандидатів на масове виправлення. Евристика «перший клас у селекторі» груба: для точності краще додати в компоненти атрибут data-component.
🔧 Example breakdown: The script reads saved axe results (one file per page with url and violations fields), groups nodes by the «rule + component» pair and sorts by impact, then by node count. The output immediately shows candidates for a mass fix. The «first class in the selector» heuristic is rough: for accuracy, add a data-component attribute to components.
💡 Простими словами: 300 вузлів — це кілька десятків причин; лагодь спершу те, що блокує людей у важливих сценаріях, і те, що одним рухом лікує сотні місць.
💡 In plain words: 300 nodes are a few dozen causes; fix first what blocks people in important flows and what heals hundreds of places in one move.
🗣 Як читати вголос Спершу я групую: триста — це вузли, а не проблеми, і після групування за правилом і компонентом зазвичай лишається два десятки причин. Потім перевіряю кілька вузлів вручну. Пріоритет ставлю за впливом на користувача і критичністю сценарію: безіменна кнопка в оплаті важливіша за контраст у футері. Далі дивлюся на охоплення і вартість: заміна одного кольорового токена може закрити сто двадцять вузлів за годину.
🗣 How to say it out loud First I group: three hundred is nodes, not problems, and after grouping by rule and component there are usually about twenty causes left. Then I verify a few nodes by hand. I set priority by user impact and flow criticality: a nameless button in payment matters more than footer contrast. Then I look at reach and cost: replacing one colour token might close a hundred and twenty nodes in an hour.
57 Як би ти провів аудит доступності чекауту інтернет-магазину? How would you run an accessibility audit of an online shop checkout?
Автоматизація і процесAutomation and process Senior ▶

Обсяг. Увесь шлях, а не окремі сторінки: кошик → вхід або гостьове оформлення → адреса → доставка → оплата (часто iframe платіжного провайдера і 3-D Secure) → підтвердження → лист і PDF-чек. Плюс стани: порожній кошик, промокод, помилки валідації, недоступний товар, таймаут сесії, мобільна версія. Ціль — WCAG 2.2 AA; результат — звіт з пріоритетами і план повторної перевірки.

Scope. The whole path, not separate pages: cart → sign-in or guest checkout → address → delivery → payment (often a payment provider iframe and 3-D Secure) → confirmation → email and PDF receipt. Plus states: empty cart, promo code, validation errors, an unavailable item, session timeout, the mobile version. The target is WCAG 2.2 AA; the output is a prioritised report and a retest plan.

Фокусні критерії для чекауту. Форми: label, autocomplete для адреси й картки (1.3.5), помилки текстом з підказкою (3.3.1, 3.3.3), перевірка перед оплатою (3.3.4), без повторного введення адреси (3.3.7), вхід без когнітивного тесту і з вставкою пароля (3.3.8). Клавіатура й фокус: вибір доставки, модалки, фокус після помилки. Оголошення змін суми, знижки, кількості (4.1.3). Таймаут (2.2.1). Цілі дотику на мобільному (2.5.8). Масштаб 400 % — кнопка «Оплатити» досяжна.

Focus criteria for checkout. Forms: labels, autocomplete for address and card (1.3.5), errors in text with suggestions (3.3.1, 3.3.3), review before paying (3.3.4), no retyping of the address (3.3.7), sign-in without a cognitive test and with password paste (3.3.8). Keyboard and focus: choosing delivery, modals, focus after an error. Announcing total, discount and quantity changes (4.1.3). Timeout (2.2.1). Touch targets on mobile (2.5.8). Zoom to 400 % — the «Pay» button stays reachable.

Метод. 1) axe у кожному кроці і стані (автотест проходить сценарій і сканує). 2) Ручний прохід клавіатурою за чеклістом. 3) NVDA + Chrome і VoiceOver + iOS Safari — повна покупка. 4) Масштаб і reflow. 5) Сторонні частини (iframe оплати): тестую, а невідповідність оформлюю як проблему вендора і прошу його ACR — для користувача це все одно наш чекаут.

Method. 1) axe in every step and state (an automated test walks the flow and scans). 2) A manual keyboard pass with the checklist. 3) NVDA + Chrome and VoiceOver + iOS Safari — a full purchase. 4) Zoom and reflow. 5) Third-party parts (the payment iframe): I test them, file non-conformance as a vendor issue and ask for their ACR — for the user it is still our checkout.

Результат. Список знахідок: критерій, крок, вплив, серйозність, докази. Спершу блокери, які не дають завершити покупку. Регресійні тести на виправлене.

Output. A findings list: criterion, step, impact, severity, evidence. Blockers that prevent completing a purchase come first. Regression tests for whatever gets fixed.

Автотест, що проходить чекаут і сканує кожен крок і стан
An automated test that walks checkout and scans every step and state
import { test, expect } from './fixtures.js';   // makeAxeBuilder з тегами WCAG 2.2 AA

test('чекаут: axe на кожному кроці та в станах помилок @a11y', async ({ page, makeAxeBuilder }) => {
  const findings = [];
  const scan = async (step) => {
    const { violations } = await makeAxeBuilder().exclude('iframe[name=payment]').analyze();
    findings.push(...violations.map((v) => ({ step, rule: v.id, impact: v.impact, nodes: v.nodes.length })));
  };

  await page.goto('/cart');                                   await scan('кошик');
  await page.getByRole('button', { name: 'Оформити замовлення' }).click();
  await page.getByRole('button', { name: 'Продовжити' }).click();       // порожня форма
  await expect(page.getByRole('alert')).toBeVisible();        await scan('адреса: помилки');
  await page.getByLabel('Місто').fill('Київ');
  await page.getByLabel('Вулиця, будинок').fill('Хрещатик, 1');
  await page.getByRole('button', { name: 'Продовжити' }).click();
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Доставка');
  await scan('доставка');
  await page.getByRole('radio', { name: /Кур'єр/ }).check();
  await page.getByRole('button', { name: 'Продовжити' }).click();
  await scan('оплата');

  console.table(findings);                                   // таблиця для звіту аудиту
  expect(findings.filter((f) => ['critical', 'serious'].includes(f.impact))).toEqual([]);
});
import { test, expect } from './fixtures.js';   // makeAxeBuilder with WCAG 2.2 AA tags

test('checkout: axe on every step and in error states @a11y', async ({ page, makeAxeBuilder }) => {
  const findings = [];
  const scan = async (step) => {
    const { violations } = await makeAxeBuilder().exclude('iframe[name=payment]').analyze();
    findings.push(...violations.map((v) => ({ step, rule: v.id, impact: v.impact, nodes: v.nodes.length })));
  };

  await page.goto('/cart');                                   await scan('cart');
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByRole('button', { name: 'Continue' }).click();         // empty form
  await expect(page.getByRole('alert')).toBeVisible();        await scan('address: errors');
  await page.getByLabel('City').fill('Kyiv');
  await page.getByLabel('Street and number').fill('Khreshchatyk 1');
  await page.getByRole('button', { name: 'Continue' }).click();
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Delivery');
  await scan('delivery');
  await page.getByRole('radio', { name: /Courier/ }).check();
  await page.getByRole('button', { name: 'Continue' }).click();
  await scan('payment');

  console.table(findings);                                   // a table for the audit report
  expect(findings.filter((f) => ['critical', 'serious'].includes(f.impact))).toEqual([]);
});
🔧 Розбір прикладу: Функція scan знімає результат axe після кожного кроку і в стані помилки, тож перевіряються не лише «чисті» сторінки. Платіжний iframe виключено зі скану, бо його перевіряють окремо як вендорську частину. Тест падає лише на critical і serious, а повна таблиця знахідок іде в звіт аудиту — ручну частину вона не замінює.
🔧 Example breakdown: The scan helper captures axe results after each step and in the error state, so it is not only the «clean» pages that get checked. The payment iframe is excluded from the scan because it is reviewed separately as the vendor's part. The test fails only on critical and serious, and the full findings table goes into the audit report — it does not replace the manual part.
💡 Простими словами: Аудит чекауту — це весь шлях покупки з усіма станами, автоматика плюс клавіатура плюс два скрінрідери, а головне питання — чи можна купити.
💡 In plain words: A checkout audit covers the whole purchase path with all its states — automation plus keyboard plus two screen readers — and the key question is whether you can buy.
🗣 Як читати вголос Я б визначив обсяг як увесь шлях покупки, від кошика до листа з чеком, разом зі станами помилок, промокодом і таймаутом. Ціль — WCAG 2.2 AA, з фокусом на формах, помилках, autocomplete, 3.3.7 і 3.3.8, клавіатурі й оголошенні змін суми. Метод: автотест, що проходить сценарій і сканує axe кожен крок, ручний прохід клавіатурою, повна покупка з NVDA і VoiceOver, масштаб. Платіжний iframe перевіряю окремо і прошу ACR у вендора. У звіті спершу блокери покупки.
🗣 How to say it out loud I would scope it as the entire purchase path, from the cart to the receipt email, including error states, promo codes and timeout. The target is WCAG 2.2 AA, focusing on forms, errors, autocomplete, 3.3.7 and 3.3.8, keyboard and announcing total changes. The method: an automated test that walks the flow and runs axe on each step, a manual keyboard pass, a full purchase with NVDA and VoiceOver, and zoom. I check the payment iframe separately and ask the vendor for an ACR. The report puts purchase blockers first.