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,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus visibility (SC 2.4.7) and focus-indicator contrast (SC 1.4.11).
|
|
3
|
+
*
|
|
4
|
+
* Method: read the computed style of every focusable element in its resting
|
|
5
|
+
* state, focus it, read the computed style again, and diff. A focus indicator
|
|
6
|
+
* exists only if something visibly CHANGED — an element that looks identical
|
|
7
|
+
* focused and unfocused has no indicator, whatever its stylesheet claims.
|
|
8
|
+
*
|
|
9
|
+
* The ring's own contrast against the colour behind it is SC 1.4.11 territory
|
|
10
|
+
* that essentially nothing automates. Where the adjacent background cannot be
|
|
11
|
+
* determined (background images, gradients) the finding is needs-review rather
|
|
12
|
+
* than a guess.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { makeFinding, SEVERITY } from '../finding.js';
|
|
16
|
+
import { checkContrast, parseColor, ColorParseError } from '../contrast-math.js';
|
|
17
|
+
import { resetFocusToDocumentStart, clearFocusSentinel } from './keyboard.js';
|
|
18
|
+
|
|
19
|
+
/** Computed properties compared between resting and focused state. */
|
|
20
|
+
export const WATCHED_PROPERTIES = [
|
|
21
|
+
'outlineStyle',
|
|
22
|
+
'outlineWidth',
|
|
23
|
+
'outlineColor',
|
|
24
|
+
'outlineOffset',
|
|
25
|
+
'boxShadow',
|
|
26
|
+
'borderTopWidth',
|
|
27
|
+
'borderTopColor',
|
|
28
|
+
'borderBottomWidth',
|
|
29
|
+
'borderBottomColor',
|
|
30
|
+
'backgroundColor',
|
|
31
|
+
'color',
|
|
32
|
+
'textDecorationLine',
|
|
33
|
+
'textDecorationColor',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Minimum contrast for a focus indicator, as a non-text visual object. */
|
|
37
|
+
export const RING_CONTRAST_MIN = 3;
|
|
38
|
+
|
|
39
|
+
const isTransparent = (value) =>
|
|
40
|
+
typeof value === 'string' &&
|
|
41
|
+
(value === 'transparent' || /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(value));
|
|
42
|
+
|
|
43
|
+
const hasWidth = (value) => Number.parseFloat(value) > 0;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Which watched properties changed on focus, described in plain terms.
|
|
47
|
+
*
|
|
48
|
+
* @param {Record<string,string>} base computed style at rest
|
|
49
|
+
* @param {Record<string,string>} focused computed style while focused
|
|
50
|
+
* @returns {{visible:boolean, indicators:string[], changed:string[]}}
|
|
51
|
+
*/
|
|
52
|
+
export function describeFocusChange(base = {}, focused = {}) {
|
|
53
|
+
const changed = WATCHED_PROPERTIES.filter((prop) => base[prop] !== focused[prop]);
|
|
54
|
+
const indicators = [];
|
|
55
|
+
|
|
56
|
+
const outlineAppeared =
|
|
57
|
+
focused.outlineStyle !== 'none' &&
|
|
58
|
+
hasWidth(focused.outlineWidth) &&
|
|
59
|
+
!isTransparent(focused.outlineColor) &&
|
|
60
|
+
(base.outlineStyle === 'none' ||
|
|
61
|
+
!hasWidth(base.outlineWidth) ||
|
|
62
|
+
isTransparent(base.outlineColor) ||
|
|
63
|
+
base.outlineColor !== focused.outlineColor ||
|
|
64
|
+
base.outlineWidth !== focused.outlineWidth);
|
|
65
|
+
if (outlineAppeared) indicators.push('outline');
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
focused.boxShadow &&
|
|
69
|
+
focused.boxShadow !== 'none' &&
|
|
70
|
+
base.boxShadow !== focused.boxShadow
|
|
71
|
+
) {
|
|
72
|
+
indicators.push('box-shadow');
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
base.borderTopWidth !== focused.borderTopWidth ||
|
|
76
|
+
base.borderTopColor !== focused.borderTopColor ||
|
|
77
|
+
base.borderBottomWidth !== focused.borderBottomWidth ||
|
|
78
|
+
base.borderBottomColor !== focused.borderBottomColor
|
|
79
|
+
) {
|
|
80
|
+
indicators.push('border');
|
|
81
|
+
}
|
|
82
|
+
if (base.backgroundColor !== focused.backgroundColor) indicators.push('background-color');
|
|
83
|
+
if (base.color !== focused.color) indicators.push('text colour');
|
|
84
|
+
if (
|
|
85
|
+
base.textDecorationLine !== focused.textDecorationLine ||
|
|
86
|
+
base.textDecorationColor !== focused.textDecorationColor
|
|
87
|
+
) {
|
|
88
|
+
indicators.push('text-decoration');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { visible: indicators.length > 0, indicators, changed };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The colour of the focus indicator itself, if one can be identified.
|
|
96
|
+
* @returns {string|null}
|
|
97
|
+
*/
|
|
98
|
+
export function indicatorColor(focused = {}, indicators = []) {
|
|
99
|
+
if (indicators.includes('outline') && !isTransparent(focused.outlineColor)) {
|
|
100
|
+
return focused.outlineColor;
|
|
101
|
+
}
|
|
102
|
+
if (indicators.includes('box-shadow')) {
|
|
103
|
+
const match = /(rgba?\([^)]*\))/.exec(focused.boxShadow ?? '');
|
|
104
|
+
if (match && !isTransparent(match[1])) return match[1];
|
|
105
|
+
}
|
|
106
|
+
if (indicators.includes('border') && !isTransparent(focused.borderTopColor)) {
|
|
107
|
+
return focused.borderTopColor;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Contrast of a focus ring against what sits behind it.
|
|
114
|
+
*
|
|
115
|
+
* @returns {{determinable:boolean, ratio?:number, ratioDisplay?:string, passes?:boolean}}
|
|
116
|
+
*/
|
|
117
|
+
export function ringContrast(ringColor, adjacentBackground) {
|
|
118
|
+
if (!ringColor || !adjacentBackground) return { determinable: false };
|
|
119
|
+
try {
|
|
120
|
+
parseColor(ringColor);
|
|
121
|
+
parseColor(adjacentBackground);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof ColorParseError) return { determinable: false };
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
const result = checkContrast(ringColor, adjacentBackground, { ui: true });
|
|
127
|
+
return {
|
|
128
|
+
determinable: true,
|
|
129
|
+
ratio: result.ratio,
|
|
130
|
+
ratioDisplay: result.ratioDisplay,
|
|
131
|
+
passes: result.passes.AA,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {Array} samples from `surveyFocusVisibility`
|
|
137
|
+
* @param {{passes:string[], state:string|null}} ctx
|
|
138
|
+
*/
|
|
139
|
+
export function focusVisibilityFindings(samples, ctx) {
|
|
140
|
+
const findings = [];
|
|
141
|
+
const { passes = [], state = null } = ctx ?? {};
|
|
142
|
+
|
|
143
|
+
for (const sample of samples) {
|
|
144
|
+
const change = describeFocusChange(sample.base, sample.focused);
|
|
145
|
+
|
|
146
|
+
if (!change.visible) {
|
|
147
|
+
findings.push(
|
|
148
|
+
makeFinding({
|
|
149
|
+
ruleId: 'focus-not-visible',
|
|
150
|
+
source: 'a11y-loop',
|
|
151
|
+
severity: SEVERITY.VIOLATION,
|
|
152
|
+
impact: 'serious',
|
|
153
|
+
sc: '2.4.7',
|
|
154
|
+
selector: sample.selector,
|
|
155
|
+
html: sample.html,
|
|
156
|
+
message:
|
|
157
|
+
'Nothing about this element changes visually when it receives keyboard focus ' +
|
|
158
|
+
'(no outline, box-shadow, border, background or text change). Keyboard users ' +
|
|
159
|
+
'cannot tell where they are.',
|
|
160
|
+
passes,
|
|
161
|
+
state,
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ring = indicatorColor(sample.focused, change.indicators);
|
|
168
|
+
const contrast = ringContrast(ring, sample.adjacentBackground);
|
|
169
|
+
|
|
170
|
+
if (!contrast.determinable) {
|
|
171
|
+
findings.push(
|
|
172
|
+
makeFinding({
|
|
173
|
+
ruleId: 'focus-indicator-contrast',
|
|
174
|
+
source: 'a11y-loop',
|
|
175
|
+
severity: SEVERITY.NEEDS_REVIEW,
|
|
176
|
+
impact: 'moderate',
|
|
177
|
+
sc: '1.4.11',
|
|
178
|
+
selector: sample.selector,
|
|
179
|
+
html: sample.html,
|
|
180
|
+
message:
|
|
181
|
+
`Focus indicator present (${change.indicators.join(', ')}), but its contrast ` +
|
|
182
|
+
'could not be computed — the colour behind it is an image, a gradient, or not ' +
|
|
183
|
+
'resolvable. Confirm the indicator is clearly visible against its surroundings.',
|
|
184
|
+
passes,
|
|
185
|
+
state,
|
|
186
|
+
data: { indicators: change.indicators, indicatorColor: ring },
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!contrast.passes) {
|
|
193
|
+
findings.push(
|
|
194
|
+
makeFinding({
|
|
195
|
+
ruleId: 'focus-indicator-contrast',
|
|
196
|
+
source: 'a11y-loop',
|
|
197
|
+
severity: SEVERITY.NEEDS_REVIEW,
|
|
198
|
+
impact: 'moderate',
|
|
199
|
+
sc: '1.4.11',
|
|
200
|
+
selector: sample.selector,
|
|
201
|
+
html: sample.html,
|
|
202
|
+
message:
|
|
203
|
+
`The focus indicator (${change.indicators.join(', ')}, ${ring}) has a contrast ` +
|
|
204
|
+
`ratio of ${contrast.ratioDisplay}:1 against the adjacent background ` +
|
|
205
|
+
`${sample.adjacentBackground}, below the ${RING_CONTRAST_MIN}:1 minimum for ` +
|
|
206
|
+
'non-text visual information.',
|
|
207
|
+
passes,
|
|
208
|
+
state,
|
|
209
|
+
data: {
|
|
210
|
+
indicators: change.indicators,
|
|
211
|
+
indicatorColor: ring,
|
|
212
|
+
adjacentBackground: sample.adjacentBackground,
|
|
213
|
+
ratio: contrast.ratio,
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return findings;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** How many Tab presses the focus walk will make. */
|
|
224
|
+
export const MAX_FOCUS_STEPS = 40;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Record resting and focused computed styles for each focusable element.
|
|
228
|
+
*
|
|
229
|
+
* Focus is driven with the Tab KEY rather than `element.focus()`, because
|
|
230
|
+
* `:focus-visible` is exactly the selector that distinguishes the two: Chromium
|
|
231
|
+
* applies it on keyboard focus and withholds it on programmatic focus for most
|
|
232
|
+
* element types. Calling `.focus()` would report missing focus rings on elements
|
|
233
|
+
* that style `:focus-visible` correctly.
|
|
234
|
+
*
|
|
235
|
+
* The cost is a second Tab walk (the keyboard check does its own). Both walks are
|
|
236
|
+
* read-only, so they cannot contaminate each other.
|
|
237
|
+
*
|
|
238
|
+
* @param {import('playwright').Page} page
|
|
239
|
+
*/
|
|
240
|
+
export async function surveyFocusVisibility(page) {
|
|
241
|
+
const resting = await page.evaluate((watched) => {
|
|
242
|
+
const helpers = window.__a11yLoop;
|
|
243
|
+
const nodes = window.tabbable ? window.tabbable.tabbable(helpers.tabbableRoot()) : [];
|
|
244
|
+
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
245
|
+
const read = (el) => {
|
|
246
|
+
const style = getComputedStyle(el);
|
|
247
|
+
const out = {};
|
|
248
|
+
for (const prop of watched) out[prop] = style[prop];
|
|
249
|
+
return out;
|
|
250
|
+
};
|
|
251
|
+
const map = {};
|
|
252
|
+
for (const el of nodes) {
|
|
253
|
+
if (!helpers.isVisible(el)) continue;
|
|
254
|
+
map[helpers.cssPath(el)] = {
|
|
255
|
+
html: helpers.shortHtml(el),
|
|
256
|
+
style: read(el),
|
|
257
|
+
adjacentBackground: helpers.backdropColor(el.parentElement ?? el),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return map;
|
|
261
|
+
}, WATCHED_PROPERTIES);
|
|
262
|
+
|
|
263
|
+
await resetFocusToDocumentStart(page);
|
|
264
|
+
|
|
265
|
+
const samples = [];
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
for (let i = 0; i < MAX_FOCUS_STEPS; i++) {
|
|
268
|
+
await page.keyboard.press('Tab');
|
|
269
|
+
const stop = await page.evaluate((watched) => {
|
|
270
|
+
const el = document.activeElement;
|
|
271
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
272
|
+
if (el.hasAttribute?.('data-a11y-loop-sentinel')) return null;
|
|
273
|
+
const style = getComputedStyle(el);
|
|
274
|
+
const focused = {};
|
|
275
|
+
for (const prop of watched) focused[prop] = style[prop];
|
|
276
|
+
return { selector: window.__a11yLoop.cssPath(el), focused };
|
|
277
|
+
}, WATCHED_PROPERTIES);
|
|
278
|
+
|
|
279
|
+
if (!stop) break;
|
|
280
|
+
if (seen.has(stop.selector)) break; // wrapped around
|
|
281
|
+
seen.add(stop.selector);
|
|
282
|
+
|
|
283
|
+
const base = resting[stop.selector];
|
|
284
|
+
if (!base) continue; // tabbable did not predict it; the keyboard check reports that
|
|
285
|
+
samples.push({
|
|
286
|
+
selector: stop.selector,
|
|
287
|
+
html: base.html,
|
|
288
|
+
base: base.style,
|
|
289
|
+
focused: stop.focused,
|
|
290
|
+
adjacentBackground: base.adjacentBackground,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await clearFocusSentinel(page);
|
|
295
|
+
return samples;
|
|
296
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard reachability and tab order (SC 2.1.1, 2.4.3).
|
|
3
|
+
*
|
|
4
|
+
* Two things axe cannot see, because it never presses a key:
|
|
5
|
+
* 1. Positive `tabindex` values — always a defect. They pull elements out of
|
|
6
|
+
* document order into a separate, brittle tab sequence.
|
|
7
|
+
* 2. The observed tab walk versus the set `tabbable()` says should be
|
|
8
|
+
* reachable. A mismatch means either something interactive is unreachable
|
|
9
|
+
* (a real failure) or the order diverges from DOM order (a judgment call
|
|
10
|
+
* for a human, so: needs review).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { makeFinding, SEVERITY } from '../finding.js';
|
|
14
|
+
|
|
15
|
+
/** How many Tab presses to walk before giving up. */
|
|
16
|
+
export const MAX_TAB_STEPS = 60;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array<{selector:string, html:string, tabindex:string}>} elements
|
|
20
|
+
* @returns {Array<{selector:string, html:string, tabindex:number}>}
|
|
21
|
+
*/
|
|
22
|
+
export function findPositiveTabindex(elements) {
|
|
23
|
+
return elements
|
|
24
|
+
.map((el) => ({ ...el, tabindex: Number.parseInt(el.tabindex, 10) }))
|
|
25
|
+
.filter((el) => Number.isFinite(el.tabindex) && el.tabindex > 0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compare what `tabbable()` says is reachable with what actually received focus.
|
|
30
|
+
*
|
|
31
|
+
* @param {{expected:string[], observed:string[]}} walk selectors, in order
|
|
32
|
+
* @returns {{unreachable:string[], unexpected:string[], divergesAt:number|null}}
|
|
33
|
+
*/
|
|
34
|
+
export function diffTabOrder({ expected = [], observed = [] } = {}) {
|
|
35
|
+
const observedSet = new Set(observed);
|
|
36
|
+
const expectedSet = new Set(expected);
|
|
37
|
+
|
|
38
|
+
const unreachable = expected.filter((sel) => !observedSet.has(sel));
|
|
39
|
+
const unexpected = observed.filter((sel) => !expectedSet.has(sel));
|
|
40
|
+
|
|
41
|
+
// Compare the order of the elements the two lists agree on.
|
|
42
|
+
const commonExpected = expected.filter((sel) => observedSet.has(sel));
|
|
43
|
+
const commonObserved = observed.filter((sel) => expectedSet.has(sel));
|
|
44
|
+
let divergesAt = null;
|
|
45
|
+
for (let i = 0; i < Math.min(commonExpected.length, commonObserved.length); i++) {
|
|
46
|
+
if (commonExpected[i] !== commonObserved[i]) {
|
|
47
|
+
divergesAt = i;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { unreachable, unexpected, divergesAt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build findings from a completed keyboard survey.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} survey
|
|
59
|
+
* @param {Array} survey.positiveTabindex
|
|
60
|
+
* @param {string[]} survey.expected
|
|
61
|
+
* @param {string[]} survey.observed
|
|
62
|
+
* @param {Array<{selector:string,html:string}>} [survey.expectedDetails]
|
|
63
|
+
* @param {{passes:string[], state:string|null}} ctx
|
|
64
|
+
*/
|
|
65
|
+
export function keyboardFindings(survey, ctx) {
|
|
66
|
+
const findings = [];
|
|
67
|
+
const { passes = [], state = null } = ctx ?? {};
|
|
68
|
+
|
|
69
|
+
const positiveTabindex = findPositiveTabindex(survey.positiveTabindex ?? []);
|
|
70
|
+
const positiveTabindexSelectors = new Set(positiveTabindex.map((el) => el.selector));
|
|
71
|
+
|
|
72
|
+
for (const el of positiveTabindex) {
|
|
73
|
+
findings.push(
|
|
74
|
+
makeFinding({
|
|
75
|
+
ruleId: 'positive-tabindex',
|
|
76
|
+
source: 'a11y-loop',
|
|
77
|
+
severity: SEVERITY.VIOLATION,
|
|
78
|
+
impact: 'serious',
|
|
79
|
+
sc: '2.4.3',
|
|
80
|
+
selector: el.selector,
|
|
81
|
+
html: el.html,
|
|
82
|
+
message:
|
|
83
|
+
`tabindex="${el.tabindex}" forces this element out of document order into a ` +
|
|
84
|
+
'separate tab sequence. Use tabindex="0" (or no tabindex) and order the DOM instead.',
|
|
85
|
+
passes,
|
|
86
|
+
state,
|
|
87
|
+
data: { tabindex: el.tabindex },
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const diff = diffTabOrder(survey);
|
|
93
|
+
const detailFor = (selector) =>
|
|
94
|
+
(survey.expectedDetails ?? []).find((d) => d.selector === selector)?.html ?? '';
|
|
95
|
+
|
|
96
|
+
// A positive-tabindex element sorts into an earlier phase of the browser's
|
|
97
|
+
// focus order than anything a forward-only walk anchored at document start
|
|
98
|
+
// can reach — the walk can never observe it, however reachable it really is,
|
|
99
|
+
// because there is no tabindex value that sorts before a positive one. That
|
|
100
|
+
// makes it look "unreachable" when the real defect (and the only one worth
|
|
101
|
+
// reporting) is already captured, more precisely, by positive-tabindex above.
|
|
102
|
+
// Reporting both would double-count the same element under two rule ids and
|
|
103
|
+
// mislabel a "reachable too early" problem as "unreachable".
|
|
104
|
+
for (const selector of diff.unreachable.filter((sel) => !positiveTabindexSelectors.has(sel))) {
|
|
105
|
+
findings.push(
|
|
106
|
+
makeFinding({
|
|
107
|
+
ruleId: 'keyboard-unreachable',
|
|
108
|
+
source: 'a11y-loop',
|
|
109
|
+
severity: SEVERITY.VIOLATION,
|
|
110
|
+
impact: 'critical',
|
|
111
|
+
sc: '2.1.1',
|
|
112
|
+
selector,
|
|
113
|
+
html: detailFor(selector),
|
|
114
|
+
message:
|
|
115
|
+
'This element should be reachable by keyboard but never received focus during a ' +
|
|
116
|
+
`Tab walk of ${MAX_TAB_STEPS} steps. Check for aria-hidden, display:none on focus, ` +
|
|
117
|
+
'a focus trap earlier in the page, or tabindex="-1".',
|
|
118
|
+
passes,
|
|
119
|
+
state,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (diff.divergesAt !== null) {
|
|
125
|
+
findings.push(
|
|
126
|
+
makeFinding({
|
|
127
|
+
ruleId: 'focus-order-diverges',
|
|
128
|
+
source: 'a11y-loop',
|
|
129
|
+
severity: SEVERITY.NEEDS_REVIEW,
|
|
130
|
+
impact: 'moderate',
|
|
131
|
+
sc: '2.4.3',
|
|
132
|
+
selector: survey.observed?.[diff.divergesAt] ?? 'html',
|
|
133
|
+
html: '',
|
|
134
|
+
message:
|
|
135
|
+
'The observed tab order diverges from DOM order at position ' +
|
|
136
|
+
`${diff.divergesAt + 1}. That is not automatically a failure — whether the order is ` +
|
|
137
|
+
'logical is a human judgment. Confirm the sequence still makes sense.',
|
|
138
|
+
passes,
|
|
139
|
+
state,
|
|
140
|
+
data: { expected: survey.expected, observed: survey.observed },
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return findings;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Put the sequential focus navigation starting point at the top of the
|
|
150
|
+
* currently reachable region.
|
|
151
|
+
*
|
|
152
|
+
* `blur()` alone is not enough: Chromium remembers the blurred element as the
|
|
153
|
+
* starting point, so the next Tab continues from there and the elements BEFORE it
|
|
154
|
+
* are never visited — which looks exactly like an unreachable control. This
|
|
155
|
+
* matters whenever a state setup function has clicked something, which is most of
|
|
156
|
+
* the time. A focused sentinel at the start of the body fixes the origin.
|
|
157
|
+
*
|
|
158
|
+
* "Top of the document" is not always `document.body`: if a native `<dialog>`
|
|
159
|
+
* is open, everything outside it is inert, so a sentinel inserted into `body`
|
|
160
|
+
* could never be focused at all. The sentinel goes into whichever element
|
|
161
|
+
* `tabbableRoot()` says is actually reachable right now.
|
|
162
|
+
*
|
|
163
|
+
* @param {import('playwright').Page} page
|
|
164
|
+
*/
|
|
165
|
+
export async function resetFocusToDocumentStart(page) {
|
|
166
|
+
await page.evaluate(() => {
|
|
167
|
+
document.querySelector('[data-a11y-loop-sentinel]')?.remove();
|
|
168
|
+
const sentinel = document.createElement('span');
|
|
169
|
+
sentinel.tabIndex = 0;
|
|
170
|
+
sentinel.setAttribute('data-a11y-loop-sentinel', '');
|
|
171
|
+
sentinel.style.cssText =
|
|
172
|
+
'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;';
|
|
173
|
+
const root = window.__a11yLoop ? window.__a11yLoop.tabbableRoot() : document.body;
|
|
174
|
+
root.insertBefore(sentinel, root.firstChild);
|
|
175
|
+
sentinel.focus();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Remove the sentinel so it can never appear in a report or a later pass. */
|
|
180
|
+
export async function clearFocusSentinel(page) {
|
|
181
|
+
await page.evaluate(() => document.querySelector('[data-a11y-loop-sentinel]')?.remove());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run the keyboard survey in a live page.
|
|
186
|
+
* @param {import('playwright').Page} page
|
|
187
|
+
*/
|
|
188
|
+
export async function surveyKeyboard(page) {
|
|
189
|
+
const expectedDetails = await page.evaluate(() => {
|
|
190
|
+
const helpers = window.__a11yLoop;
|
|
191
|
+
const nodes = window.tabbable ? window.tabbable.tabbable(helpers.tabbableRoot()) : [];
|
|
192
|
+
return nodes.map((el) => ({
|
|
193
|
+
selector: helpers.cssPath(el),
|
|
194
|
+
html: helpers.shortHtml(el),
|
|
195
|
+
}));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const positiveTabindex = await page.evaluate(() => {
|
|
199
|
+
const helpers = window.__a11yLoop;
|
|
200
|
+
return Array.from(document.querySelectorAll('[tabindex]'))
|
|
201
|
+
.filter((el) => helpers.isVisible(el))
|
|
202
|
+
.map((el) => ({
|
|
203
|
+
selector: helpers.cssPath(el),
|
|
204
|
+
html: helpers.shortHtml(el),
|
|
205
|
+
tabindex: el.getAttribute('tabindex'),
|
|
206
|
+
}));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await resetFocusToDocumentStart(page);
|
|
210
|
+
|
|
211
|
+
const observed = [];
|
|
212
|
+
const seen = new Set();
|
|
213
|
+
for (let i = 0; i < MAX_TAB_STEPS; i++) {
|
|
214
|
+
await page.keyboard.press('Tab');
|
|
215
|
+
const current = await page.evaluate(() => {
|
|
216
|
+
const el = document.activeElement;
|
|
217
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
218
|
+
if (el.hasAttribute?.('data-a11y-loop-sentinel')) return null;
|
|
219
|
+
return window.__a11yLoop.cssPath(el);
|
|
220
|
+
});
|
|
221
|
+
if (current === null) break;
|
|
222
|
+
if (seen.has(current)) break; // wrapped around
|
|
223
|
+
seen.add(current);
|
|
224
|
+
observed.push(current);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await clearFocusSentinel(page);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
expected: expectedDetails.map((d) => d.selector),
|
|
231
|
+
expectedDetails,
|
|
232
|
+
observed,
|
|
233
|
+
positiveTabindex,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambiguous link text (SC 2.4.4 Link Purpose (In Context), Level A).
|
|
3
|
+
*
|
|
4
|
+
* Cheap heuristic, deliberately narrow. "Read more" IS allowed by SC 2.4.4 when
|
|
5
|
+
* the surrounding context makes the destination clear, and only a human can say
|
|
6
|
+
* whether it does — so every finding here is needs-review, never a violation.
|
|
7
|
+
* The phrase list is kept short on purpose: a broad list produces noise, and
|
|
8
|
+
* noise is what makes agents ignore reports.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { makeFinding, SEVERITY } from '../finding.js';
|
|
12
|
+
|
|
13
|
+
/** Exact accessible names treated as uninformative on their own. */
|
|
14
|
+
export const AMBIGUOUS_PHRASES = ['click here', 'read more', 'learn more', 'here', 'more'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalise an accessible name for comparison: lowercase, strip surrounding
|
|
18
|
+
* punctuation and trailing chevrons/arrows, collapse whitespace.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeLinkText(text) {
|
|
21
|
+
if (typeof text !== 'string') return '';
|
|
22
|
+
return text
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[→⇒›»>]+/g, ' ')
|
|
25
|
+
.replace(/[.!?:,;…]+/g, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @returns {boolean} */
|
|
31
|
+
export function isAmbiguousLinkText(text) {
|
|
32
|
+
return AMBIGUOUS_PHRASES.includes(normalizeLinkText(text));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {Array<{selector:string, html:string, name:string, context?:string}>} links
|
|
37
|
+
* @param {{passes:string[], state:string|null}} ctx
|
|
38
|
+
*/
|
|
39
|
+
export function linkTextFindings(links = [], ctx) {
|
|
40
|
+
const { passes = [], state = null } = ctx ?? {};
|
|
41
|
+
return links
|
|
42
|
+
.filter((link) => isAmbiguousLinkText(link.name))
|
|
43
|
+
.map((link) =>
|
|
44
|
+
makeFinding({
|
|
45
|
+
ruleId: 'ambiguous-link-text',
|
|
46
|
+
source: 'a11y-loop',
|
|
47
|
+
severity: SEVERITY.NEEDS_REVIEW,
|
|
48
|
+
impact: 'moderate',
|
|
49
|
+
sc: '2.4.4',
|
|
50
|
+
selector: link.selector,
|
|
51
|
+
html: link.html,
|
|
52
|
+
message:
|
|
53
|
+
`Link text is "${link.name.trim()}", which does not describe the destination. ` +
|
|
54
|
+
'SC 2.4.4 allows this when the surrounding context supplies the purpose, and only a ' +
|
|
55
|
+
'human can judge that — so confirm the context, or name the destination in the link ' +
|
|
56
|
+
'(e.g. "Read more about the speaker line-up").',
|
|
57
|
+
passes,
|
|
58
|
+
state,
|
|
59
|
+
data: { name: link.name.trim(), context: link.context ?? null },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collect visible links and their accessible names.
|
|
66
|
+
* @param {import('playwright').Page} page
|
|
67
|
+
*/
|
|
68
|
+
export async function surveyLinks(page) {
|
|
69
|
+
return page.evaluate(() => {
|
|
70
|
+
const helpers = window.__a11yLoop;
|
|
71
|
+
return Array.from(document.querySelectorAll('a[href], [role="link"]'))
|
|
72
|
+
.filter((el) => helpers.isVisible(el))
|
|
73
|
+
.map((el) => ({
|
|
74
|
+
selector: helpers.cssPath(el),
|
|
75
|
+
html: helpers.shortHtml(el),
|
|
76
|
+
name: helpers.accessibleName(el),
|
|
77
|
+
context: (el.closest('li, p, td, h1, h2, h3, h4, section')?.textContent ?? '')
|
|
78
|
+
.replace(/\s+/g, ' ')
|
|
79
|
+
.trim()
|
|
80
|
+
.slice(0, 120),
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
}
|