a11y-loop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +409 -0
- package/THIRD-PARTY-NOTICES.md +32 -0
- package/package.json +51 -0
- package/skill/a11y-loop/SKILL.md +332 -0
- package/skill/a11y-loop/evals/evals.json +168 -0
- package/skill/a11y-loop/evals/trigger-evals.json +20 -0
- package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
- package/skill/a11y-loop/references/apg-patterns.md +264 -0
- package/skill/a11y-loop/references/manual-testing.md +224 -0
- package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
- package/src/cli.js +207 -0
- package/src/commands/audit.js +125 -0
- package/src/commands/contrast.js +141 -0
- package/src/commands/diff.js +65 -0
- package/src/lib/axe-runner.js +400 -0
- package/src/lib/browser-utils.js +221 -0
- package/src/lib/checks/dialog.js +341 -0
- package/src/lib/checks/div-button.js +87 -0
- package/src/lib/checks/focus-visible.js +296 -0
- package/src/lib/checks/keyboard.js +235 -0
- package/src/lib/checks/link-text.js +83 -0
- package/src/lib/checks/reduced-motion.js +139 -0
- package/src/lib/checks/reflow.js +101 -0
- package/src/lib/checks/target-size.js +128 -0
- package/src/lib/contrast-math.js +189 -0
- package/src/lib/diff.js +118 -0
- package/src/lib/finding.js +164 -0
- package/src/lib/fingerprint.js +0 -0
- package/src/lib/format/checklist.js +281 -0
- package/src/lib/format/human.js +175 -0
- package/src/lib/format/json.js +139 -0
- package/src/lib/format/sarif.js +111 -0
- package/src/lib/serve.js +189 -0
- package/src/lib/suggest-color.js +169 -0
- package/src/lib/wcag-map.js +271 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Accessibility failure modes of generated UI code
|
|
2
|
+
|
|
3
|
+
These are not edge cases. They are what the training data contains, so they are
|
|
4
|
+
what gets generated unless you actively resist them.
|
|
5
|
+
|
|
6
|
+
- 84% of LLM-generated websites in a controlled study had accessibility issues
|
|
7
|
+
([W4A'24](https://doi.org/10.1145/3677846.3677854)).
|
|
8
|
+
- Accessibility-oriented *prompts alone* produced a slightly **worse** violation
|
|
9
|
+
rate than agnostic prompts — 17.32% vs 15.93%
|
|
10
|
+
([W4A'25](https://doi.org/10.1145/3744257.3744266)). Instructions without
|
|
11
|
+
verification do not work.
|
|
12
|
+
- "Bad patterns are not edge cases in the training set. They *are* the training
|
|
13
|
+
set." — Simon Miner (IAAP), *AI-Generated Code is Inaccessible by Default*.
|
|
14
|
+
- **Broken ARIA references**, hallucinated image descriptions, and misleading
|
|
15
|
+
compliance claims are documented as *emergent, LLM-specific* failure modes
|
|
16
|
+
(arXiv:2605.13873, a 2026 review of 33 studies).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 1. The clickable div — SC 2.1.1 (A), 4.1.2 (A)
|
|
21
|
+
|
|
22
|
+
```html
|
|
23
|
+
<!-- WRONG --> <div class="btn" onclick="submit()">Save</div>
|
|
24
|
+
<!-- RIGHT --> <button type="button" class="btn" onclick="submit()">Save</button>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A `div` is not focusable, is not announced as a control, ignores Enter and Space,
|
|
28
|
+
and has no disabled state. `role="button"` plus `tabindex="0"` plus two key
|
|
29
|
+
handlers reproduces perhaps half of what `<button>` already does. Set `type`
|
|
30
|
+
explicitly — a `<button>` in a form defaults to `submit`.
|
|
31
|
+
|
|
32
|
+
## 2. Icon-only control with no accessible name — SC 4.1.2 (A), 2.4.4 (A)
|
|
33
|
+
|
|
34
|
+
Empty buttons appear on 30.6% of home pages and empty links on 46.3%,
|
|
35
|
+
overwhelmingly because of this.
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<!-- WRONG -->
|
|
39
|
+
<button><svg><path d="..."/></svg></button>
|
|
40
|
+
<a href="/cart"><i class="icon-cart"></i></a>
|
|
41
|
+
<!-- RIGHT -->
|
|
42
|
+
<button aria-label="Close dialog"><svg aria-hidden="true"><path d="..."/></svg></button>
|
|
43
|
+
<a href="/cart"><span class="sr-only">Cart</span><i class="icon-cart" aria-hidden="true"></i></a>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Otherwise it announces as "button" with no name — unusable by voice control,
|
|
47
|
+
meaningless to a screen reader. Hide the decorative glyph so it cannot leak junk
|
|
48
|
+
into the name.
|
|
49
|
+
|
|
50
|
+
## 3. Placeholder as label — SC 1.3.1 (A), 3.3.2 (A), 4.1.2 (A)
|
|
51
|
+
|
|
52
|
+
Missing form labels appear on 51.0% of home pages.
|
|
53
|
+
|
|
54
|
+
```html
|
|
55
|
+
<!-- WRONG -->
|
|
56
|
+
<input type="email" placeholder="Email address">
|
|
57
|
+
<!-- RIGHT -->
|
|
58
|
+
<label for="email">Email address</label>
|
|
59
|
+
<input type="email" id="email" name="email" autocomplete="email">
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A placeholder vanishes as soon as typing starts, is usually low contrast, is not
|
|
63
|
+
reliably announced, and leaves the field nameless once filled. `autocomplete`
|
|
64
|
+
also earns SC 1.3.5. If the design forbids a visible label use `aria-label` — but
|
|
65
|
+
a visible label is the better interface for everyone.
|
|
66
|
+
|
|
67
|
+
## 4. Skipped heading levels — SC 1.3.1 (A), 2.4.6 (AA)
|
|
68
|
+
|
|
69
|
+
```html
|
|
70
|
+
<!-- WRONG --> <h1>Pricing</h1> <h4 class="text-sm font-semibold">Starter</h4>
|
|
71
|
+
<!-- RIGHT --> <h1>Pricing</h1> <h2 class="text-sm font-semibold">Starter</h2>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Generated markup picks the level by visual size, breaking the outline screen
|
|
75
|
+
reader users navigate by. Pick the level from the outline; set size in CSS.
|
|
76
|
+
|
|
77
|
+
## 5. `outline: none` with no replacement — SC 2.4.7 (AA), 1.4.11 (AA)
|
|
78
|
+
|
|
79
|
+
```css
|
|
80
|
+
/* WRONG */ button:focus, a:focus, input:focus { outline: none; }
|
|
81
|
+
/* RIGHT */ :focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Removing the outline makes the interface unusable by keyboard while looking fine
|
|
85
|
+
in a screenshot. `:focus-visible` shows the ring for keyboard use only. Confirm
|
|
86
|
+
the ring reaches 3:1 against its surroundings — including dark mode and forced
|
|
87
|
+
colors, where hard-coded ring colors disappear (use `currentColor` or
|
|
88
|
+
`Highlight`).
|
|
89
|
+
|
|
90
|
+
## 6. Broken ARIA references — SC 1.3.1 (A), 4.1.2 (A)
|
|
91
|
+
|
|
92
|
+
An id is referenced that was never rendered, was renamed, or is duplicated across
|
|
93
|
+
component instances.
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<!-- WRONG -->
|
|
97
|
+
<input aria-labelledby="email-label" aria-describedby="email-hint">
|
|
98
|
+
<span id="emailLabel">Email</span>
|
|
99
|
+
<!-- RIGHT -->
|
|
100
|
+
<span id="email-label">Email</span>
|
|
101
|
+
<input id="email" aria-labelledby="email-label" aria-describedby="email-hint">
|
|
102
|
+
<p id="email-hint">We only use this to send your receipt.</p>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A dangling reference is silently ignored, so the control ends up with **no**
|
|
106
|
+
name — worse than writing nothing, because it looks handled. In a component
|
|
107
|
+
rendered more than once per page, ids must be per-instance (`useId()`, a counter,
|
|
108
|
+
a prop), never hard-coded. Grep your diff: every `aria-labelledby`,
|
|
109
|
+
`aria-describedby`, `aria-controls`, `aria-owns`, and `for=` must resolve to one
|
|
110
|
+
element in the rendered page.
|
|
111
|
+
|
|
112
|
+
## 7. `aria-hidden` on something focusable — SC 1.3.1 (A), 4.1.2 (A)
|
|
113
|
+
|
|
114
|
+
Rule 4 of the four rules of ARIA use forbids this outright.
|
|
115
|
+
|
|
116
|
+
```html
|
|
117
|
+
<!-- WRONG -->
|
|
118
|
+
<button aria-hidden="true">Skip</button>
|
|
119
|
+
<div class="offscreen-menu" aria-hidden="true"><a href="/help">Help</a></div>
|
|
120
|
+
<!-- RIGHT -->
|
|
121
|
+
<button hidden>Skip</button>
|
|
122
|
+
<div class="offscreen-menu" inert hidden><a href="/help">Help</a></div>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`aria-hidden` removes an element from the accessibility tree but leaves it in the
|
|
126
|
+
tab order, so focus lands on something that announces nothing at all. Use
|
|
127
|
+
`hidden`, `display: none`, or `inert` — something that removes it from both.
|
|
128
|
+
|
|
129
|
+
## 8. A role whose behavior was never implemented — SC 2.1.1 (A), 4.1.2 (A)
|
|
130
|
+
|
|
131
|
+
"A role is a promise" (APG *Read Me First*).
|
|
132
|
+
|
|
133
|
+
```html
|
|
134
|
+
<!-- WRONG -->
|
|
135
|
+
<div role="tab" aria-selected="true">Overview</div>
|
|
136
|
+
<div role="tab">Billing</div>
|
|
137
|
+
<!-- RIGHT: implement the whole contract, or don't claim the role -->
|
|
138
|
+
<div role="tablist">
|
|
139
|
+
<button role="tab" id="t1" aria-selected="true" aria-controls="p1" tabindex="0">Overview</button>
|
|
140
|
+
<button role="tab" id="t2" aria-selected="false" aria-controls="p2" tabindex="-1">Billing</button>
|
|
141
|
+
</div>
|
|
142
|
+
<!-- plus arrow-key roving focus, Home/End, and aria-selected kept in sync -->
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`role="tab"` promises the arrow-key contract, roving tabindex, a `tablist`
|
|
146
|
+
parent, and linked panels. ARIA creates no behavior — the role only changes what
|
|
147
|
+
is announced, so an unimplemented contract produces a control that says it works
|
|
148
|
+
one way and works another. Contracts in [apg-patterns.md](apg-patterns.md); if
|
|
149
|
+
you cannot implement one, use links, buttons, or a disclosure.
|
|
150
|
+
|
|
151
|
+
## 9. Missing document language — SC 3.1.1 (A)
|
|
152
|
+
|
|
153
|
+
Still missing on 13.5% of home pages, and free to fix.
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<!-- WRONG --> <html>
|
|
157
|
+
<!-- RIGHT --> <html lang="en">
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Without it, screen readers use the user's default voice, so English content may
|
|
161
|
+
be read with, say, German phoneme rules. Mark inline language changes with `lang`
|
|
162
|
+
too (SC 3.1.2).
|
|
163
|
+
|
|
164
|
+
## 10. Animation that ignores reduced motion — SC 2.2.2 (A)
|
|
165
|
+
|
|
166
|
+
```css
|
|
167
|
+
/* WRONG */ .card { animation: slide-in 600ms ease-out; }
|
|
168
|
+
/* RIGHT */
|
|
169
|
+
.card { animation: slide-in 600ms ease-out; }
|
|
170
|
+
@media (prefers-reduced-motion: reduce) {
|
|
171
|
+
.card { animation: none; }
|
|
172
|
+
*, *::before, *::after {
|
|
173
|
+
animation-duration: 0.01ms !important;
|
|
174
|
+
transition-duration: 0.01ms !important;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Generated code adds motion enthusiastically and the media query almost never.
|
|
180
|
+
Reduce it genuinely — a global reset that a later rule overrides is worse than
|
|
181
|
+
none — and verify under the reduced-motion pass of the audit. This also matters
|
|
182
|
+
beyond WCAG: unwanted motion triggers vestibular symptoms.
|
|
183
|
+
|
|
184
|
+
## 11. Focus lost on SPA route change — SC 2.4.3 (A), 4.1.3 (AA)
|
|
185
|
+
|
|
186
|
+
```jsx
|
|
187
|
+
// WRONG
|
|
188
|
+
function Route({ children }) { return <main>{children}</main>; }
|
|
189
|
+
// RIGHT
|
|
190
|
+
function Route({ title, children }) {
|
|
191
|
+
const h = useRef(null);
|
|
192
|
+
useEffect(() => { h.current?.focus(); }, [title]);
|
|
193
|
+
return <main><h1 ref={h} tabIndex={-1}>{title}</h1>{children}</main>;
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Client-side navigation replaces the DOM without a page load, so focus falls back
|
|
198
|
+
to `<body>` — the keyboard user is dumped at the top of the document with no
|
|
199
|
+
announcement that anything changed. Move focus to the new heading (or a
|
|
200
|
+
`tabindex="-1"` container) and update `document.title`.
|
|
201
|
+
|
|
202
|
+
## 12. Dynamic content that is never announced — SC 4.1.3 (AA)
|
|
203
|
+
|
|
204
|
+
```jsx
|
|
205
|
+
// WRONG
|
|
206
|
+
{error && <p className="text-red-600">{error}</p>}
|
|
207
|
+
{results.length > 0 && <p>{results.length} results</p>}
|
|
208
|
+
// RIGHT
|
|
209
|
+
<p role="alert">{error}</p>
|
|
210
|
+
<p role="status">{results.length ? `${results.length} results` : ''}</p>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Text that appears without focus moving is invisible to a screen reader unless it
|
|
214
|
+
lands in a live region — and the region must exist in the DOM **before** the text
|
|
215
|
+
arrives, because mounting an already-populated `aria-live` element usually
|
|
216
|
+
announces nothing. Render the container always and change only its text.
|
|
217
|
+
`role="alert"` (assertive) for errors, `role="status"` (polite) for counts,
|
|
218
|
+
saves, and loading. Do not wrap the whole page in `aria-live`.
|
|
219
|
+
|
|
220
|
+
## 13. Positive `tabindex` — SC 2.4.3 (A)
|
|
221
|
+
|
|
222
|
+
```html
|
|
223
|
+
<!-- WRONG --> <input tabindex="1"> <input tabindex="2"> <button tabindex="3">Go</button>
|
|
224
|
+
<!-- RIGHT --> <input> <input> <button>Go</button>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Any positive `tabindex` jumps ahead of every natural element on the page and
|
|
228
|
+
decays the moment someone inserts a field. It is always a defect. Only `0` (make
|
|
229
|
+
focusable in natural order) and `-1` (programmatic target only) are legitimate.
|
|
230
|
+
|
|
231
|
+
## 14. Invented alt text — SC 1.1.1 (A)
|
|
232
|
+
|
|
233
|
+
```html
|
|
234
|
+
<!-- WRONG -->
|
|
235
|
+
<img src="/hero-2.jpg" alt="A team collaborating in a modern office">
|
|
236
|
+
<img src="/decorative-swirl.svg" alt="decorative image">
|
|
237
|
+
<!-- RIGHT -->
|
|
238
|
+
<img src="/hero-2.jpg" alt="DRAFT: two people reviewing a document at a desk">
|
|
239
|
+
<img src="/decorative-swirl.svg" alt="">
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
You cannot see the image. A plausible description of an image you have not seen
|
|
243
|
+
is a factual claim you have no basis for, and a wrong `alt` is worse than a
|
|
244
|
+
missing one because nothing will ever flag it. Meanwhile `alt="decorative image"`
|
|
245
|
+
passes every automated check and is strictly worse than `alt=""`. Write the best
|
|
246
|
+
draft you can from filename, surrounding copy, and purpose; label it **DRAFT**;
|
|
247
|
+
ask the user to confirm or replace it. Same rule for `aria-label` text on icons
|
|
248
|
+
whose meaning you inferred rather than read.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Self-review before saving a UI file
|
|
253
|
+
|
|
254
|
+
1. Any `div`/`span` with a click handler? → native element.
|
|
255
|
+
2. Every button and link has a non-empty accessible name?
|
|
256
|
+
3. Every input has `<label for>` or `aria-label`, plus `autocomplete` where it applies?
|
|
257
|
+
4. Heading levels descend without skipping, exactly one `<h1>`?
|
|
258
|
+
5. Any `outline: none` without a `:focus-visible` replacement?
|
|
259
|
+
6. Every `aria-*` id reference resolves; ids unique per instance?
|
|
260
|
+
7. No `aria-hidden` on anything focusable?
|
|
261
|
+
8. Every ARIA role's keyboard contract actually implemented?
|
|
262
|
+
9. `<html lang>` present?
|
|
263
|
+
10. Every animation has a `prefers-reduced-motion` branch?
|
|
264
|
+
11. Route changes move focus; async updates land in a pre-existing live region?
|
|
265
|
+
12. No positive `tabindex`?
|
|
266
|
+
13. All alt text and inferred labels marked DRAFT for human confirmation?
|
|
267
|
+
|
|
268
|
+
Then run the audit. This list is the setup, not the verification.
|
|
269
|
+
|
|
270
|
+
## Related
|
|
271
|
+
|
|
272
|
+
[wcag22-quick-ref.md](wcag22-quick-ref.md) · [apg-patterns.md](apg-patterns.md) · [manual-testing.md](manual-testing.md)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# ARIA patterns and their keyboard contracts
|
|
2
|
+
|
|
3
|
+
Source of truth: the
|
|
4
|
+
[ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) (APG), plus
|
|
5
|
+
[WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) for normative role
|
|
6
|
+
definitions and [ARIA in HTML](https://www.w3.org/TR/html-aria/) for which roles
|
|
7
|
+
are permitted on which elements. ARIA 1.2 is the stable baseline; treat 1.3
|
|
8
|
+
features (`aria-notify`, `sectionheader`, `aria-braillelabel`, `suggestion`,
|
|
9
|
+
`comment`, `mark`) as emerging and never depend on them.
|
|
10
|
+
|
|
11
|
+
**First check whether you need ARIA at all.** A `<button>`,
|
|
12
|
+
`<details>`/`<summary>`, an `<input type="radio">` group in a `<fieldset>`, a
|
|
13
|
+
`<select>`, or a `<dialog>` gives you the whole contract for free and cannot drift
|
|
14
|
+
out of sync. Reach for a pattern below only when the native element cannot do the
|
|
15
|
+
job.
|
|
16
|
+
|
|
17
|
+
**A role is a promise.** Declaring a role commits you to every keyboard
|
|
18
|
+
interaction, state, and focus behavior in its contract, and ARIA adds no behavior
|
|
19
|
+
by itself. Pages using ARIA average *more* failures than pages without it (WebAIM
|
|
20
|
+
Million 2026: 59.1 vs 42.0 errors per page) precisely because the promise usually
|
|
21
|
+
goes unkept. If you cannot implement the full contract, ship the native element
|
|
22
|
+
or the plain version.
|
|
23
|
+
|
|
24
|
+
## The 30 APG patterns
|
|
25
|
+
|
|
26
|
+
Accordion (Sections With Show/Hide Functionality) · Alert · Alert and Message
|
|
27
|
+
Dialogs · Breadcrumb · Button · Carousel (Slide Show or Image Rotator) ·
|
|
28
|
+
Checkbox · Combobox · Dialog (Modal) · Disclosure (Show/Hide) · Feed · Grid
|
|
29
|
+
(Interactive Tabular Data and Layout Containers) · Landmarks · Link · Listbox ·
|
|
30
|
+
Menu and Menubar · Menu Button · Meter · Radio Group · Slider · Slider
|
|
31
|
+
(Multi-Thumb) · Spinbutton · Switch · Table · Tabs · Toolbar · Tooltip · Tree
|
|
32
|
+
View · Treegrid · Window Splitter
|
|
33
|
+
|
|
34
|
+
Index: [w3.org/WAI/ARIA/apg/patterns](https://www.w3.org/WAI/ARIA/apg/patterns/)
|
|
35
|
+
|
|
36
|
+
**If the component you are building is not detailed below, open its APG page and
|
|
37
|
+
read the keyboard interaction table before writing any code.** The ten patterns
|
|
38
|
+
below cover most application UI; the other twenty (carousel, grid, treegrid,
|
|
39
|
+
slider, spinbutton, toolbar, tree view, window splitter, feed…) have longer
|
|
40
|
+
contracts that are easy to get wrong from memory.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Dialog (Modal)
|
|
45
|
+
|
|
46
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) · Prefer the
|
|
47
|
+
native `<dialog>` with `showModal()`, which gives you the top layer, backdrop,
|
|
48
|
+
inertness of the rest of the page, and Escape handling.
|
|
49
|
+
|
|
50
|
+
Roles and states: `role="dialog"` with `aria-modal="true"`; an accessible name
|
|
51
|
+
via `aria-labelledby` (pointing at the dialog's heading) or `aria-label`;
|
|
52
|
+
optionally `aria-describedby` for the body text.
|
|
53
|
+
|
|
54
|
+
| Key | Behavior |
|
|
55
|
+
|---|---|
|
|
56
|
+
| — (on open) | Focus moves into the dialog: the first focusable element, or the dialog container if there is nothing focusable |
|
|
57
|
+
| `Tab` | Cycles forward through focusable elements **inside the dialog only**, wrapping at the end |
|
|
58
|
+
| `Shift+Tab` | Cycles backward, wrapping at the start |
|
|
59
|
+
| `Escape` | Closes the dialog |
|
|
60
|
+
| — (on close) | Focus returns to the element that opened it |
|
|
61
|
+
|
|
62
|
+
Content outside the dialog must be inert (native `<dialog>`, the `inert`
|
|
63
|
+
attribute, or `aria-hidden` plus removal from the tab order — never `aria-hidden`
|
|
64
|
+
on a still-focusable element). For an alert dialog use `role="alertdialog"` with
|
|
65
|
+
initial focus on the least destructive button. Focus not returning to the trigger
|
|
66
|
+
on close is the most common defect in generated dialogs, and trivially
|
|
67
|
+
detectable.
|
|
68
|
+
|
|
69
|
+
## Tabs
|
|
70
|
+
|
|
71
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)
|
|
72
|
+
|
|
73
|
+
Roles and states: `role="tablist"` containing elements with `role="tab"`, each
|
|
74
|
+
with `aria-selected="true|false"` and `aria-controls` pointing at its
|
|
75
|
+
`role="tabpanel"`; each panel has `aria-labelledby` pointing back at its tab.
|
|
76
|
+
Add `aria-orientation="vertical"` for a vertical tablist.
|
|
77
|
+
|
|
78
|
+
Only one tab is in the tab order: `tabindex="0"` on the selected tab,
|
|
79
|
+
`tabindex="-1"` on the rest (roving tabindex).
|
|
80
|
+
|
|
81
|
+
| Key | Behavior |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `Tab` | Moves focus into the tablist (to the selected tab), then out to the active panel |
|
|
84
|
+
| `Right` / `Left` | Previous/next tab in a horizontal tablist, wrapping optional |
|
|
85
|
+
| `Down` / `Up` | Previous/next tab in a vertical tablist |
|
|
86
|
+
| `Home` / `End` | First / last tab |
|
|
87
|
+
| `Enter` / `Space` | Activates the focused tab — only in manual-activation mode |
|
|
88
|
+
| `Delete` | Optional, for closable tabs; move focus to a neighbouring tab |
|
|
89
|
+
|
|
90
|
+
Automatic activation (selection follows focus) is the APG default and is right
|
|
91
|
+
when switching panels is cheap. Use manual activation when panels load data.
|
|
92
|
+
|
|
93
|
+
## Accordion
|
|
94
|
+
|
|
95
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/)
|
|
96
|
+
|
|
97
|
+
Each header is a real `<button>` wrapped in the heading level appropriate to the
|
|
98
|
+
document outline, with `aria-expanded` and `aria-controls` pointing at its panel.
|
|
99
|
+
The panel may carry `role="region"` with `aria-labelledby` referencing the
|
|
100
|
+
button — helpful with few panels, noisy with many.
|
|
101
|
+
|
|
102
|
+
| Key | Behavior |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `Enter` / `Space` | Toggles the focused panel |
|
|
105
|
+
| `Tab` | Moves through headers and the content of expanded panels in DOM order |
|
|
106
|
+
| `Down` / `Up` | Optional: next/previous accordion header |
|
|
107
|
+
| `Home` / `End` | Optional: first/last accordion header |
|
|
108
|
+
|
|
109
|
+
Do not put `aria-expanded` on the heading; it belongs on the button. Do not
|
|
110
|
+
disable the button of an expanded panel unless the pattern genuinely forbids
|
|
111
|
+
collapsing all panels.
|
|
112
|
+
|
|
113
|
+
## Menu Button
|
|
114
|
+
|
|
115
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/) · Only for
|
|
116
|
+
*actions*. A list of links is navigation, not a menu — use a disclosure with a
|
|
117
|
+
`<ul>`. `role="menu"` misapplied to navigation is a common and confusing defect.
|
|
118
|
+
|
|
119
|
+
Roles and states: the trigger is a `<button>` with `aria-haspopup="true"`,
|
|
120
|
+
`aria-expanded`, and `aria-controls` referencing the menu; the menu is
|
|
121
|
+
`role="menu"` containing `role="menuitem"` (or `menuitemcheckbox`/`menuitemradio`).
|
|
122
|
+
|
|
123
|
+
| Key | Behavior |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `Enter` / `Space` / `Down` (on button) | Opens the menu, focus on the first item |
|
|
126
|
+
| `Up` (on button) | Opens the menu, focus on the last item |
|
|
127
|
+
| `Down` / `Up` (in menu) | Next / previous item, wrapping |
|
|
128
|
+
| `Home` / `End` | First / last item |
|
|
129
|
+
| printable character | Moves to the next item whose label starts with that character |
|
|
130
|
+
| `Enter` / `Space` | Activates the item, closes the menu, focus returns to the button |
|
|
131
|
+
| `Escape` | Closes the menu, focus returns to the button |
|
|
132
|
+
| `Tab` | Closes the menu and moves focus onward in the page |
|
|
133
|
+
|
|
134
|
+
Menu items use roving tabindex or `aria-activedescendant`; they are never in the
|
|
135
|
+
page tab order themselves.
|
|
136
|
+
|
|
137
|
+
## Combobox
|
|
138
|
+
|
|
139
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) · The most intricate
|
|
140
|
+
common pattern. If a plain `<select>` or an `<input list>` will do, use it.
|
|
141
|
+
|
|
142
|
+
Roles and states: `role="combobox"` on the text input with `aria-expanded`,
|
|
143
|
+
`aria-controls` referencing the popup, `aria-autocomplete="none|list|both"`, and
|
|
144
|
+
`aria-activedescendant` set to the id of the visually focused option while DOM
|
|
145
|
+
focus stays in the input. The popup is usually `role="listbox"` with
|
|
146
|
+
`role="option"` children carrying `aria-selected`. Label the input with `<label
|
|
147
|
+
for>`.
|
|
148
|
+
|
|
149
|
+
| Key | Behavior |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `Down` | Opens the popup if closed and moves to the first/next option |
|
|
152
|
+
| `Up` | Opens the popup and moves to the last/previous option |
|
|
153
|
+
| `Alt+Down` | Opens the popup without moving the active option |
|
|
154
|
+
| `Enter` | Accepts the active option, closes the popup, keeps focus in the input |
|
|
155
|
+
| `Escape` | Closes the popup; a second press may clear the input |
|
|
156
|
+
| `Home` / `End` | Move the text cursor within the input, not through options |
|
|
157
|
+
| printable characters | Edit the input value and filter the list |
|
|
158
|
+
| `Tab` | Accepts the active option (if any) and moves focus out |
|
|
159
|
+
|
|
160
|
+
DOM focus must stay in the input throughout — move the *visual* active option
|
|
161
|
+
with `aria-activedescendant`. Announce result counts in a live region so screen
|
|
162
|
+
reader users know filtering happened.
|
|
163
|
+
|
|
164
|
+
## Disclosure (Show/Hide)
|
|
165
|
+
|
|
166
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) · The simplest and
|
|
167
|
+
most under-used pattern. Native `<details>`/`<summary>` covers most cases.
|
|
168
|
+
|
|
169
|
+
A `<button>` with `aria-expanded="true|false"` and `aria-controls` referencing
|
|
170
|
+
the content, which follows the button in the DOM.
|
|
171
|
+
|
|
172
|
+
| Key | Behavior |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `Enter` / `Space` | Toggles the content |
|
|
175
|
+
|
|
176
|
+
That is the entire contract. No arrow keys, no roving tabindex, no focus
|
|
177
|
+
management. Reach for this before menu, before tabs, before dialog.
|
|
178
|
+
|
|
179
|
+
## Radio Group
|
|
180
|
+
|
|
181
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) · Strongly prefer native
|
|
182
|
+
`<input type="radio">` inputs sharing a `name`, inside a `<fieldset>` with a
|
|
183
|
+
`<legend>` — the browser then supplies the whole contract, grouping and
|
|
184
|
+
required-field semantics included. For the ARIA version: `role="radiogroup"` with
|
|
185
|
+
an accessible name, containing `role="radio"` elements with `aria-checked`, and
|
|
186
|
+
roving tabindex so only the checked radio is tabbable (the first, if none is).
|
|
187
|
+
|
|
188
|
+
| Key | Behavior |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `Tab` | Moves into the group (to the checked radio) and, next press, out of the group entirely |
|
|
191
|
+
| `Right` / `Down` | Moves focus to the next radio **and checks it**, wrapping |
|
|
192
|
+
| `Left` / `Up` | Moves focus to the previous radio and checks it, wrapping |
|
|
193
|
+
| `Space` | Checks the focused radio if it is not already checked |
|
|
194
|
+
|
|
195
|
+
Selection follows focus here, unlike a listbox. Never make each radio
|
|
196
|
+
separately tabbable.
|
|
197
|
+
|
|
198
|
+
## Switch
|
|
199
|
+
|
|
200
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/switch/)
|
|
201
|
+
|
|
202
|
+
Either `<button role="switch" aria-checked="true|false">` or `<input
|
|
203
|
+
type="checkbox" role="switch">`. A switch is on/off with immediate effect; a
|
|
204
|
+
checkbox is a selection a form submits. Do not use `aria-pressed` — that is a
|
|
205
|
+
toggle button.
|
|
206
|
+
|
|
207
|
+
| Key | Behavior |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `Space` | Toggles the switch |
|
|
210
|
+
| `Enter` | Toggles it too, in the `<button>` form |
|
|
211
|
+
|
|
212
|
+
The label must name the thing being controlled ("Email notifications"), not the
|
|
213
|
+
state ("Notifications on") — the state lives in `aria-checked`, and putting it in
|
|
214
|
+
the label makes the announcement contradict itself. `aria-checked` must be
|
|
215
|
+
updated in the same handler that changes the visuals. `role="switch"` has no
|
|
216
|
+
mixed state.
|
|
217
|
+
|
|
218
|
+
## Tooltip
|
|
219
|
+
|
|
220
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) · The APG warns this
|
|
221
|
+
pattern is under discussion and support is uneven; a visible text label or a
|
|
222
|
+
disclosure is usually better. The tooltip element has `role="tooltip"` and an
|
|
223
|
+
`id` referenced by the trigger's `aria-describedby`, and the trigger must itself
|
|
224
|
+
be focusable.
|
|
225
|
+
|
|
226
|
+
| Trigger | Behavior |
|
|
227
|
+
|---|---|
|
|
228
|
+
| hover | Shows the tooltip |
|
|
229
|
+
| focus | Shows the tooltip — required, not optional |
|
|
230
|
+
| `Escape` | Hides it while the trigger keeps focus |
|
|
231
|
+
|
|
232
|
+
SC 1.4.13 requires the tooltip to be dismissible without moving the pointer,
|
|
233
|
+
hoverable (the pointer can move onto the tooltip without it vanishing), and
|
|
234
|
+
persistent until dismissed or invalid. Never put interactive content — links,
|
|
235
|
+
buttons, form fields — inside a tooltip; it is unreachable. For an icon-only
|
|
236
|
+
button give the *name* with `aria-label` and use the tooltip as the visible
|
|
237
|
+
description; a tooltip alone is not an accessible name.
|
|
238
|
+
|
|
239
|
+
## Listbox
|
|
240
|
+
|
|
241
|
+
[APG](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) · Prefer `<select>`
|
|
242
|
+
unless you need custom option rendering or drag-reordering.
|
|
243
|
+
|
|
244
|
+
Roles and states: `role="listbox"` with an accessible name, containing
|
|
245
|
+
`role="option"` children with `aria-selected`; `aria-multiselectable="true"` for
|
|
246
|
+
multi-select; roving tabindex or `aria-activedescendant` for focus; `role="group"`
|
|
247
|
+
plus an `aria-label` to group options.
|
|
248
|
+
|
|
249
|
+
| Key | Behavior |
|
|
250
|
+
|---|---|
|
|
251
|
+
| `Down` / `Up` | Move the focused option |
|
|
252
|
+
| `Home` / `End` | First / last option |
|
|
253
|
+
| printable character | Type-ahead to the next matching option |
|
|
254
|
+
| `Space` | Toggles selection in a multi-select listbox |
|
|
255
|
+
| `Shift+Down` / `Shift+Up` | Extends the selection in a multi-select listbox |
|
|
256
|
+
| `Ctrl+A` | Optional: select or deselect all |
|
|
257
|
+
|
|
258
|
+
Single-select listboxes normally let selection follow focus. Multi-select must
|
|
259
|
+
not: focus and selection are independent, and `Space` commits.
|
|
260
|
+
|
|
261
|
+
## Related
|
|
262
|
+
|
|
263
|
+
[wcag22-quick-ref.md](wcag22-quick-ref.md) (the criteria behind these contracts) ·
|
|
264
|
+
[ai-failure-modes.md](ai-failure-modes.md) · [manual-testing.md](manual-testing.md)
|