@setsquare/review-sdk 0.0.1 → 0.1.1
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/NOTICE +3 -2
- package/README.md +287 -3
- package/dist/autoscan.d.ts +23 -1
- package/dist/autoscan.d.ts.map +1 -1
- package/dist/autoscan.js +220 -15
- package/dist/autoscan.js.map +1 -1
- package/dist/brand.d.ts +51 -2
- package/dist/brand.d.ts.map +1 -1
- package/dist/brand.js +560 -5
- package/dist/brand.js.map +1 -1
- package/dist/checks/context-change.d.ts +96 -0
- package/dist/checks/context-change.d.ts.map +1 -0
- package/dist/checks/context-change.js +655 -0
- package/dist/checks/context-change.js.map +1 -0
- package/dist/checks/focus.d.ts +154 -0
- package/dist/checks/focus.d.ts.map +1 -0
- package/dist/checks/focus.js +1123 -0
- package/dist/checks/focus.js.map +1 -0
- package/dist/checks/heuristics.d.ts +117 -0
- package/dist/checks/heuristics.d.ts.map +1 -0
- package/dist/checks/heuristics.js +961 -0
- package/dist/checks/heuristics.js.map +1 -0
- package/dist/checks/keyboard.d.ts +106 -0
- package/dist/checks/keyboard.d.ts.map +1 -0
- package/dist/checks/keyboard.js +452 -0
- package/dist/checks/keyboard.js.map +1 -0
- package/dist/checks/reflow.d.ts +75 -0
- package/dist/checks/reflow.d.ts.map +1 -0
- package/dist/checks/reflow.js +285 -0
- package/dist/checks/reflow.js.map +1 -0
- package/dist/checks/rule-pack.d.ts +1 -1
- package/dist/checks/rule-pack.d.ts.map +1 -1
- package/dist/checks/rule-pack.js +350 -45
- package/dist/checks/rule-pack.js.map +1 -1
- package/dist/checks/shortcuts.d.ts +71 -0
- package/dist/checks/shortcuts.d.ts.map +1 -0
- package/dist/checks/shortcuts.js +196 -0
- package/dist/checks/shortcuts.js.map +1 -0
- package/dist/checks/versions.d.ts.map +1 -1
- package/dist/checks/versions.js +33 -1
- package/dist/checks/versions.js.map +1 -1
- package/dist/contract.d.ts +32 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/covers.d.ts +56 -0
- package/dist/covers.d.ts.map +1 -0
- package/dist/covers.js +135 -0
- package/dist/covers.js.map +1 -0
- package/dist/dedupe.d.ts +23 -0
- package/dist/dedupe.d.ts.map +1 -1
- package/dist/dedupe.js +96 -2
- package/dist/dedupe.js.map +1 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +144 -0
- package/dist/document.js.map +1 -1
- package/dist/envelope.d.ts +8 -0
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +106 -16
- package/dist/envelope.js.map +1 -1
- package/dist/fixture.d.ts.map +1 -1
- package/dist/fixture.js +33 -4
- package/dist/fixture.js.map +1 -1
- package/dist/html-report.d.ts +24 -7
- package/dist/html-report.d.ts.map +1 -1
- package/dist/html-report.js +608 -136
- package/dist/html-report.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/insights.d.ts.map +1 -1
- package/dist/insights.js +9 -12
- package/dist/insights.js.map +1 -1
- package/dist/links.d.ts +43 -0
- package/dist/links.d.ts.map +1 -1
- package/dist/links.js +43 -0
- package/dist/links.js.map +1 -1
- package/dist/observe.d.ts +8 -0
- package/dist/observe.d.ts.map +1 -1
- package/dist/observe.js +45 -1
- package/dist/observe.js.map +1 -1
- package/dist/options.d.ts +53 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +3 -0
- package/dist/options.js.map +1 -1
- package/dist/reading-level.d.ts +22 -1
- package/dist/reading-level.d.ts.map +1 -1
- package/dist/reading-level.js +122 -19
- package/dist/reading-level.js.map +1 -1
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +31 -15
- package/dist/reporter.js.map +1 -1
- package/dist/scan.d.ts +36 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +16 -2
- package/dist/scan.js.map +1 -1
- package/dist/screens.d.ts +55 -4
- package/dist/screens.d.ts.map +1 -1
- package/dist/screens.js +243 -43
- package/dist/screens.js.map +1 -1
- package/dist/settle.d.ts +35 -0
- package/dist/settle.d.ts.map +1 -1
- package/dist/settle.js +43 -2
- package/dist/settle.js.map +1 -1
- package/dist/summary.d.ts +35 -0
- package/dist/summary.d.ts.map +1 -1
- package/dist/summary.js +248 -28
- package/dist/summary.js.map +1 -1
- package/dist/unscannable-reasons.d.ts +30 -0
- package/dist/unscannable-reasons.d.ts.map +1 -0
- package/dist/unscannable-reasons.js +53 -0
- package/dist/unscannable-reasons.js.map +1 -0
- package/dist/viewports.d.ts +105 -0
- package/dist/viewports.d.ts.map +1 -0
- package/dist/viewports.js +134 -0
- package/dist/viewports.js.map +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
import { FOCUS_APPEARANCE_HELP_URL, FOCUS_NOT_OBSCURED_ENHANCED_HELP_URL, FOCUS_NOT_OBSCURED_MINIMUM_HELP_URL, FOCUS_VISIBLE_HELP_URL, } from '../links.js';
|
|
2
|
+
import { sanitiseSelectors } from '../sanitise.js';
|
|
3
|
+
/**
|
|
4
|
+
* The focus checks: what happens when an element actually receives focus.
|
|
5
|
+
*
|
|
6
|
+
* This is the class of criterion an engine like axe structurally cannot reach.
|
|
7
|
+
* axe analyses one DOM snapshot; 2.4.11 and 2.4.12 are about the state the
|
|
8
|
+
* page enters *when something is focused*, which needs a browser being driven.
|
|
9
|
+
* We have the page, in the worker, mid-run — that is the whole difference, and
|
|
10
|
+
* it is the reason our own Consent page can argue that a sticky bottom bar
|
|
11
|
+
* covering the focused element is a textbook 2.4.11 failure. Until this
|
|
12
|
+
* existed, we were selling a claim our own report could not check.
|
|
13
|
+
*
|
|
14
|
+
* **Teardown only, and this is not a detail.** The other dynamic checks also
|
|
15
|
+
* run inside an awaited explicit `setsquare.scan()`, because injecting and
|
|
16
|
+
* removing a stylesheet fires no page code. Focusing does. Every `focus()`
|
|
17
|
+
* here runs the page's own focus, focusin and blur handlers, and a handler
|
|
18
|
+
* that opens a menu, marks a field dirty or starts a request has already run
|
|
19
|
+
* by the time we restore anything. Scroll position and the previously focused
|
|
20
|
+
* element are restored; side effects cannot be. So this runs only where the
|
|
21
|
+
* test is over and the page is about to be discarded.
|
|
22
|
+
*
|
|
23
|
+
* The cost of that choice, stated rather than hidden: a scan of a named state
|
|
24
|
+
* (`setsquare.scan(page, surface, { state: 'menu-open' })`) carries no focus
|
|
25
|
+
* evidence. In practice the failures this catches — cookie banners, sticky
|
|
26
|
+
* headers, chat widgets — are present in every state including the last one,
|
|
27
|
+
* so teardown sees them. If that turns out to be wrong it should change on
|
|
28
|
+
* evidence, not on a guess.
|
|
29
|
+
*/
|
|
30
|
+
// @2 added 2.4.7 Focus Visible and 2.4.13 Focus Appearance to the same walk.
|
|
31
|
+
export const SETSQUARE_FOCUS_CHECKS_VERSION = 'setsquare-focus@2';
|
|
32
|
+
export const FOCUS_OBSCURED_RULE_ID = 'setsquare-focus-obscured';
|
|
33
|
+
export const FOCUS_OBSCURED_ENHANCED_RULE_ID = 'setsquare-focus-obscured-enhanced';
|
|
34
|
+
export const FOCUS_VISIBLE_RULE_ID = 'setsquare-focus-visible';
|
|
35
|
+
export const FOCUS_APPEARANCE_RULE_ID = 'setsquare-focus-appearance';
|
|
36
|
+
/**
|
|
37
|
+
* 2.4.13's two normative numbers, from the SC itself.
|
|
38
|
+
*
|
|
39
|
+
* The indicator must be at least as large as a 2 CSS pixel thick perimeter of
|
|
40
|
+
* the unfocused component, and have a contrast ratio of at least 3:1 between
|
|
41
|
+
* its focused and unfocused pixels. They are constants here so the finding can
|
|
42
|
+
* state the threshold it measured against rather than asserting a verdict the
|
|
43
|
+
* reader has to take on trust.
|
|
44
|
+
*/
|
|
45
|
+
const APPEARANCE_MIN_THICKNESS = 2;
|
|
46
|
+
const APPEARANCE_MIN_CONTRAST = 3;
|
|
47
|
+
/**
|
|
48
|
+
* How many focusable elements one page's walk will focus.
|
|
49
|
+
*
|
|
50
|
+
* A ceiling, not a sample size: the AC for this check is that it "must not be
|
|
51
|
+
* able to cost a scan", and a data grid with two thousand focusable cells is a
|
|
52
|
+
* real page. The walk says how many it looked at and whether it stopped early,
|
|
53
|
+
* so a truncated walk is never reported as a complete one.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_ELEMENTS = 120;
|
|
56
|
+
/** Wall-clock ceiling for the in-page walk, whichever limit bites first. */
|
|
57
|
+
const BUDGET_MS = 1_500;
|
|
58
|
+
const FOCUSABLE = [
|
|
59
|
+
'a[href]',
|
|
60
|
+
'button',
|
|
61
|
+
'input',
|
|
62
|
+
'select',
|
|
63
|
+
'textarea',
|
|
64
|
+
'summary',
|
|
65
|
+
'[tabindex]',
|
|
66
|
+
'[contenteditable="true"]',
|
|
67
|
+
].join(',');
|
|
68
|
+
/**
|
|
69
|
+
* WCAG 2.4.11 Focus Not Obscured (Minimum, AA) and 2.4.12 (Enhanced, AAA).
|
|
70
|
+
*
|
|
71
|
+
* One walk, two thresholds — which is why they are one function. 2.4.11 asks
|
|
72
|
+
* that the focused element is not *entirely* hidden; 2.4.12 that *no part* of
|
|
73
|
+
* it is. A partly covered element therefore violates 2.4.12 and does not
|
|
74
|
+
* violate 2.4.11, and the two rules are separate so that stays true in the
|
|
75
|
+
* evidence rather than only in a comment.
|
|
76
|
+
*
|
|
77
|
+
* 2.4.12 is AAA, so its rule is emitted only when the run targets AAA —
|
|
78
|
+
* the same scoping as the in-page rule pack and axe's own three AAA rules.
|
|
79
|
+
*/
|
|
80
|
+
export async function runFocusNotObscured(page, target) {
|
|
81
|
+
let walk;
|
|
82
|
+
try {
|
|
83
|
+
walk = await page.evaluate(({ selector, maxElements, budgetMs }) => {
|
|
84
|
+
const startedAt = performance.now();
|
|
85
|
+
const previouslyFocused = document.activeElement;
|
|
86
|
+
const scrollX = window.scrollX;
|
|
87
|
+
const scrollY = window.scrollY;
|
|
88
|
+
const obscured = [];
|
|
89
|
+
const ambiguous = [];
|
|
90
|
+
const indicators = [];
|
|
91
|
+
let checked = 0;
|
|
92
|
+
/*
|
|
93
|
+
* A selector someone can paste into devtools.
|
|
94
|
+
*
|
|
95
|
+
* The first version was `tag#id.class`, which on the fixture page
|
|
96
|
+
* produced the selector `a` — three occluded links collapsing to one
|
|
97
|
+
* useless row. An id or a class wins when there is one; otherwise it
|
|
98
|
+
* falls back to a short ancestor path with :nth-child, which is what
|
|
99
|
+
* axe does and for the same reason. Capped at three steps: past that a
|
|
100
|
+
* selector is longer than the devtools search box and nobody uses it.
|
|
101
|
+
*
|
|
102
|
+
* Structure only. No text content and no accessible name: for this
|
|
103
|
+
* check the name is not the finding (D13), and page-derived text is the
|
|
104
|
+
* one thing a selector must not smuggle out.
|
|
105
|
+
*/
|
|
106
|
+
const step = (el) => {
|
|
107
|
+
const tag = el.tagName.toLowerCase();
|
|
108
|
+
if (el.id)
|
|
109
|
+
return `${tag}#${el.id}`;
|
|
110
|
+
if (el.classList.length > 0)
|
|
111
|
+
return `${tag}.${el.classList[0]}`;
|
|
112
|
+
const parent = el.parentElement;
|
|
113
|
+
if (!parent)
|
|
114
|
+
return tag;
|
|
115
|
+
const index = Array.prototype.indexOf.call(parent.children, el) + 1;
|
|
116
|
+
return `${tag}:nth-child(${index})`;
|
|
117
|
+
};
|
|
118
|
+
const describe = (el) => {
|
|
119
|
+
const parts = [];
|
|
120
|
+
let node = el;
|
|
121
|
+
for (let depth = 0; node && depth < 3; depth += 1) {
|
|
122
|
+
parts.unshift(step(node));
|
|
123
|
+
// An id or a class is unique enough on its own; stop climbing.
|
|
124
|
+
if (node.id || node.classList.length > 0)
|
|
125
|
+
break;
|
|
126
|
+
node = node.parentElement;
|
|
127
|
+
if (!node || node === document.body)
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
return parts.join(' > ');
|
|
131
|
+
};
|
|
132
|
+
/** Does this element paint a background of its own, at full alpha? */
|
|
133
|
+
const paintsOpaquely = (cs) => {
|
|
134
|
+
if (cs.backdropFilter && cs.backdropFilter !== 'none')
|
|
135
|
+
return true;
|
|
136
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none')
|
|
137
|
+
return true;
|
|
138
|
+
const bg = cs.backgroundColor;
|
|
139
|
+
if (!bg || bg === 'transparent')
|
|
140
|
+
return false;
|
|
141
|
+
/*
|
|
142
|
+
* The alpha channel, if there is one — and there usually is not.
|
|
143
|
+
*
|
|
144
|
+
* The first version matched `rgba?\([^)]*,\s*([\d.]+)\s*\)` and
|
|
145
|
+
* compared the capture to 1, which for `rgb(26, 26, 26)` captures the
|
|
146
|
+
* BLUE channel. An opaque dark bar therefore read as translucent and
|
|
147
|
+
* the fixture page's guaranteed 2.4.11 failure came back as
|
|
148
|
+
* needs-review. Found by the fixture, which is the argument for the
|
|
149
|
+
* fixture: the unit lane cannot see a computed style.
|
|
150
|
+
*
|
|
151
|
+
* Components are split on commas, slashes and spaces so both
|
|
152
|
+
* `rgba(0, 0, 0, 0.5)` and the modern `rgb(0 0 0 / 50%)` parse. Fewer
|
|
153
|
+
* than four components means no alpha, which means opaque.
|
|
154
|
+
*/
|
|
155
|
+
const inside = /rgba?\(([^)]+)\)/.exec(bg);
|
|
156
|
+
if (!inside)
|
|
157
|
+
return true;
|
|
158
|
+
const parts = inside[1].split(/[,/\s]+/).filter(Boolean);
|
|
159
|
+
if (parts.length < 4)
|
|
160
|
+
return true;
|
|
161
|
+
const alpha = parts[3];
|
|
162
|
+
return alpha.endsWith('%') ? Number(alpha.slice(0, -1)) === 100 : Number(alpha) === 1;
|
|
163
|
+
};
|
|
164
|
+
/*
|
|
165
|
+
* What is actually covering the focused element, and whether it hides it.
|
|
166
|
+
*
|
|
167
|
+
* `elementFromPoint` returns the topmost element, which over a cookie
|
|
168
|
+
* bar is usually its BUTTON or its paragraph — and a button inside an
|
|
169
|
+
* opaque bar paints no background of its own. Testing that element
|
|
170
|
+
* alone reported every real occlusion as a judgement call, which is how
|
|
171
|
+
* the fixture page found this: the sticky bar came back `incomplete`
|
|
172
|
+
* instead of `violation`. So walk up from the hit and find the box that
|
|
173
|
+
* is doing the painting.
|
|
174
|
+
*
|
|
175
|
+
* The walk stops at the first ancestor that also contains the focused
|
|
176
|
+
* element: from there upwards is shared ancestry, which is behind the
|
|
177
|
+
* element rather than over it.
|
|
178
|
+
*
|
|
179
|
+
* `opacity` below 1 anywhere in that chain makes the whole subtree
|
|
180
|
+
* translucent, so it disqualifies the lot — deliberately conservative.
|
|
181
|
+
* A decorative overlay at 20% does not hide a focus ring; an opaque bar
|
|
182
|
+
* does, and only one of those is assertable from geometry.
|
|
183
|
+
*/
|
|
184
|
+
const coverer = (hit, focused) => {
|
|
185
|
+
let node = hit;
|
|
186
|
+
let painted;
|
|
187
|
+
let translucent = false;
|
|
188
|
+
while (node && !node.contains(focused)) {
|
|
189
|
+
const cs = window.getComputedStyle(node);
|
|
190
|
+
if (Number(cs.opacity) < 1)
|
|
191
|
+
translucent = true;
|
|
192
|
+
if (!painted && paintsOpaquely(cs))
|
|
193
|
+
painted = node;
|
|
194
|
+
node = node.parentElement;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
element: painted ?? hit,
|
|
198
|
+
opaque: Boolean(painted) && !translucent,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
/*
|
|
202
|
+
* Resolve any CSS colour to sRGB, by asking the browser.
|
|
203
|
+
*
|
|
204
|
+
* Parsing `rgb()` by hand covered the syntax computed style used to
|
|
205
|
+
* return, and stopped being enough the moment a stylesheet was
|
|
206
|
+
* authored in a modern colour space: Tailwind 4 writes `oklch()`, and
|
|
207
|
+
* Chrome keeps it in computed style rather than converting. The check
|
|
208
|
+
* then reported a perfectly ordinary focus ring as "painted in a
|
|
209
|
+
* colour that could not be parsed" — a parser limitation wearing the
|
|
210
|
+
* costume of an accessibility finding.
|
|
211
|
+
*
|
|
212
|
+
* A canvas resolves every syntax there will ever be, because it is the
|
|
213
|
+
* same code the page is painted with. Memoised on the string, since a
|
|
214
|
+
* run asks about the same half-dozen colours thousands of times.
|
|
215
|
+
*/
|
|
216
|
+
const colourCache = new Map();
|
|
217
|
+
const probe = document.createElement('canvas');
|
|
218
|
+
probe.width = 1;
|
|
219
|
+
probe.height = 1;
|
|
220
|
+
const probeCtx = probe.getContext('2d', { willReadFrequently: true });
|
|
221
|
+
const rgb = (value) => {
|
|
222
|
+
if (!value)
|
|
223
|
+
return undefined;
|
|
224
|
+
const trimmed = value.trim();
|
|
225
|
+
if (colourCache.has(trimmed))
|
|
226
|
+
return colourCache.get(trimmed);
|
|
227
|
+
let resolved;
|
|
228
|
+
if (probeCtx) {
|
|
229
|
+
try {
|
|
230
|
+
/*
|
|
231
|
+
* An invalid value leaves `fillStyle` unchanged, so it is probed
|
|
232
|
+
* from two different sentinels: a real colour lands on the same
|
|
233
|
+
* value from both, and a bad one keeps whichever sentinel it
|
|
234
|
+
* started from.
|
|
235
|
+
*/
|
|
236
|
+
probeCtx.fillStyle = '#000000';
|
|
237
|
+
probeCtx.fillStyle = trimmed;
|
|
238
|
+
const fromBlack = probeCtx.fillStyle;
|
|
239
|
+
probeCtx.fillStyle = '#ffffff';
|
|
240
|
+
probeCtx.fillStyle = trimmed;
|
|
241
|
+
if (fromBlack === probeCtx.fillStyle) {
|
|
242
|
+
probeCtx.clearRect(0, 0, 1, 1);
|
|
243
|
+
probeCtx.fillRect(0, 0, 1, 1);
|
|
244
|
+
const data = probeCtx.getImageData(0, 0, 1, 1).data;
|
|
245
|
+
resolved = [data[0], data[1], data[2], data[3] / 255];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
resolved = undefined;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!resolved) {
|
|
253
|
+
// No canvas — a policy blocking it, an exotic document. Fall back
|
|
254
|
+
// to the legacy syntax rather than losing the measurement outright.
|
|
255
|
+
const inside = /rgba?\(([^)]+)\)/.exec(trimmed);
|
|
256
|
+
if (inside) {
|
|
257
|
+
const parts = inside[1]
|
|
258
|
+
.split(/[,/\s]+/)
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.map((n) => (n.endsWith('%') ? Number(n.slice(0, -1)) / 100 : Number(n)));
|
|
261
|
+
if (parts.length >= 3 && !parts.some((n) => Number.isNaN(n))) {
|
|
262
|
+
resolved = [parts[0], parts[1], parts[2], parts.length > 3 ? parts[3] : 1];
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
colourCache.set(trimmed, resolved);
|
|
267
|
+
return resolved;
|
|
268
|
+
};
|
|
269
|
+
const luminance = (c) => {
|
|
270
|
+
const chan = (v) => {
|
|
271
|
+
const x = v / 255;
|
|
272
|
+
return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
|
273
|
+
};
|
|
274
|
+
return 0.2126 * chan(c[0]) + 0.7152 * chan(c[1]) + 0.0722 * chan(c[2]);
|
|
275
|
+
};
|
|
276
|
+
/** `a·fg + (1−a)·bg`, per channel. The colour a translucent band paints. */
|
|
277
|
+
const composite = (fg, bg) => {
|
|
278
|
+
const a = fg[3];
|
|
279
|
+
if (a >= 1)
|
|
280
|
+
return fg;
|
|
281
|
+
return [
|
|
282
|
+
a * fg[0] + (1 - a) * bg[0],
|
|
283
|
+
a * fg[1] + (1 - a) * bg[1],
|
|
284
|
+
a * fg[2] + (1 - a) * bg[2],
|
|
285
|
+
1,
|
|
286
|
+
];
|
|
287
|
+
};
|
|
288
|
+
const contrastOf = (a, b) => {
|
|
289
|
+
const la = luminance(a);
|
|
290
|
+
const lb = luminance(b);
|
|
291
|
+
const hi = Math.max(la, lb);
|
|
292
|
+
const lo = Math.min(la, lb);
|
|
293
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Split a computed `box-shadow` into its shadows.
|
|
297
|
+
*
|
|
298
|
+
* Computed style gives `rgb(…) 0px 0px 0px 2px` per shadow, comma
|
|
299
|
+
* separated — the colour first, then offset-x, offset-y, blur, spread.
|
|
300
|
+
* Commas appear inside the colour too, so the split is on commas that
|
|
301
|
+
* are not inside parentheses.
|
|
302
|
+
*/
|
|
303
|
+
const splitShadows = (value) => {
|
|
304
|
+
if (!value || value === 'none')
|
|
305
|
+
return [];
|
|
306
|
+
const parts = [];
|
|
307
|
+
let depth = 0;
|
|
308
|
+
let current = '';
|
|
309
|
+
for (const char of value) {
|
|
310
|
+
if (char === '(')
|
|
311
|
+
depth += 1;
|
|
312
|
+
if (char === ')')
|
|
313
|
+
depth -= 1;
|
|
314
|
+
if (char === ',' && depth === 0) {
|
|
315
|
+
parts.push(current);
|
|
316
|
+
current = '';
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
current += char;
|
|
320
|
+
}
|
|
321
|
+
parts.push(current);
|
|
322
|
+
return parts
|
|
323
|
+
.map((part) => part.trim())
|
|
324
|
+
.filter(Boolean)
|
|
325
|
+
.map((part) => {
|
|
326
|
+
const colour = rgb(part);
|
|
327
|
+
const lengths = (part.replace(/rgba?\([^)]*\)/, '').match(/-?[\d.]+px/g) ?? []).map((n) => parseFloat(n));
|
|
328
|
+
return {
|
|
329
|
+
colour,
|
|
330
|
+
// offset-x, offset-y, blur, spread — blur and spread are the
|
|
331
|
+
// third and fourth, and both default to zero when omitted.
|
|
332
|
+
blur: lengths[2] ?? 0,
|
|
333
|
+
spread: lengths[3] ?? 0,
|
|
334
|
+
inset: /\binset\b/.test(part),
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
/*
|
|
339
|
+
* The colour actually painted behind an element.
|
|
340
|
+
*
|
|
341
|
+
* Walks up until something paints. A gradient or an image gives no
|
|
342
|
+
* single value, and the honest answer there is that we could not
|
|
343
|
+
* resolve it — the same answer axe's own contrast rule gives, and for
|
|
344
|
+
* the same reason. Guessing an average would be inventing a
|
|
345
|
+
* measurement.
|
|
346
|
+
*/
|
|
347
|
+
const effectiveBackground = (start) => {
|
|
348
|
+
let node = start;
|
|
349
|
+
while (node) {
|
|
350
|
+
const cs = window.getComputedStyle(node);
|
|
351
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
352
|
+
return { unresolved: 'the background behind it is an image or a gradient' };
|
|
353
|
+
}
|
|
354
|
+
const colour = rgb(cs.backgroundColor);
|
|
355
|
+
if (colour && colour[3] === 1)
|
|
356
|
+
return { colour };
|
|
357
|
+
if (colour && colour[3] > 0 && colour[3] < 1) {
|
|
358
|
+
return { unresolved: 'the background behind it is semi-transparent' };
|
|
359
|
+
}
|
|
360
|
+
node = node.parentElement;
|
|
361
|
+
}
|
|
362
|
+
// Nothing in the chain painted: the canvas is the browser default,
|
|
363
|
+
// which we did not observe and will not assume.
|
|
364
|
+
return { unresolved: 'nothing in the ancestor chain paints a background' };
|
|
365
|
+
};
|
|
366
|
+
/*
|
|
367
|
+
* The properties a focus indicator can be drawn with, including both
|
|
368
|
+
* pseudo-elements — a custom ring built with `::after` is real, and a
|
|
369
|
+
* check that ignored it would report a violation against a page that
|
|
370
|
+
* has an indicator.
|
|
371
|
+
*/
|
|
372
|
+
const snapshot = (el) => {
|
|
373
|
+
const take = (pseudo, prefix) => {
|
|
374
|
+
const cs = window.getComputedStyle(el, pseudo);
|
|
375
|
+
return {
|
|
376
|
+
[`${prefix}outline`]: `${cs.outlineStyle} ${cs.outlineWidth} ${cs.outlineColor} ${cs.outlineOffset}`,
|
|
377
|
+
[`${prefix}box-shadow`]: cs.boxShadow,
|
|
378
|
+
[`${prefix}border`]: `${cs.borderWidth} ${cs.borderStyle} ${cs.borderColor}`,
|
|
379
|
+
[`${prefix}background-color`]: cs.backgroundColor,
|
|
380
|
+
[`${prefix}content`]: cs.content,
|
|
381
|
+
[`${prefix}transform`]: cs.transform,
|
|
382
|
+
[`${prefix}opacity`]: cs.opacity,
|
|
383
|
+
};
|
|
384
|
+
};
|
|
385
|
+
return {
|
|
386
|
+
...take(null, ''),
|
|
387
|
+
...take('::before', '::before '),
|
|
388
|
+
...take('::after', '::after '),
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
const all = Array.prototype.slice.call(document.querySelectorAll(selector));
|
|
392
|
+
const candidates = all.filter((el) => {
|
|
393
|
+
if (el.hasAttribute('disabled'))
|
|
394
|
+
return false;
|
|
395
|
+
if (el.getAttribute('aria-hidden') === 'true')
|
|
396
|
+
return false;
|
|
397
|
+
if (el.tabIndex < 0)
|
|
398
|
+
return false;
|
|
399
|
+
const cs = window.getComputedStyle(el);
|
|
400
|
+
if (cs.visibility === 'hidden' || cs.display === 'none')
|
|
401
|
+
return false;
|
|
402
|
+
const rect = el.getBoundingClientRect();
|
|
403
|
+
return rect.width > 0 && rect.height > 0;
|
|
404
|
+
});
|
|
405
|
+
let truncated = candidates.length > maxElements;
|
|
406
|
+
for (const el of candidates.slice(0, maxElements)) {
|
|
407
|
+
if (performance.now() - startedAt > budgetMs) {
|
|
408
|
+
truncated = true;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
/*
|
|
412
|
+
* Blur it first, or the "before" is already the "after".
|
|
413
|
+
*
|
|
414
|
+
* If the element the walk has reached is the one the test left
|
|
415
|
+
* focused, its unfocused snapshot is taken while it is focused,
|
|
416
|
+
* nothing appears to change, and 2.4.7 reports a page with a perfectly
|
|
417
|
+
* good focus ring as having none. Found in a captured envelope: the
|
|
418
|
+
* fixture page's `#tinted` button came back as a VIOLATION when the
|
|
419
|
+
* walk ran at teardown, having correctly reported needs-review when
|
|
420
|
+
* the same walk ran mid-test. The difference was which element
|
|
421
|
+
* happened to hold focus.
|
|
422
|
+
*
|
|
423
|
+
* The reflow is forced explicitly rather than trusted: computed style
|
|
424
|
+
* is live, but the blur and the read are in the same task.
|
|
425
|
+
*/
|
|
426
|
+
if (document.activeElement === el)
|
|
427
|
+
el.blur();
|
|
428
|
+
void el.offsetHeight;
|
|
429
|
+
const before = snapshot(el);
|
|
430
|
+
// Scrolled into view on purpose: focusing scrolls, and the scrolled
|
|
431
|
+
// position is the state a keyboard user is actually in. Measuring
|
|
432
|
+
// before the scroll would answer a question nobody is asking.
|
|
433
|
+
el.focus();
|
|
434
|
+
if (document.activeElement !== el)
|
|
435
|
+
continue;
|
|
436
|
+
checked += 1;
|
|
437
|
+
/* ---- 2.4.7 / 2.4.13: what did focusing change, and by how much? ---- */
|
|
438
|
+
const after = snapshot(el);
|
|
439
|
+
const changed = Object.keys(after).filter((key) => after[key] !== before[key]);
|
|
440
|
+
let focusVisible = false;
|
|
441
|
+
try {
|
|
442
|
+
focusVisible = el.matches(':focus-visible');
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// A browser without :focus-visible support cannot tell us, and the
|
|
446
|
+
// row is treated as unable to support a violation. Never a throw.
|
|
447
|
+
}
|
|
448
|
+
const focusedStyle = window.getComputedStyle(el);
|
|
449
|
+
/*
|
|
450
|
+
* MEASURE THE INDICATOR, BAND BY BAND.
|
|
451
|
+
*
|
|
452
|
+
* 2.4.13 asks two arithmetic questions: is an area of the indicator
|
|
453
|
+
* at least as large as a 2 CSS pixel perimeter of the unfocused
|
|
454
|
+
* component, and does that area reach 3:1 "between the same pixels
|
|
455
|
+
* in the focused and unfocused states". Both are computable, and an
|
|
456
|
+
* earlier version of this check declined to answer either.
|
|
457
|
+
*
|
|
458
|
+
* It got two things wrong, and they compounded.
|
|
459
|
+
*
|
|
460
|
+
* It measured only the OUTLINE, so a ring with a box-shadow layer
|
|
461
|
+
* was written off as unmeasurable — on the reasoning that a
|
|
462
|
+
* multi-layer indicator is not deterministic from computed style.
|
|
463
|
+
* That is not true: an outline has a width, an offset and a colour,
|
|
464
|
+
* a box-shadow has a spread and a colour, and every one of those is
|
|
465
|
+
* in computed style. The bands are adjacent rectangles and their
|
|
466
|
+
* areas are arithmetic.
|
|
467
|
+
*
|
|
468
|
+
* And it compared the indicator against "the colours beside it",
|
|
469
|
+
* taking the WORST. The SC says the same pixels in the focused and
|
|
470
|
+
* unfocused states, and it asks whether SOME area qualifies — so
|
|
471
|
+
* taking the worst layer of a two-layer ring is exactly backwards.
|
|
472
|
+
* The light inner band of the recommended two-layer pattern is 1:1
|
|
473
|
+
* against the page on purpose; the dark outer band is what carries
|
|
474
|
+
* the contrast, and the criterion is met by the dark one.
|
|
475
|
+
*
|
|
476
|
+
* The upshot: a focus ring built the way the guidance says to build
|
|
477
|
+
* one was reported as needing a human, on every focusable element of
|
|
478
|
+
* every page. Nothing about it needed a human.
|
|
479
|
+
*/
|
|
480
|
+
const rect = el.getBoundingClientRect();
|
|
481
|
+
const w = rect.width;
|
|
482
|
+
const h = rect.height;
|
|
483
|
+
/*
|
|
484
|
+
* The SC's bar: the area a 2px band along the component's perimeter
|
|
485
|
+
* occupies. `w·h − (w−4)(h−4)` for a 2px inset on all four sides.
|
|
486
|
+
*/
|
|
487
|
+
const requiredAreaPx = Math.max(0, Math.round(w * h - Math.max(0, w - 4) * Math.max(0, h - 4)));
|
|
488
|
+
/** Area of a band lying between `a` and `b` px outside the border box. */
|
|
489
|
+
const ringArea = (a, b) => Math.max(0, Math.round((w + 2 * b) * (h + 2 * b) - (w + 2 * a) * (h + 2 * a)));
|
|
490
|
+
/*
|
|
491
|
+
* What a band OUTSIDE the element covered before focus: whatever the
|
|
492
|
+
* parent paints. An approximation where the ring overlaps a sibling,
|
|
493
|
+
* and the standard one — the alternative is reading pixels, which is
|
|
494
|
+
* a different product.
|
|
495
|
+
*/
|
|
496
|
+
const outsideBefore = el.parentElement
|
|
497
|
+
? effectiveBackground(el.parentElement)
|
|
498
|
+
: { unresolved: 'the element has no parent whose background could be resolved' };
|
|
499
|
+
const layers = [];
|
|
500
|
+
const addLayer = (kind, thicknessPx, areaPx, focusedColour, beneath) => {
|
|
501
|
+
if (thicknessPx <= 0 || areaPx <= 0)
|
|
502
|
+
return;
|
|
503
|
+
if (!focusedColour) {
|
|
504
|
+
layers.push({
|
|
505
|
+
kind,
|
|
506
|
+
thicknessPx,
|
|
507
|
+
areaPx,
|
|
508
|
+
contrast: null,
|
|
509
|
+
unresolved: 'this band is painted in a colour that could not be parsed',
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// A band with zero alpha paints nothing, so it contributes no area
|
|
514
|
+
// rather than an unmeasurable one.
|
|
515
|
+
if (focusedColour[3] === 0)
|
|
516
|
+
return;
|
|
517
|
+
if (!beneath.colour) {
|
|
518
|
+
layers.push({
|
|
519
|
+
kind,
|
|
520
|
+
thicknessPx,
|
|
521
|
+
areaPx,
|
|
522
|
+
contrast: null,
|
|
523
|
+
unresolved: beneath.unresolved ?? 'what this band covers could not be resolved',
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
/*
|
|
528
|
+
* A TRANSLUCENT band composites onto what it covers, and the
|
|
529
|
+
* result is arithmetic rather than a judgement: `a·fg + (1−a)·bg`.
|
|
530
|
+
* Declining to measure it was the same mistake as declining to
|
|
531
|
+
* measure a two-layer ring — a component library that draws its
|
|
532
|
+
* focus ring at 50% alpha (Tailwind's `ring-ring/50`, say) is
|
|
533
|
+
* perfectly measurable against a known ground.
|
|
534
|
+
*/
|
|
535
|
+
const painted = composite(focusedColour, beneath.colour);
|
|
536
|
+
layers.push({
|
|
537
|
+
kind,
|
|
538
|
+
thicknessPx,
|
|
539
|
+
areaPx,
|
|
540
|
+
contrast: contrastOf(painted, beneath.colour),
|
|
541
|
+
});
|
|
542
|
+
};
|
|
543
|
+
/* --- the outline band --- */
|
|
544
|
+
let rowUnresolved;
|
|
545
|
+
if (focusedStyle.outlineStyle === 'auto') {
|
|
546
|
+
/*
|
|
547
|
+
* The browser's OWN ring. Its painted geometry is engine-specific
|
|
548
|
+
* and is not what `outline-width` reports — Chrome says 1px for a
|
|
549
|
+
* ring it draws as two bands — so this is the one thing here that
|
|
550
|
+
* genuinely cannot be measured from computed style. Named as such
|
|
551
|
+
* rather than guessed at.
|
|
552
|
+
*/
|
|
553
|
+
rowUnresolved =
|
|
554
|
+
"the indicator is the browser's own ring (outline-style: auto), whose painted " +
|
|
555
|
+
'geometry is engine-specific and is not what outline-width reports';
|
|
556
|
+
}
|
|
557
|
+
else if (focusedStyle.outlineStyle !== 'none') {
|
|
558
|
+
const width = parseFloat(focusedStyle.outlineWidth) || 0;
|
|
559
|
+
const offset = parseFloat(focusedStyle.outlineOffset) || 0;
|
|
560
|
+
// A negative offset draws the outline OVER the element, so what it
|
|
561
|
+
// covers is the element's own background rather than the parent's.
|
|
562
|
+
const beneath = offset < 0 ? effectiveBackground(el) : outsideBefore;
|
|
563
|
+
addLayer('outline', width, ringArea(Math.max(0, offset), Math.max(0, offset) + width), rgb(focusedStyle.outlineColor), beneath);
|
|
564
|
+
}
|
|
565
|
+
/* --- the box-shadow bands --- */
|
|
566
|
+
for (const shadow of splitShadows(focusedStyle.boxShadow)) {
|
|
567
|
+
if (shadow.inset)
|
|
568
|
+
continue;
|
|
569
|
+
if (shadow.blur > 0) {
|
|
570
|
+
// A blurred edge is a gradient, so the band has no single colour
|
|
571
|
+
// and no crisp extent. The solid core could be approximated; a
|
|
572
|
+
// measurement nobody can reproduce is worse than saying so.
|
|
573
|
+
layers.push({
|
|
574
|
+
kind: 'box-shadow',
|
|
575
|
+
thicknessPx: shadow.spread,
|
|
576
|
+
areaPx: ringArea(0, shadow.spread),
|
|
577
|
+
contrast: null,
|
|
578
|
+
unresolved: 'this band is blurred, so it has no single colour or crisp extent',
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
addLayer('box-shadow', shadow.spread, ringArea(0, shadow.spread), shadow.colour, outsideBefore);
|
|
583
|
+
}
|
|
584
|
+
/* --- a border that changed colour --- */
|
|
585
|
+
const borderWidth = parseFloat(focusedStyle.borderTopWidth) || 0;
|
|
586
|
+
if (borderWidth > 0 && before['border'] !== after['border']) {
|
|
587
|
+
const beforeBorder = rgb(String(before['border'] ?? ''));
|
|
588
|
+
addLayer('border', borderWidth, Math.max(0, Math.round(w * h - Math.max(0, w - 2 * borderWidth) * Math.max(0, h - 2 * borderWidth))), rgb(focusedStyle.borderTopColor), beforeBorder
|
|
589
|
+
? { colour: beforeBorder }
|
|
590
|
+
: { unresolved: 'the unfocused border colour could not be resolved' });
|
|
591
|
+
}
|
|
592
|
+
/* --- a background that changed --- */
|
|
593
|
+
if (before['background-color'] !== after['background-color']) {
|
|
594
|
+
const beforeBg = rgb(String(before['background-color'] ?? ''));
|
|
595
|
+
addLayer('background', Math.min(w, h), Math.max(0, Math.round(w * h)), rgb(focusedStyle.backgroundColor), beforeBg
|
|
596
|
+
? { colour: beforeBg }
|
|
597
|
+
: { unresolved: 'the unfocused background colour could not be resolved' });
|
|
598
|
+
}
|
|
599
|
+
/*
|
|
600
|
+
* A ring drawn with a PSEUDO-ELEMENT is the one remaining geometry
|
|
601
|
+
* computed style cannot give us. `getComputedStyle(el, '::after')`
|
|
602
|
+
* has its colours and its inset, but a pseudo-element has no
|
|
603
|
+
* `getBoundingClientRect`, so its painted box needs layout we cannot
|
|
604
|
+
* ask for. Real indicator, unmeasurable area — needs-review, and
|
|
605
|
+
* narrowly, rather than the whole multi-layer case it used to sweep
|
|
606
|
+
* up with it.
|
|
607
|
+
*/
|
|
608
|
+
if (layers.length === 0 &&
|
|
609
|
+
!rowUnresolved &&
|
|
610
|
+
changed.some((key) => key.startsWith('::'))) {
|
|
611
|
+
rowUnresolved =
|
|
612
|
+
'the indicator is drawn with a ::before or ::after pseudo-element, which has no ' +
|
|
613
|
+
'box we can measure from computed style';
|
|
614
|
+
}
|
|
615
|
+
const qualifyingAreaPx = layers
|
|
616
|
+
.filter((layer) => layer.contrast !== null && layer.contrast >= 3)
|
|
617
|
+
.reduce((total, layer) => total + layer.areaPx, 0);
|
|
618
|
+
indicators.push({
|
|
619
|
+
selector: describe(el),
|
|
620
|
+
changed,
|
|
621
|
+
focusVisible,
|
|
622
|
+
requiredAreaPx,
|
|
623
|
+
layers,
|
|
624
|
+
qualifyingAreaPx,
|
|
625
|
+
...(rowUnresolved ? { unresolved: rowUnresolved } : {}),
|
|
626
|
+
});
|
|
627
|
+
/* ---------------- 2.4.11 / 2.4.12: what covers it ---------------- */
|
|
628
|
+
/*
|
|
629
|
+
* Corner samples are inset past the BORDER RADIUS, not by a pixel.
|
|
630
|
+
*
|
|
631
|
+
* A point one pixel inside the bounding box's corner is OUTSIDE a
|
|
632
|
+
* rounded element — nothing is painted there, `elementFromPoint`
|
|
633
|
+
* returns whatever is behind, and the check reports the element as
|
|
634
|
+
* partly covered by its own background. On a page of rounded buttons
|
|
635
|
+
* and cards that is every element, and 2.4.12 fails everywhere.
|
|
636
|
+
*
|
|
637
|
+
* Found on this repository's own marketing site, where the skip link
|
|
638
|
+
* — a rounded box sitting over the header on purpose, at z-index 100
|
|
639
|
+
* — was reported as covered by the header behind it. The middle of
|
|
640
|
+
* the link hit-tested as the link and its bottom-right corner did
|
|
641
|
+
* not, which is the signature of a radius rather than of an overlay.
|
|
642
|
+
*
|
|
643
|
+
* The edge midpoints need no inset: a radius never reaches them.
|
|
644
|
+
*/
|
|
645
|
+
const radius = (value) => {
|
|
646
|
+
const trimmed = value.split(' ')[0] ?? '0';
|
|
647
|
+
if (trimmed.endsWith('%')) {
|
|
648
|
+
return (Number(trimmed.slice(0, -1)) / 100) * Math.min(rect.width, rect.height);
|
|
649
|
+
}
|
|
650
|
+
return parseFloat(trimmed) || 0;
|
|
651
|
+
};
|
|
652
|
+
const focusedCs = window.getComputedStyle(el);
|
|
653
|
+
const cornerRadius = Math.max(radius(focusedCs.borderTopLeftRadius), radius(focusedCs.borderTopRightRadius), radius(focusedCs.borderBottomLeftRadius), radius(focusedCs.borderBottomRightRadius));
|
|
654
|
+
/*
|
|
655
|
+
* SAMPLE THE LINE BOXES, NOT THE BOUNDING BOX.
|
|
656
|
+
*
|
|
657
|
+
* `getBoundingClientRect()` of an inline element that WRAPS is the
|
|
658
|
+
* union of its line boxes, and a union is not a shape. A link that
|
|
659
|
+
* breaks after its first word has a union spanning both lines, most
|
|
660
|
+
* of whose area belongs to the text around it — so the sample grid
|
|
661
|
+
* puts points on that text and the check hit-tests something it was
|
|
662
|
+
* never over.
|
|
663
|
+
*
|
|
664
|
+
* The ancestor guard below does not save it, because what is at those
|
|
665
|
+
* points is a SIBLING: on this repository's own accessibility page at
|
|
666
|
+
* 390 CSS pixels, the top-edge midpoint of a wrapped link landed on a
|
|
667
|
+
* `<strong>` earlier in the same paragraph. The strong paints no
|
|
668
|
+
* background, so it could not be shown to be opaque, and the link was
|
|
669
|
+
* reported as needing a person to judge whether its focus ring was
|
|
670
|
+
* still visible. The ring was entirely visible; nothing was over it
|
|
671
|
+
* at all. Four such findings across two surfaces, in a run that was
|
|
672
|
+
* otherwise clean, and none of them visible at 1280 — where the link
|
|
673
|
+
* fits on one line and the union IS its shape.
|
|
674
|
+
*
|
|
675
|
+
* `getClientRects()` returns one rect per line box, so every point
|
|
676
|
+
* built from them is on the element. For anything that does not wrap
|
|
677
|
+
* it returns a single rect equal to the bounding box and nothing
|
|
678
|
+
* about this changes.
|
|
679
|
+
*
|
|
680
|
+
* At most three boxes, largest first. A paragraph-length link at 320
|
|
681
|
+
* can wrap eight times, and nine hit tests per line is a cost with no
|
|
682
|
+
* answer attached to it — the widest lines are where an overlay would
|
|
683
|
+
* be found anyway.
|
|
684
|
+
*/
|
|
685
|
+
const boxes = Array.from(el.getClientRects())
|
|
686
|
+
.filter((box) => box.width > 0 && box.height > 0)
|
|
687
|
+
.sort((a, b) => b.width * b.height - a.width * a.height)
|
|
688
|
+
.slice(0, 3);
|
|
689
|
+
const sampleBoxes = boxes.length > 0 ? boxes : [rect];
|
|
690
|
+
const points = [];
|
|
691
|
+
for (const box of sampleBoxes) {
|
|
692
|
+
const inset = Math.min(cornerRadius, box.width / 2 - 1, box.height / 2 - 1) + 1;
|
|
693
|
+
const x0 = box.left + inset;
|
|
694
|
+
const x1 = box.left + box.width / 2;
|
|
695
|
+
const x2 = box.right - inset;
|
|
696
|
+
const y0 = box.top + inset;
|
|
697
|
+
const y1 = box.top + box.height / 2;
|
|
698
|
+
const y2 = box.bottom - inset;
|
|
699
|
+
points.push([x0, y0], [x1, box.top + 1], [x2, y0], [box.left + 1, y1], [x1, y1], [box.right - 1, y1], [x0, y2], [x1, box.bottom - 1], [x2, y2]);
|
|
700
|
+
}
|
|
701
|
+
let sampled = 0;
|
|
702
|
+
let covered = 0;
|
|
703
|
+
let coveredAmbiguously = 0;
|
|
704
|
+
let by;
|
|
705
|
+
for (const [x, y] of points) {
|
|
706
|
+
// Outside the viewport: not occlusion by another element, so it is
|
|
707
|
+
// not evidence either way. Counted as unsampled rather than clean.
|
|
708
|
+
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight)
|
|
709
|
+
continue;
|
|
710
|
+
const hit = document.elementFromPoint(x, y);
|
|
711
|
+
if (!hit)
|
|
712
|
+
continue;
|
|
713
|
+
sampled += 1;
|
|
714
|
+
// An ancestor or a descendant is the element's own box, not a
|
|
715
|
+
// cover. A label wrapping its input hit-tests as the label, and
|
|
716
|
+
// treating that as occlusion would flag every well-formed form.
|
|
717
|
+
if (hit === el || el.contains(hit) || hit.contains(el))
|
|
718
|
+
continue;
|
|
719
|
+
const cover = coverer(hit, el);
|
|
720
|
+
if (cover.opaque) {
|
|
721
|
+
covered += 1;
|
|
722
|
+
by = by ?? cover.element;
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
coveredAmbiguously += 1;
|
|
726
|
+
by = by ?? cover.element;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (sampled === 0 || !by)
|
|
730
|
+
continue;
|
|
731
|
+
const positioned = window.getComputedStyle(by).position;
|
|
732
|
+
const row = {
|
|
733
|
+
selector: describe(el),
|
|
734
|
+
by: describe(by),
|
|
735
|
+
positioned,
|
|
736
|
+
entirely: covered === sampled,
|
|
737
|
+
};
|
|
738
|
+
if (covered > 0)
|
|
739
|
+
obscured.push(row);
|
|
740
|
+
else if (coveredAmbiguously > 0)
|
|
741
|
+
ambiguous.push(row);
|
|
742
|
+
}
|
|
743
|
+
// Put the page back where the test left it. Focus handlers that already
|
|
744
|
+
// fired cannot be put back, which is why this only runs at teardown.
|
|
745
|
+
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
746
|
+
previouslyFocused.focus();
|
|
747
|
+
}
|
|
748
|
+
else if (document.activeElement instanceof HTMLElement) {
|
|
749
|
+
document.activeElement.blur();
|
|
750
|
+
}
|
|
751
|
+
window.scrollTo(scrollX, scrollY);
|
|
752
|
+
return { checked, found: candidates.length, truncated, obscured, ambiguous, indicators };
|
|
753
|
+
}, { selector: FOCUSABLE, maxElements: MAX_ELEMENTS, budgetMs: BUDGET_MS });
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
// Never throws into a customer's test, and never reports a guess in place
|
|
757
|
+
// of an answer: a walk that could not run produces no evidence at all.
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
return focusEvidence(walk, target);
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* The verdict, as a pure function of the walk.
|
|
764
|
+
*
|
|
765
|
+
* 2.4.11 is AA and always assessed; 2.4.12 is AAA and emitted only when the
|
|
766
|
+
* run targets it — the same scoping as the in-page rule pack and axe's own
|
|
767
|
+
* three AAA rules.
|
|
768
|
+
*/
|
|
769
|
+
export function focusEvidence(walk, target) {
|
|
770
|
+
return [
|
|
771
|
+
minimum(walk),
|
|
772
|
+
focusVisible(walk),
|
|
773
|
+
...(target === 'AAA' ? [enhanced(walk), focusAppearance(walk)] : []),
|
|
774
|
+
];
|
|
775
|
+
}
|
|
776
|
+
const MINIMUM_WCAG = { criteria: ['2.4.11'], level: 'AA', versionTags: [] };
|
|
777
|
+
const ENHANCED_WCAG = { criteria: ['2.4.12'], level: 'AAA', versionTags: [] };
|
|
778
|
+
/**
|
|
779
|
+
* 2.4.11: a violation only where the focused element was covered ENTIRELY, by
|
|
780
|
+
* something opaque. Anything less is not this criterion's failure — it is
|
|
781
|
+
* 2.4.12's — and reporting it here would fail a page at AA for something AA
|
|
782
|
+
* permits.
|
|
783
|
+
*/
|
|
784
|
+
function minimum(walk) {
|
|
785
|
+
if (walk.checked === 0) {
|
|
786
|
+
return { inapplicable: { ruleId: FOCUS_OBSCURED_RULE_ID, wcag: MINIMUM_WCAG } };
|
|
787
|
+
}
|
|
788
|
+
const hidden = walk.obscured.filter((row) => row.entirely);
|
|
789
|
+
if (hidden.length > 0) {
|
|
790
|
+
return {
|
|
791
|
+
finding: {
|
|
792
|
+
ruleId: FOCUS_OBSCURED_RULE_ID,
|
|
793
|
+
status: 'violation',
|
|
794
|
+
impact: 'serious',
|
|
795
|
+
wcag: MINIMUM_WCAG,
|
|
796
|
+
helpUrl: FOCUS_NOT_OBSCURED_MINIMUM_HELP_URL,
|
|
797
|
+
evidence: { failureSummary: summarise(hidden, 'entirely hidden', walk) },
|
|
798
|
+
targets: { nodeCount: hidden.length, ...sanitiseSelectors(hidden.map((r) => r.selector)) },
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
// Ambiguous stacking is needs-review, never a pass and never a fail — the
|
|
803
|
+
// same discipline the target-size rule uses for its exceptions.
|
|
804
|
+
if (walk.ambiguous.length > 0) {
|
|
805
|
+
return {
|
|
806
|
+
finding: {
|
|
807
|
+
ruleId: FOCUS_OBSCURED_RULE_ID,
|
|
808
|
+
status: 'incomplete',
|
|
809
|
+
impact: 'moderate',
|
|
810
|
+
wcag: MINIMUM_WCAG,
|
|
811
|
+
helpUrl: FOCUS_NOT_OBSCURED_MINIMUM_HELP_URL,
|
|
812
|
+
evidence: { failureSummary: ambiguousSummary(walk.ambiguous) },
|
|
813
|
+
targets: {
|
|
814
|
+
nodeCount: walk.ambiguous.length,
|
|
815
|
+
...sanitiseSelectors(walk.ambiguous.map((r) => r.selector)),
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
return { pass: { ruleId: FOCUS_OBSCURED_RULE_ID, nodeCount: walk.checked, wcag: MINIMUM_WCAG } };
|
|
821
|
+
}
|
|
822
|
+
/** 2.4.12: a violation where ANY part of the focused element was covered. */
|
|
823
|
+
function enhanced(walk) {
|
|
824
|
+
if (walk.checked === 0) {
|
|
825
|
+
return { inapplicable: { ruleId: FOCUS_OBSCURED_ENHANCED_RULE_ID, wcag: ENHANCED_WCAG } };
|
|
826
|
+
}
|
|
827
|
+
if (walk.obscured.length > 0) {
|
|
828
|
+
return {
|
|
829
|
+
finding: {
|
|
830
|
+
ruleId: FOCUS_OBSCURED_ENHANCED_RULE_ID,
|
|
831
|
+
status: 'violation',
|
|
832
|
+
impact: 'serious',
|
|
833
|
+
wcag: ENHANCED_WCAG,
|
|
834
|
+
helpUrl: FOCUS_NOT_OBSCURED_ENHANCED_HELP_URL,
|
|
835
|
+
evidence: { failureSummary: summarise(walk.obscured, 'partly or entirely covered', walk) },
|
|
836
|
+
targets: {
|
|
837
|
+
nodeCount: walk.obscured.length,
|
|
838
|
+
...sanitiseSelectors(walk.obscured.map((r) => r.selector)),
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (walk.ambiguous.length > 0) {
|
|
844
|
+
return {
|
|
845
|
+
finding: {
|
|
846
|
+
ruleId: FOCUS_OBSCURED_ENHANCED_RULE_ID,
|
|
847
|
+
status: 'incomplete',
|
|
848
|
+
impact: 'moderate',
|
|
849
|
+
wcag: ENHANCED_WCAG,
|
|
850
|
+
helpUrl: FOCUS_NOT_OBSCURED_ENHANCED_HELP_URL,
|
|
851
|
+
evidence: { failureSummary: ambiguousSummary(walk.ambiguous) },
|
|
852
|
+
targets: {
|
|
853
|
+
nodeCount: walk.ambiguous.length,
|
|
854
|
+
...sanitiseSelectors(walk.ambiguous.map((r) => r.selector)),
|
|
855
|
+
},
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
pass: {
|
|
861
|
+
ruleId: FOCUS_OBSCURED_ENHANCED_RULE_ID,
|
|
862
|
+
nodeCount: walk.checked,
|
|
863
|
+
wcag: ENHANCED_WCAG,
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const VISIBLE_WCAG = { criteria: ['2.4.7'], level: 'AA', versionTags: [] };
|
|
868
|
+
const APPEARANCE_WCAG = { criteria: ['2.4.13'], level: 'AAA', versionTags: [] };
|
|
869
|
+
/**
|
|
870
|
+
* WCAG 2.4.7 Focus Visible (AA): does anything visibly change on focus?
|
|
871
|
+
*
|
|
872
|
+
* The commonest real failure on the web, and one `scripts/check-colours.mjs`
|
|
873
|
+
* already bans in our own source — `outline: none` with nothing replacing it.
|
|
874
|
+
* That gate does not travel to a customer, and it cannot see a utility class or
|
|
875
|
+
* a component library defeating the ring without anyone writing `outline: none`
|
|
876
|
+
* anywhere. This is the runtime version of the same rule, in their product.
|
|
877
|
+
*
|
|
878
|
+
* Two things it declines to call, both for the same reason: we can only report
|
|
879
|
+
* what we could actually observe.
|
|
880
|
+
*
|
|
881
|
+
* A row where `:focus-visible` did not match is **needs-review, never a
|
|
882
|
+
* violation.** A great many sites style `:focus-visible` only, and a
|
|
883
|
+
* programmatic `focus()` does not always match it — so "nothing changed" from
|
|
884
|
+
* a script-driven focus is a weaker claim than "a keyboard user sees nothing",
|
|
885
|
+
* and only the second one is a failure of this criterion.
|
|
886
|
+
*
|
|
887
|
+
* A row whose ONLY change is `background-color` is also needs-review: that is
|
|
888
|
+
* as likely to be a hover style leaking as a deliberate indicator, and we
|
|
889
|
+
* cannot attribute it.
|
|
890
|
+
*/
|
|
891
|
+
function focusVisible(walk) {
|
|
892
|
+
if (walk.indicators.length === 0) {
|
|
893
|
+
return { inapplicable: { ruleId: FOCUS_VISIBLE_RULE_ID, wcag: VISIBLE_WCAG } };
|
|
894
|
+
}
|
|
895
|
+
const silent = walk.indicators.filter((row) => row.changed.length === 0);
|
|
896
|
+
const invisible = silent.filter((row) => row.focusVisible);
|
|
897
|
+
const unobserved = silent.filter((row) => !row.focusVisible);
|
|
898
|
+
const backgroundOnly = walk.indicators.filter((row) => row.changed.length === 1 && row.changed[0] === 'background-color');
|
|
899
|
+
if (invisible.length > 0) {
|
|
900
|
+
return {
|
|
901
|
+
finding: {
|
|
902
|
+
ruleId: FOCUS_VISIBLE_RULE_ID,
|
|
903
|
+
status: 'violation',
|
|
904
|
+
impact: 'serious',
|
|
905
|
+
wcag: VISIBLE_WCAG,
|
|
906
|
+
helpUrl: FOCUS_VISIBLE_HELP_URL,
|
|
907
|
+
evidence: {
|
|
908
|
+
failureSummary: `Nothing in the computed style changes when ${invisible[0].selector} receives ` +
|
|
909
|
+
`focus — no outline, box-shadow, border, background or pseudo-element — and ` +
|
|
910
|
+
`:focus-visible matched, so this is what a keyboard user gets. ` +
|
|
911
|
+
`${invisible.length} element(s) affected. Fix: give focus an indicator; if ` +
|
|
912
|
+
`outline is being cleared, replace it rather than removing it.` +
|
|
913
|
+
bounds(walk),
|
|
914
|
+
},
|
|
915
|
+
targets: {
|
|
916
|
+
nodeCount: invisible.length,
|
|
917
|
+
...sanitiseSelectors(invisible.map((row) => row.selector)),
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const review = [...unobserved, ...backgroundOnly];
|
|
923
|
+
if (review.length > 0) {
|
|
924
|
+
const why = unobserved.length > 0
|
|
925
|
+
? `:focus-visible did not match when we focused ${unobserved[0].selector} from script, so ` +
|
|
926
|
+
`an indicator styled for :focus-visible only would not have applied — "nothing changed" ` +
|
|
927
|
+
`here is not the same claim as "a keyboard user sees nothing"`
|
|
928
|
+
: `the only thing that changes on focus for ${backgroundOnly[0].selector} is its ` +
|
|
929
|
+
`background colour, which is as likely to be a hover style leaking as a deliberate indicator`;
|
|
930
|
+
return {
|
|
931
|
+
finding: {
|
|
932
|
+
ruleId: FOCUS_VISIBLE_RULE_ID,
|
|
933
|
+
status: 'incomplete',
|
|
934
|
+
impact: 'moderate',
|
|
935
|
+
wcag: VISIBLE_WCAG,
|
|
936
|
+
helpUrl: FOCUS_VISIBLE_HELP_URL,
|
|
937
|
+
evidence: {
|
|
938
|
+
failureSummary: `Not reported as a failure because ${why}. ${review.length} element(s) affected. ` +
|
|
939
|
+
`A person needs to tab to these and look.` +
|
|
940
|
+
bounds(walk),
|
|
941
|
+
},
|
|
942
|
+
targets: {
|
|
943
|
+
nodeCount: review.length,
|
|
944
|
+
...sanitiseSelectors(review.map((row) => row.selector)),
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
pass: { ruleId: FOCUS_VISIBLE_RULE_ID, nodeCount: walk.indicators.length, wcag: VISIBLE_WCAG },
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* WCAG 2.4.13 Focus Appearance (AAA): is the indicator thick enough and does
|
|
955
|
+
* it contrast enough?
|
|
956
|
+
*
|
|
957
|
+
* The SC's two numbers are normative — at least the area of a 2 CSS pixel
|
|
958
|
+
* perimeter, and at least 3:1 against adjacent colours — and both are
|
|
959
|
+
* computable from the rects and the resolved colours, which is why this is a
|
|
960
|
+
* rule at all rather than a judgement.
|
|
961
|
+
*
|
|
962
|
+
* **What it will assert:** an outline present and thinner than 2px, or a
|
|
963
|
+
* measured contrast below 3:1 against colours it actually resolved. Both are
|
|
964
|
+
* deterministic and both are stated as figures in the evidence, because a
|
|
965
|
+
* finding that says "insufficient" without the number is asking to be
|
|
966
|
+
* believed rather than checked.
|
|
967
|
+
*
|
|
968
|
+
* **What it will not:** an indicator drawn with a box-shadow or a
|
|
969
|
+
* pseudo-element, whose area is not deterministic from computed style, and any
|
|
970
|
+
* row whose adjacent colour could not be resolved to a solid value — a
|
|
971
|
+
* gradient, an image, a semi-transparent stack. Those are needs-review, which
|
|
972
|
+
* is the same answer the contrast rule already gives for the same reason.
|
|
973
|
+
* Averaging a gradient would be inventing a measurement.
|
|
974
|
+
*/
|
|
975
|
+
function focusAppearance(walk) {
|
|
976
|
+
const indicated = walk.indicators.filter((row) => row.changed.length > 0);
|
|
977
|
+
if (indicated.length === 0) {
|
|
978
|
+
// Nothing to measure: either nothing focusable, or no indicator at all —
|
|
979
|
+
// and "there is no indicator" is 2.4.7's finding, not this one's.
|
|
980
|
+
return { inapplicable: { ruleId: FOCUS_APPEARANCE_RULE_ID, wcag: APPEARANCE_WCAG } };
|
|
981
|
+
}
|
|
982
|
+
/*
|
|
983
|
+
* The SC asks whether AN AREA of the indicator meets both conditions — not
|
|
984
|
+
* whether every band does. A two-layer ring's light band is 1:1 against the
|
|
985
|
+
* page on purpose; the dark band is what carries the contrast, and the
|
|
986
|
+
* criterion is met by the dark one. Summing the qualifying bands and
|
|
987
|
+
* comparing against the 2px perimeter is the whole test.
|
|
988
|
+
*/
|
|
989
|
+
const failing = indicated.filter((row) => !hasUnmeasured(row) && row.qualifyingAreaPx < row.requiredAreaPx);
|
|
990
|
+
if (failing.length > 0) {
|
|
991
|
+
const first = failing[0];
|
|
992
|
+
return {
|
|
993
|
+
finding: {
|
|
994
|
+
ruleId: FOCUS_APPEARANCE_RULE_ID,
|
|
995
|
+
status: 'violation',
|
|
996
|
+
impact: 'moderate',
|
|
997
|
+
wcag: APPEARANCE_WCAG,
|
|
998
|
+
helpUrl: FOCUS_APPEARANCE_HELP_URL,
|
|
999
|
+
evidence: {
|
|
1000
|
+
failureSummary: `The focus indicator on ${first.selector} covers ${first.qualifyingAreaPx}px² at ` +
|
|
1001
|
+
`${APPEARANCE_MIN_CONTRAST}:1 or better, and WCAG 2.4.13 asks for at least ` +
|
|
1002
|
+
`${first.requiredAreaPx}px² — the area of a ${APPEARANCE_MIN_THICKNESS}px perimeter ` +
|
|
1003
|
+
`of the component. ${describeLayers(first)} ${failing.length} element(s) affected.` +
|
|
1004
|
+
bounds(walk),
|
|
1005
|
+
},
|
|
1006
|
+
targets: {
|
|
1007
|
+
nodeCount: failing.length,
|
|
1008
|
+
...sanitiseSelectors(failing.map((row) => row.selector)),
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
/*
|
|
1014
|
+
* Needs-review only where a colour or a geometry genuinely could not be
|
|
1015
|
+
* resolved AND the verdict turns on it. A row that already qualifies on the
|
|
1016
|
+
* bands we could measure is a pass whatever the unmeasurable one would have
|
|
1017
|
+
* added — reporting it as undecided would be declining to answer a question
|
|
1018
|
+
* we have already answered.
|
|
1019
|
+
*/
|
|
1020
|
+
const undecided = indicated.filter((row) => row.qualifyingAreaPx < row.requiredAreaPx && hasUnmeasured(row));
|
|
1021
|
+
if (undecided.length > 0) {
|
|
1022
|
+
const first = undecided[0];
|
|
1023
|
+
const why = first.unresolved ??
|
|
1024
|
+
first.layers.find((layer) => layer.unresolved)?.unresolved ??
|
|
1025
|
+
'a band could not be measured';
|
|
1026
|
+
return {
|
|
1027
|
+
finding: {
|
|
1028
|
+
ruleId: FOCUS_APPEARANCE_RULE_ID,
|
|
1029
|
+
status: 'incomplete',
|
|
1030
|
+
impact: 'moderate',
|
|
1031
|
+
wcag: APPEARANCE_WCAG,
|
|
1032
|
+
helpUrl: FOCUS_APPEARANCE_HELP_URL,
|
|
1033
|
+
evidence: {
|
|
1034
|
+
failureSummary: `The indicator on ${first.selector} measures ${first.qualifyingAreaPx}px² at ` +
|
|
1035
|
+
`${APPEARANCE_MIN_CONTRAST}:1 against a requirement of ${first.requiredAreaPx}px², ` +
|
|
1036
|
+
`and the shortfall turns on a band we could not measure: ${why}. ` +
|
|
1037
|
+
`${undecided.length} element(s) affected${uniformReason(undecided)
|
|
1038
|
+
? ', all for the same reason — one question about one component, not ' +
|
|
1039
|
+
`${undecided.length} separate ones`
|
|
1040
|
+
: ''}. A person measures that band.` +
|
|
1041
|
+
bounds(walk),
|
|
1042
|
+
},
|
|
1043
|
+
targets: {
|
|
1044
|
+
nodeCount: undecided.length,
|
|
1045
|
+
...sanitiseSelectors(undecided.map((row) => row.selector)),
|
|
1046
|
+
},
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
pass: { ruleId: FOCUS_APPEARANCE_RULE_ID, nodeCount: indicated.length, wcag: APPEARANCE_WCAG },
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
/** Did anything about this row resist measurement — the row, or any band? */
|
|
1055
|
+
function hasUnmeasured(row) {
|
|
1056
|
+
return row.unresolved !== undefined || row.layers.some((layer) => layer.unresolved !== undefined);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* The bands, in the finding, because a measurement nobody can check is an
|
|
1060
|
+
* assertion. "outline 3px at 21.4:1, box-shadow 2px at 1.1:1" tells somebody
|
|
1061
|
+
* which band to change; "insufficient indicator" tells them to guess.
|
|
1062
|
+
*/
|
|
1063
|
+
function describeLayers(row) {
|
|
1064
|
+
if (row.layers.length === 0)
|
|
1065
|
+
return 'No measurable band was painted.';
|
|
1066
|
+
return ('Measured: ' +
|
|
1067
|
+
row.layers
|
|
1068
|
+
.map((layer) => `${layer.kind} ${layer.thicknessPx}px covering ${layer.areaPx}px² at ` +
|
|
1069
|
+
(layer.contrast === null ? 'an unresolved contrast' : `${layer.contrast.toFixed(2)}:1`))
|
|
1070
|
+
.join('; ') +
|
|
1071
|
+
'.');
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Do every undecided row share one reason? See the note on the occlusion
|
|
1075
|
+
* walk's own version: a design system has one focus ring, so a hundred rows
|
|
1076
|
+
* are one question.
|
|
1077
|
+
*/
|
|
1078
|
+
function uniformReason(rows) {
|
|
1079
|
+
if (rows.length < 2)
|
|
1080
|
+
return false;
|
|
1081
|
+
const reason = (row) => row.unresolved ?? row.layers.find((layer) => layer.unresolved)?.unresolved;
|
|
1082
|
+
const first = reason(rows[0]);
|
|
1083
|
+
return rows.every((row) => reason(row) === first);
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* The truncation sentence, shared by every finding this file emits.
|
|
1087
|
+
*
|
|
1088
|
+
* A bounded walk reported as a complete one is a figure that looks like a
|
|
1089
|
+
* statement about the page and is a statement about part of it.
|
|
1090
|
+
*/
|
|
1091
|
+
function bounds(walk) {
|
|
1092
|
+
return walk.truncated
|
|
1093
|
+
? ` The walk stopped after ${walk.checked} of ${walk.found} focusable elements, so this is not the whole page.`
|
|
1094
|
+
: '';
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Names the coverer, because a finding that says only "something covered it"
|
|
1098
|
+
* is a worse version of no finding: nobody can act on it, and the reader's
|
|
1099
|
+
* first question is the one it refuses to answer.
|
|
1100
|
+
*/
|
|
1101
|
+
function summarise(rows, how, walk) {
|
|
1102
|
+
const first = rows[0];
|
|
1103
|
+
const positioned = first.positioned === 'fixed' || first.positioned === 'sticky'
|
|
1104
|
+
? ` (position: ${first.positioned}, which is the usual cause)`
|
|
1105
|
+
: '';
|
|
1106
|
+
const others = rows.length > 1 ? ` and ${rows.length - 1} more element(s)` : '';
|
|
1107
|
+
const bounded = walk.truncated
|
|
1108
|
+
? ` The walk stopped after ${walk.checked} of ${walk.found} focusable elements, so this is not the whole page.`
|
|
1109
|
+
: '';
|
|
1110
|
+
return (`When focused, ${first.selector} is ${how} by ${first.by}${positioned}${others}. ` +
|
|
1111
|
+
`Fix: keep the focused element clear of overlays — scroll-padding-top and ` +
|
|
1112
|
+
`scroll-padding-bottom sized to the sticky element are usually enough, or ` +
|
|
1113
|
+
`move the overlay out of the way when focus enters the area behind it.` +
|
|
1114
|
+
bounded);
|
|
1115
|
+
}
|
|
1116
|
+
function ambiguousSummary(rows) {
|
|
1117
|
+
const first = rows[0];
|
|
1118
|
+
return (`When focused, ${first.selector} sits behind ${first.by}, which is translucent or paints ` +
|
|
1119
|
+
`no background of its own — whether the focus indicator is still visible is a judgement, ` +
|
|
1120
|
+
`so this is not reported as a failure. ${rows.length} element(s) affected. ` +
|
|
1121
|
+
`A person needs to focus these and look.`);
|
|
1122
|
+
}
|
|
1123
|
+
//# sourceMappingURL=focus.js.map
|