automatica11y 0.3.2 → 0.4.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/README.md +35 -8
- package/package.json +6 -3
- package/skills/automatica11y-runner/SKILL.md +47 -13
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/data/README.md +19 -0
- package/src/data/wcag-2.2.json +7557 -0
- package/src/data/wcag-2.2.source.json +6 -0
- package/src/frameworks/index.js +42 -0
- package/src/frameworks/react.js +77 -0
- package/src/frameworks/vue.js +90 -0
- package/src/frameworks/wc.js +55 -0
- package/src/globals.d.ts +2 -0
- package/src/harness/bundle.js +23 -8
- package/src/harness/generate/dialects.js +64 -0
- package/src/harness/generate/index.js +12 -0
- package/src/harness/generate/jsx-recipes.js +224 -0
- package/src/harness/generate/jsx.js +76 -0
- package/src/harness/generate/kit.js +50 -0
- package/src/harness/generate/marking.js +64 -0
- package/src/harness/generate/probe.js +97 -0
- package/src/harness/generate/shared.js +12 -0
- package/src/harness/generate/wc-recipes.js +132 -0
- package/src/harness/npm-install.js +85 -5
- package/src/harness/settle.js +17 -0
- package/src/harness/storybook.js +1 -0
- package/src/harness/url.js +13 -3
- package/src/plan/classify.js +11 -4
- package/src/plan/mapping.js +11 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +21 -6
- package/src/report/parts.js +60 -8
- package/src/run/audit-npm.js +137 -29
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +24 -4
- package/src/run/summary.js +10 -0
- package/src/schema.js +34 -7
- package/src/tiers/computed/checks.js +197 -0
- package/src/tiers/computed/color.js +48 -0
- package/src/tiers/computed/index.js +25 -0
- package/src/tiers/computed/measure-kit.js +225 -0
- package/src/tiers/conditions/checks.js +283 -0
- package/src/tiers/conditions/index.js +30 -0
- package/src/tiers/conditions/kit.js +133 -0
- package/src/tiers/interactions/archetypes.js +117 -4
- package/src/tiers/interactions/focus-indicator.js +39 -0
- package/src/tiers/interactions/helpers.js +87 -16
- package/src/tiers/interactions/index.js +17 -4
- package/src/tiers/rules/axe.js +4 -3
- package/src/wcag/index.js +83 -0
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conditions checks. Each opens the page under a setting a person might use (reduced motion, dark mode, forced colors),
|
|
3
|
+
* or in an environment (a 320 pixel window, wider text spacing), and says whether the page holds up.
|
|
4
|
+
* A check returns pass, fail, undetermined, or not-applicable, with a detail and the numbers behind it.
|
|
5
|
+
* These are automatica11y's own measurements. They're reported on their own and never added to axe-core or IBM counts.
|
|
6
|
+
*/
|
|
7
|
+
import { contrastOver, formatRatio, ringIsEnough, textThreshold } from "../computed/color.js";
|
|
8
|
+
import { num, plural } from "../../text.js";
|
|
9
|
+
import { criterionRef } from "../../wcag/index.js";
|
|
10
|
+
|
|
11
|
+
const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
|
|
12
|
+
const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
|
|
13
|
+
const na = (detail) => ({ result: "not-applicable", detail });
|
|
14
|
+
const undetermined = (detail, extra = {}) => ({ result: "undetermined", detail, ...extra });
|
|
15
|
+
|
|
16
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
17
|
+
const floor2 = (n) => Math.floor(n * 100) / 100;
|
|
18
|
+
const list = (items, max = 3) => `${items.slice(0, max).join("; ")}${items.length > max ? `; and ${items.length - max} more` : ""}`;
|
|
19
|
+
|
|
20
|
+
/** Archetypes whose trigger shouldn't be pressed to reach their resting state: a link would navigate, and a chart has nothing to press. */
|
|
21
|
+
const NO_ACTIVATION = new Set(["link", "chart"]);
|
|
22
|
+
|
|
23
|
+
/** Does this page have a trigger hook (a fixture), or is it a whole page? */
|
|
24
|
+
const hasTrigger = async (ctx) => (await ctx.trigger.count()) > 0;
|
|
25
|
+
|
|
26
|
+
/** Press Enter on the trigger of a fixture, so a dialog, menu, or message is showing. Whole pages are left as they are. */
|
|
27
|
+
async function reachState(ctx, archetype) {
|
|
28
|
+
if (!(await hasTrigger(ctx)) || NO_ACTIVATION.has(archetype)) return false;
|
|
29
|
+
await ctx.focus();
|
|
30
|
+
await ctx.press("Enter");
|
|
31
|
+
await ctx.settle(300);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- reduced motion ----
|
|
36
|
+
|
|
37
|
+
/** Every animation seen at load and just after the trigger is pressed. Each is recorded once. */
|
|
38
|
+
async function collectAnimations(ctx, archetype) {
|
|
39
|
+
const seen = new Map();
|
|
40
|
+
const take = async () => {
|
|
41
|
+
for (const a of await ctx.page.evaluate(() => window.__a11yConditions.animations())) seen.set(`${a.kind}|${a.name}|${a.target}|${a.props.join()}|${a.duration}|${a.iterations}`, a);
|
|
42
|
+
};
|
|
43
|
+
await ctx.settle(100);
|
|
44
|
+
await take();
|
|
45
|
+
await sleep(150);
|
|
46
|
+
await take();
|
|
47
|
+
if ((await hasTrigger(ctx)) && !NO_ACTIVATION.has(archetype)) {
|
|
48
|
+
await ctx.focus();
|
|
49
|
+
await ctx.page.keyboard.press("Enter");
|
|
50
|
+
for (const wait of [0, 40, 80, 160, 240]) {
|
|
51
|
+
await take();
|
|
52
|
+
await sleep(wait);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...seen.values()];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** An animation worth reducing: anything that repeats forever, or moves for longer than a blink. */
|
|
59
|
+
const concerning = (a) => a.iterations === "infinite" || (a.moving && (a.duration ?? 0) > 100);
|
|
60
|
+
const describeAnimation = (a) => `${a.name || a.kind} on ${a.target} (${a.moving ? `moves ${a.props.filter((p) => /^(transform|translate|rotate|scale|top|left|right|bottom|inset|margin|height|width|offset|background|clip|max|min)/.test(p)).join(", ")}` : a.props.join(", ") || "no keyframes"}; ${a.duration ?? "?"}ms${a.iterations === "infinite" ? ", repeats forever" : ""})`;
|
|
61
|
+
|
|
62
|
+
const reducedMotion = (archetype) => ({
|
|
63
|
+
name: "reduced-motion-respected",
|
|
64
|
+
criteria: ["2.3.3", "2.2.2"],
|
|
65
|
+
async run(ctx) {
|
|
66
|
+
const normal = await collectAnimations(ctx, archetype);
|
|
67
|
+
const reduced = await collectAnimations(await ctx.variant({ reducedMotion: true }), archetype);
|
|
68
|
+
const measurements = [{ setting: "no preference", animations: normal.length, moveOrRepeat: normal.filter(concerning).length }, { setting: "reduce", animations: reduced.length, moveOrRepeat: reduced.filter(concerning).length }];
|
|
69
|
+
if (normal.length === 0 && reduced.length === 0) return na("No animations or transitions were running at load or after the trigger was pressed, so there's nothing to reduce. Motion driven by JavaScript timers isn't visible to this check.");
|
|
70
|
+
const still = reduced.filter(concerning);
|
|
71
|
+
if (still.length) {
|
|
72
|
+
const before = normal.filter(concerning).length;
|
|
73
|
+
return fail(`With prefers-reduced-motion: reduce, ${plural(still.length, "animation")} still ${still.length === 1 ? "moves or repeats" : "move or repeat"}: ${list(still.map(describeAnimation))}. Without the preference, ${num(before)} did.`, { measurements });
|
|
74
|
+
}
|
|
75
|
+
const had = normal.filter(concerning);
|
|
76
|
+
return had.length
|
|
77
|
+
? pass(`${plural(had.length, "animation")} that move or repeat (${list(had.map(describeAnimation), 2)}) don't run when motion is reduced.`, { measurements })
|
|
78
|
+
: pass(`The page animates (${plural(normal.length, "animation")}), but nothing moves or repeats, so there's no motion to reduce. Motion driven by JavaScript timers isn't visible to this check.`, { measurements });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---- color scheme ----
|
|
83
|
+
|
|
84
|
+
const colorScheme = () => ({
|
|
85
|
+
name: "dark-mode-contrast",
|
|
86
|
+
criteria: ["1.4.3"],
|
|
87
|
+
async run(ctx) {
|
|
88
|
+
await ctx.settle(300);
|
|
89
|
+
// Animations are frozen in both shots, so a spinner or a fade can't make a page look as if it changed.
|
|
90
|
+
const light = await ctx.page.screenshot({ animations: "disabled" });
|
|
91
|
+
const dark = await ctx.variant({ colorScheme: "dark" });
|
|
92
|
+
await dark.settle(300);
|
|
93
|
+
const shot = await dark.page.screenshot({ animations: "disabled" });
|
|
94
|
+
if (light.equals(shot)) return na("The page looks the same under prefers-color-scheme: dark, so it doesn't adapt to it. A page isn't required to. Nothing was checked.");
|
|
95
|
+
const parts = (await dark.page.evaluate(() => window.__a11yMeasure.text("all"))) ?? [];
|
|
96
|
+
if (parts.length === 0) return undetermined("The page changes under prefers-color-scheme: dark, but no visible text was found to measure.");
|
|
97
|
+
const rows = [];
|
|
98
|
+
const unknown = [];
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
if (part.undetermined || !part.backdrop) unknown.push(`"${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
|
|
101
|
+
else rows.push({ text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
|
|
102
|
+
}
|
|
103
|
+
const failed = rows.filter((r) => r.ratio < r.required).sort((a, b) => a.ratio / a.required - b.ratio / b.required);
|
|
104
|
+
const measurements = [{ measured: rows.length, undetermined: unknown.length, below: failed.length, lowestRatio: rows.length ? floor2(Math.min(...rows.map((r) => r.ratio))) : null }];
|
|
105
|
+
if (failed.length) return fail(`In dark mode, ${plural(failed.length, "piece")} of text fall${failed.length === 1 ? "s" : ""} below the contrast it needs. The worst is "${failed[0].text}" at ${formatRatio(failed[0].ratio)} (needs ${failed[0].required}:1).`, { measurements });
|
|
106
|
+
if (unknown.length) return undetermined(`${rows.length ? `All ${plural(rows.length, "measured piece")} of text pass in dark mode. ` : ""}${plural(unknown.length, "other piece")} can't be reduced to one color. ${unknown[0]}`, { measurements });
|
|
107
|
+
return pass(`The page adapts to dark mode, and all ${plural(rows.length, "piece")} of text measured pass. The lowest is ${formatRatio(Math.min(...rows.map((r) => r.ratio)))}.`, { measurements });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---- prefers-contrast ----
|
|
112
|
+
|
|
113
|
+
/** Measure the contrast of every piece of text on a page. */
|
|
114
|
+
async function measureAllText(ctx) {
|
|
115
|
+
const parts = (await ctx.page.evaluate(() => window.__a11yMeasure.text("all"))) ?? [];
|
|
116
|
+
const rows = [];
|
|
117
|
+
const unknown = [];
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (part.undetermined || !part.backdrop) unknown.push(`"${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
|
|
120
|
+
else rows.push({ text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
|
|
121
|
+
}
|
|
122
|
+
return { rows, unknown };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The piece of text with the least room above its threshold, and how many fall below it. */
|
|
126
|
+
function tightest(rows) {
|
|
127
|
+
const sorted = [...rows].sort((a, b) => a.ratio / a.required - b.ratio / b.required);
|
|
128
|
+
return { worst: sorted[0], below: sorted.filter((r) => r.ratio < r.required) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Screenshots with animations frozen, so a running spinner can't make a page look different. */
|
|
132
|
+
async function lookOf(ctx) {
|
|
133
|
+
await ctx.settle(300);
|
|
134
|
+
return ctx.page.screenshot({ animations: "disabled" });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const moreContrast = () => ({
|
|
138
|
+
name: "more-contrast-respected",
|
|
139
|
+
criteria: ["1.4.3", "1.4.6"],
|
|
140
|
+
async run(ctx) {
|
|
141
|
+
const normal = await lookOf(ctx);
|
|
142
|
+
const more = await ctx.variant({ contrast: "more" });
|
|
143
|
+
if (normal.equals(await lookOf(more))) return na("The page looks the same under prefers-contrast: more, so it doesn't respond to it. A page isn't required to. Nothing was checked.");
|
|
144
|
+
const base = await measureAllText(ctx);
|
|
145
|
+
const now = await measureAllText(more);
|
|
146
|
+
if (now.rows.length === 0) return undetermined("The page changes under prefers-contrast: more, but no visible text was measurable.");
|
|
147
|
+
const { worst, below } = tightest(now.rows);
|
|
148
|
+
const lowestNow = Math.min(...now.rows.map((r) => r.ratio));
|
|
149
|
+
const lowestBase = base.rows.length ? Math.min(...base.rows.map((r) => r.ratio)) : null;
|
|
150
|
+
const enhanced = now.rows.every((r) => r.ratio >= (r.required === 3 ? 4.5 : 7));
|
|
151
|
+
const measurements = [{ lowestWithoutPreference: lowestBase === null ? null : floor2(lowestBase), lowestWithMore: floor2(lowestNow), belowMinimum: below.length, reachesEnhanced: enhanced }];
|
|
152
|
+
if (below.length) return fail(`With prefers-contrast: more, ${plural(below.length, "piece")} of text fall${below.length === 1 ? "s" : ""} below the minimum contrast. The worst is "${worst.text}" at ${formatRatio(worst.ratio)} (needs ${worst.required}:1).`, { measurements });
|
|
153
|
+
if (lowestBase !== null && lowestNow < lowestBase - 0.01) return fail(`With prefers-contrast: more, the lowest text contrast dropped from ${formatRatio(lowestBase)} to ${formatRatio(lowestNow)}, so the page asked for more contrast and got less.`, { measurements });
|
|
154
|
+
if (now.unknown.length) return undetermined(`All ${plural(now.rows.length, "measured piece")} of text pass with more contrast. ${plural(now.unknown.length, "other piece")} can't be reduced to one color. ${now.unknown[0]}`, { measurements });
|
|
155
|
+
return pass(`The page responds to prefers-contrast: more. The lowest text contrast is ${formatRatio(lowestNow)}${lowestBase === null ? "" : `, from ${formatRatio(lowestBase)} without the preference`}. Enhanced contrast (${criterionRef("1.4.6")}, 7:1 for normal text) is ${enhanced ? "reached" : "not reached for every piece of text"}.`, { measurements });
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const lessContrast = () => ({
|
|
160
|
+
name: "less-contrast-stays-readable",
|
|
161
|
+
criteria: ["1.4.3"],
|
|
162
|
+
async run(ctx) {
|
|
163
|
+
const normal = await lookOf(ctx);
|
|
164
|
+
const less = await ctx.variant({ contrast: "less" });
|
|
165
|
+
if (normal.equals(await lookOf(less))) return na("The page looks the same under prefers-contrast: less, so it doesn't respond to it. A page isn't required to. Nothing was checked.");
|
|
166
|
+
const now = await measureAllText(less);
|
|
167
|
+
if (now.rows.length === 0) return undetermined("The page changes under prefers-contrast: less, but no visible text was measurable.");
|
|
168
|
+
const { worst, below } = tightest(now.rows);
|
|
169
|
+
const measurements = [{ measured: now.rows.length, belowMinimum: below.length, lowest: floor2(Math.min(...now.rows.map((r) => r.ratio))) }];
|
|
170
|
+
if (below.length) return fail(`With prefers-contrast: less, ${plural(below.length, "piece")} of text fall${below.length === 1 ? "s" : ""} below the minimum contrast. The worst is "${worst.text}" at ${formatRatio(worst.ratio)} (needs ${worst.required}:1). Softer contrast is fine only while text stays readable.`, { measurements });
|
|
171
|
+
if (now.unknown.length) return undetermined(`All ${plural(now.rows.length, "measured piece")} of text stay above the minimum. ${plural(now.unknown.length, "other piece")} can't be reduced to one color. ${now.unknown[0]}`, { measurements });
|
|
172
|
+
return pass(`The page softens its contrast under prefers-contrast: less, and all ${plural(now.rows.length, "piece")} of text measured stay above the minimum. The lowest is ${formatRatio(Math.min(...now.rows.map((r) => r.ratio)))}.`, { measurements });
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---- prefers-reduced-transparency ----
|
|
177
|
+
|
|
178
|
+
const reducedTransparency = () => ({
|
|
179
|
+
name: "reduced-transparency-respected",
|
|
180
|
+
// The preference isn't a success criterion. See-through backgrounds make text contrast unpredictable, which 1.4.3 and 1.4.11 care about.
|
|
181
|
+
criteria: ["1.4.3", "1.4.11"],
|
|
182
|
+
async run(ctx) {
|
|
183
|
+
await ctx.settle(300);
|
|
184
|
+
const normal = await ctx.page.evaluate(() => window.__a11yConditions.translucentSurfaces());
|
|
185
|
+
const reduced = await ctx.variant({ reducedTransparency: true });
|
|
186
|
+
await reduced.settle(300);
|
|
187
|
+
const still = await reduced.page.evaluate(() => window.__a11yConditions.translucentSurfaces());
|
|
188
|
+
const measurements = [{ translucentWithoutPreference: normal.length, translucentWithReduce: still.length }];
|
|
189
|
+
if (normal.length === 0 && still.length === 0) return na("No surface that holds text is see-through (no translucent background and no backdrop filter), so there's nothing to reduce. Empty overlays aren't counted.");
|
|
190
|
+
const name = (s) => `${s.element} (${[s.background, s.backdropFilter && `backdrop-filter ${s.backdropFilter}`].filter(Boolean).join(", ")})`;
|
|
191
|
+
if (still.length) return fail(`With prefers-reduced-transparency: reduce, ${plural(still.length, "surface")} holding text stay${still.length === 1 ? "s" : ""} see-through: ${list(still.map(name))}. Without the preference there were ${num(normal.length)}. This preference isn't a WCAG requirement. It matters because see-through backgrounds make text contrast unpredictable.`, { measurements });
|
|
192
|
+
return pass(`${plural(normal.length, "see-through surface")} holding text (${list(normal.map(name), 2)}) become opaque when transparency is reduced.`, { measurements });
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---- forced colors ----
|
|
197
|
+
|
|
198
|
+
const forcedColors = (archetype) => ({
|
|
199
|
+
name: "forced-colors-focus-visible",
|
|
200
|
+
criteria: ["1.4.11", "2.4.7"],
|
|
201
|
+
async run(ctx) {
|
|
202
|
+
const forced = await ctx.variant({ forcedColors: true });
|
|
203
|
+
await forced.settle(300);
|
|
204
|
+
let focused;
|
|
205
|
+
if (await hasTrigger(forced)) {
|
|
206
|
+
if ((await forced.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't checked.");
|
|
207
|
+
} else {
|
|
208
|
+
await forced.page.keyboard.press("Tab");
|
|
209
|
+
await forced.settle(100);
|
|
210
|
+
}
|
|
211
|
+
focused = await forced.page.evaluate(() => window.__a11yMeasure.focusStyles(false));
|
|
212
|
+
if (!focused || (focused.box.width === 0 && focused.box.height === 0) || (await forced.page.evaluate(() => document.activeElement === document.body))) return na("Nothing on the page takes keyboard focus, so there's no focus indicator to check.");
|
|
213
|
+
await forced.settle(400);
|
|
214
|
+
const clip = forced.clipAround(focused.box, 12);
|
|
215
|
+
const shotFocused = await forced.page.screenshot({ clip, animations: "disabled" });
|
|
216
|
+
await forced.page.evaluate(() => /** @type {HTMLElement} */ (document.activeElement)?.blur?.());
|
|
217
|
+
await forced.page.mouse.move(0, 0);
|
|
218
|
+
await forced.settle(300);
|
|
219
|
+
const shotBlurred = await forced.page.screenshot({ clip, animations: "disabled" });
|
|
220
|
+
const url = (buffer) => `data:image/png;base64,${buffer.toString("base64")}`;
|
|
221
|
+
const px = await forced.page.evaluate(([a, b]) => window.__a11yMeasure.compareShots(a, b), [url(shotBlurred), url(shotFocused)]);
|
|
222
|
+
const optOuts = await forced.page.evaluate(() => window.__a11yConditions.forcedColorOptOuts());
|
|
223
|
+
const note = optOuts.length ? ` ${plural(optOuts.length, "element")} opt${optOuts.length === 1 ? "s" : ""} out of forced colors with forced-color-adjust: none (${list(optOuts, 3)}), so a person should check them.` : "";
|
|
224
|
+
const perimeter = 2 * (focused.box.width + focused.box.height);
|
|
225
|
+
const measurements = [{ changedPixels: px.changed, pixelsAtLeast3to1: px.strong, perimeterPixels: Math.round(perimeter), forcedColorOptOuts: optOuts.length }];
|
|
226
|
+
if (px.changed === 0) return fail(`With forced colors on, nothing visible changed when the control took focus. A focus ring drawn with box-shadow or a background color disappears in forced colors. Use an outline.${note}`, { measurements, method: "pixels" });
|
|
227
|
+
return ringIsEnough(px.strong, perimeter)
|
|
228
|
+
? pass(`With forced colors on, the focus indicator is visible: ${num(px.strong)} of ${num(px.changed)} changed pixels reach 3:1 against their unfocused color.${note}`, { measurements, method: "pixels" })
|
|
229
|
+
: fail(`With forced colors on, the focus indicator is weak: only ${num(px.strong)} of ${num(px.changed)} changed pixels reach 3:1 against their unfocused color, and enough to count is about half the control's perimeter, ${Math.round(perimeter / 2)}.${note}`, { measurements, method: "pixels" });
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ---- reflow ----
|
|
234
|
+
|
|
235
|
+
const reflow = (archetype) => ({
|
|
236
|
+
name: "reflow-at-320px",
|
|
237
|
+
criteria: ["1.4.10"],
|
|
238
|
+
async run(ctx) {
|
|
239
|
+
const narrow = await ctx.variant({ viewport: { width: 320, height: 256 } });
|
|
240
|
+
await narrow.settle(300);
|
|
241
|
+
await reachState(narrow, archetype);
|
|
242
|
+
const o = await narrow.page.evaluate(() => window.__a11yConditions.overflow("[data-a11y-root]"));
|
|
243
|
+
const measurements = [{ viewportWidth: o.viewportWidth, scrollWidth: o.scrollWidth, overflowingElements: o.offenderCount }];
|
|
244
|
+
const problems = [];
|
|
245
|
+
if (o.scrollWidth > o.viewportWidth + 1) problems.push(`the page scrolls sideways (it is ${o.scrollWidth}px wide in a ${o.viewportWidth}px window)`);
|
|
246
|
+
if (o.offenders.length) problems.push(`${plural(o.offenderCount, "element")} reach${o.offenderCount === 1 ? "es" : ""} past the right edge: ${list(o.offenders.map((e) => `${e.element} ends at ${e.right}px`))}`);
|
|
247
|
+
if (o.root && (o.root.right > o.viewportWidth + 1 || o.root.left < -1)) problems.push(`${o.root.element} spans ${o.root.left}px to ${o.root.right}px`);
|
|
248
|
+
if (problems.length) return fail(`In a 320px window, ${problems.join(", and ")}. ${criterionRef("1.4.10")} exempts two-dimensional content such as data tables and maps, so a person should judge whether that applies.`, { measurements });
|
|
249
|
+
return pass(`In a 320px window, nothing reaches past the right edge and the page doesn't scroll sideways${o.root ? `. The ${o.root.element} fits (${o.root.left}px to ${o.root.right}px)` : ""}.`, { measurements });
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ---- text spacing ----
|
|
254
|
+
|
|
255
|
+
const textSpacing = (archetype) => ({
|
|
256
|
+
name: "text-spacing-no-clipping",
|
|
257
|
+
criteria: ["1.4.12"],
|
|
258
|
+
async run(ctx) {
|
|
259
|
+
await ctx.settle(200);
|
|
260
|
+
await reachState(ctx, archetype);
|
|
261
|
+
const before = await ctx.page.evaluate(() => window.__a11yConditions.clipping());
|
|
262
|
+
await ctx.page.evaluate(() => window.__a11yConditions.applySpacing());
|
|
263
|
+
await ctx.settle(200);
|
|
264
|
+
const after = await ctx.page.evaluate(() => window.__a11yConditions.clipping());
|
|
265
|
+
if (before.length !== after.length) return undetermined("The page's elements changed while the spacing was applied, so the before and after can't be compared.");
|
|
266
|
+
const clipped = (e) => (e.hidesX && e.overX > 1) || (e.hidesY && e.overY > 1);
|
|
267
|
+
const cut = [];
|
|
268
|
+
after.forEach((now, index) => {
|
|
269
|
+
const was = before[index];
|
|
270
|
+
if (clipped(now) && now.text && !now.visuallyHidden && (!clipped(was) || now.overX - was.overX > 1 || now.overY - was.overY > 1)) cut.push(`${now.element} ("${now.text}")`);
|
|
271
|
+
});
|
|
272
|
+
const measurements = [{ elementsChecked: after.length, clipped: cut.length }];
|
|
273
|
+
const settings = "line height 1.5, letter spacing 0.12em, word spacing 0.16em, and paragraph spacing 2em";
|
|
274
|
+
return cut.length
|
|
275
|
+
? fail(`With ${settings}, text in ${plural(cut.length, "element")} gets cut off: ${list(cut)}. Overlapping text isn't checked.`, { measurements })
|
|
276
|
+
: pass(`With ${settings}, no element cut off its text (${num(after.length)} elements checked). Overlapping text isn't checked.`, { measurements });
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/** The conditions checks for an archetype. Whole pages use the name "page". */
|
|
281
|
+
export function conditionChecksFor(archetype) {
|
|
282
|
+
return [reducedMotion(archetype), colorScheme(), moreContrast(), lessContrast(), reducedTransparency(), forcedColors(archetype), reflow(archetype), textSpacing(archetype)];
|
|
283
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { installMeasure } from "../computed/measure-kit.js";
|
|
2
|
+
import { installHelpers } from "../interactions/helpers.js";
|
|
3
|
+
import { runCheck } from "../interactions/index.js";
|
|
4
|
+
import { conditionChecksFor } from "./checks.js";
|
|
5
|
+
import { installConditions } from "./kit.js";
|
|
6
|
+
|
|
7
|
+
/** These checks open each page twice, and sometimes press keys, so they get longer than the others. */
|
|
8
|
+
const TIMEOUT_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run the conditions checks against a fixture page or a whole page. Each check opens its own fresh pages.
|
|
12
|
+
* A check that can't finish reports `error`, and one that can't reduce the page to colors reports `undetermined`.
|
|
13
|
+
* Neither counts as a pass.
|
|
14
|
+
* @param {import("playwright-core").Browser} browser
|
|
15
|
+
* @param {string} url
|
|
16
|
+
* @param {string} archetype An archetype name for a fixture, or "page" for a whole page.
|
|
17
|
+
*/
|
|
18
|
+
export async function runConditions(browser, url, archetype) {
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const check of conditionChecksFor(archetype)) {
|
|
21
|
+
results.push(await runCheck(browser, url, check, { kits: [installHelpers, installMeasure, installConditions], needsTrigger: archetype !== "page", timeoutMs: TIMEOUT_MS, waitUntil: archetype === "page" ? "networkidle" : "load" }));
|
|
22
|
+
}
|
|
23
|
+
return { status: "ran", checks: results };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a Storybook target says about this tier. */
|
|
27
|
+
export const CONDITIONS_NOT_APPLICABLE_FOR_STORIES = {
|
|
28
|
+
status: "not-applicable",
|
|
29
|
+
reason: "Conditions checks open the whole page again with other settings. Storybook stories are audited inside their own frame, so this version doesn't run them.",
|
|
30
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs inside the page before any page script. It sets `window.__a11yConditions`, which reads what the page does under
|
|
3
|
+
* a user's settings and environment: running animations, content that spills past the window, and content a text spacing
|
|
4
|
+
* override clips. It reports plain facts. The checks in checks.js decide pass or fail.
|
|
5
|
+
* This function is serialized and sent to the browser, so it can't use anything from outside itself.
|
|
6
|
+
*/
|
|
7
|
+
export function installConditions() {
|
|
8
|
+
/** A short, readable name for an element: `button#save`, `div.alert`, or `span`. */
|
|
9
|
+
const describe = (el) => {
|
|
10
|
+
if (!el || el.nodeType !== 1) return "(document)";
|
|
11
|
+
const tag = el.localName;
|
|
12
|
+
if (el.id) return `${tag}#${el.id}`;
|
|
13
|
+
const first = typeof el.className === "string" ? el.className.trim().split(/\s+/).find(Boolean) : "";
|
|
14
|
+
return first ? `${tag}.${first}` : tag;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Keyframe properties that move or resize something. Opacity and color changes aren't motion. */
|
|
18
|
+
const MOVING = /^(transform|translate|rotate|scale|top|left|right|bottom|inset\w*|margin\w*|offset(Path|Distance|Rotate)|backgroundPosition\w*|height|maxHeight|minHeight|width|maxWidth|minWidth|clipPath|clip)$/;
|
|
19
|
+
const IGNORED_KEYS = new Set(["offset", "easing", "composite", "computedOffset"]);
|
|
20
|
+
|
|
21
|
+
const allElements = () => [...document.body.querySelectorAll("*")];
|
|
22
|
+
|
|
23
|
+
/** The scroll container an element sits in, if one hides its overflow sideways (so it doesn't push the page wider). */
|
|
24
|
+
const scrollsSideways = (el) => {
|
|
25
|
+
for (let node = el.parentElement; node && node !== document.documentElement; node = node.parentElement) {
|
|
26
|
+
const x = getComputedStyle(node).overflowX;
|
|
27
|
+
if (x === "auto" || x === "scroll" || x === "hidden" || x === "clip") return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
window.__a11yConditions = {
|
|
33
|
+
describe,
|
|
34
|
+
/** Every animation and transition running right now. */
|
|
35
|
+
animations() {
|
|
36
|
+
return document.getAnimations().map((/** @type {any} */ animation) => {
|
|
37
|
+
const effect = /** @type {any} */ (animation.effect);
|
|
38
|
+
const timing = effect?.getComputedTiming?.() ?? {};
|
|
39
|
+
const keyframes = effect?.getKeyframes?.() ?? [];
|
|
40
|
+
const props = [...new Set(keyframes.flatMap((frame) => Object.keys(frame)).filter((key) => !IGNORED_KEYS.has(key)))];
|
|
41
|
+
const target = effect?.target ?? null;
|
|
42
|
+
const iterations = timing.iterations === Infinity ? "infinite" : timing.iterations;
|
|
43
|
+
return {
|
|
44
|
+
kind: animation.constructor.name,
|
|
45
|
+
name: animation.animationName ?? animation.transitionProperty ?? "",
|
|
46
|
+
target: describe(target),
|
|
47
|
+
duration: typeof timing.duration === "number" ? timing.duration : null,
|
|
48
|
+
iterations,
|
|
49
|
+
props,
|
|
50
|
+
moving: props.some((prop) => MOVING.test(prop)),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
/** How far the page reaches past the window's right edge, and which elements do it. */
|
|
55
|
+
overflow(rootSelector) {
|
|
56
|
+
const width = document.documentElement.clientWidth;
|
|
57
|
+
const offenders = [];
|
|
58
|
+
for (const el of allElements()) {
|
|
59
|
+
const box = el.getBoundingClientRect();
|
|
60
|
+
if (box.width === 0 || box.height === 0) continue;
|
|
61
|
+
const style = getComputedStyle(el);
|
|
62
|
+
if (style.visibility === "hidden" || style.display === "none") continue;
|
|
63
|
+
if (box.right > width + 1 && !scrollsSideways(el)) offenders.push({ element: describe(el), right: Math.round(box.right), width: Math.round(box.width), left: Math.round(box.left) });
|
|
64
|
+
}
|
|
65
|
+
const root = rootSelector ? document.querySelector(rootSelector) : null;
|
|
66
|
+
const rootBox = root && root.getClientRects().length ? root.getBoundingClientRect() : null;
|
|
67
|
+
return {
|
|
68
|
+
viewportWidth: width,
|
|
69
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
70
|
+
offenders: offenders.slice(0, 8),
|
|
71
|
+
offenderCount: offenders.length,
|
|
72
|
+
root: rootBox ? { element: describe(root), left: Math.round(rootBox.left), right: Math.round(rootBox.right) } : null,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
/** For every element, whether its box cuts off its own content, so two snapshots can show what a change clips. */
|
|
76
|
+
clipping() {
|
|
77
|
+
return allElements().map((el) => {
|
|
78
|
+
const style = getComputedStyle(el);
|
|
79
|
+
const hidesX = ["hidden", "clip"].includes(style.overflowX);
|
|
80
|
+
const hidesY = ["hidden", "clip"].includes(style.overflowY);
|
|
81
|
+
const box = el.getBoundingClientRect();
|
|
82
|
+
return {
|
|
83
|
+
/** A one-pixel box that hides its overflow is a screen reader only copy. Nobody sees its text, so nothing is cut off. */
|
|
84
|
+
visuallyHidden: box.width <= 1 && box.height <= 1,
|
|
85
|
+
element: describe(el),
|
|
86
|
+
hidesX,
|
|
87
|
+
hidesY,
|
|
88
|
+
overX: el.scrollWidth - el.clientWidth,
|
|
89
|
+
overY: el.scrollHeight - el.clientHeight,
|
|
90
|
+
text: (el.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 30),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
/** Apply the text spacing the success criterion names, with !important so authored styles can't hold it back. */
|
|
95
|
+
applySpacing() {
|
|
96
|
+
const style = document.createElement("style");
|
|
97
|
+
style.setAttribute("data-a11y-spacing", "");
|
|
98
|
+
style.textContent = "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }";
|
|
99
|
+
document.head.append(style);
|
|
100
|
+
},
|
|
101
|
+
/**
|
|
102
|
+
* Surfaces that carry text over a see-through background or a blur: a background color that isn't fully opaque,
|
|
103
|
+
* or a backdrop filter. Empty overlays are left out, because they hold no text for the effect to get in the way of.
|
|
104
|
+
*/
|
|
105
|
+
translucentSurfaces() {
|
|
106
|
+
const toRgba = window.__a11yMeasure?.rgba;
|
|
107
|
+
const found = [];
|
|
108
|
+
for (const el of allElements()) {
|
|
109
|
+
const box = el.getBoundingClientRect();
|
|
110
|
+
if (box.width === 0 || box.height === 0) continue;
|
|
111
|
+
const style = getComputedStyle(el);
|
|
112
|
+
if (style.visibility === "hidden" || style.display === "none") continue;
|
|
113
|
+
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim()) || el.matches("input, textarea, select, button");
|
|
114
|
+
const holdsText = own || (el.textContent ?? "").trim().length > 0;
|
|
115
|
+
if (!holdsText) continue;
|
|
116
|
+
const color = toRgba ? toRgba(style.backgroundColor) : null;
|
|
117
|
+
const seeThrough = Boolean(color) && color[3] > 0 && color[3] < 1;
|
|
118
|
+
const blur = style.backdropFilter && style.backdropFilter !== "none" ? style.backdropFilter : "";
|
|
119
|
+
if (seeThrough || blur) found.push({ element: describe(el), background: seeThrough ? `${Math.round(color[3] * 100)}% opaque` : "", backdropFilter: blur });
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
},
|
|
123
|
+
/** Elements that opt out of forced colors, and so keep their own colors. */
|
|
124
|
+
forcedColorOptOuts() {
|
|
125
|
+
return allElements().filter((el) => getComputedStyle(el).forcedColorAdjust === "none").slice(0, 20).map(describe);
|
|
126
|
+
},
|
|
127
|
+
/** A cheap fingerprint of how the page looks in the current color scheme. */
|
|
128
|
+
appearance() {
|
|
129
|
+
const html = getComputedStyle(document.documentElement);
|
|
130
|
+
return { colorScheme: html.colorScheme, background: getComputedStyle(document.body).backgroundColor, color: getComputedStyle(document.body).color };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Checks only use the two hooks (`data-a11y-trigger`, `data-a11y-root`) and ARIA roles.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { focusIndicatorChanges } from "./focus-indicator.js";
|
|
9
|
+
|
|
8
10
|
const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
|
|
9
11
|
const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
|
|
10
12
|
const na = (detail) => ({ result: "not-applicable", detail });
|
|
@@ -28,15 +30,15 @@ const COMMON = [
|
|
|
28
30
|
criteria: ["2.4.7"],
|
|
29
31
|
async run(ctx) {
|
|
30
32
|
if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't checked.");
|
|
31
|
-
const focused = await ctx.page.evaluate(() => window.__a11y.
|
|
33
|
+
const focused = await ctx.page.evaluate(() => window.__a11y.focusSnapshot());
|
|
32
34
|
await ctx.page.evaluate(() => window.__a11y.remember());
|
|
33
35
|
const clip = ctx.clipAround(focused.box);
|
|
34
36
|
const shotFocused = await ctx.page.screenshot({ clip });
|
|
35
37
|
await ctx.page.evaluate(() => /** @type {any} */ (window).__a11yLast?.blur());
|
|
36
38
|
await ctx.settle();
|
|
37
|
-
const unfocused = await ctx.page.evaluate(() => window.__a11y.
|
|
38
|
-
const changed =
|
|
39
|
-
if (changed.length) return pass(`Computed style changed on focus: ${changed.join("
|
|
39
|
+
const unfocused = await ctx.page.evaluate(() => window.__a11y.lastSnapshot());
|
|
40
|
+
const changed = focusIndicatorChanges(focused.parts, unfocused?.parts);
|
|
41
|
+
if (changed.length) return pass(`Computed style changed on focus: ${changed.join("; ")}.`, { method: "computed-style" });
|
|
40
42
|
const shotBlurred = await ctx.page.screenshot({ clip });
|
|
41
43
|
if (!shotFocused.equals(shotBlurred)) return pass("The pixels around the trigger changed on focus, though no style property did.", { method: "screenshot" });
|
|
42
44
|
return fail("Nothing visible changed when the trigger got keyboard focus. Checked computed styles first, then a screenshot comparison.", { method: "screenshot" });
|
|
@@ -85,9 +87,120 @@ async function openMenu(ctx) {
|
|
|
85
87
|
return null;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// ---- live-region: a message that appears or changes without moving focus ----
|
|
91
|
+
|
|
92
|
+
/** Press Enter on the trigger and wait for the message. Returns what the page looked like before, and after. */
|
|
93
|
+
async function showMessage(ctx) {
|
|
94
|
+
await ctx.focus();
|
|
95
|
+
const before = await ctx.page.evaluate(() => window.__a11y.live());
|
|
96
|
+
await ctx.press("Enter");
|
|
97
|
+
const deadline = Date.now() + 2500;
|
|
98
|
+
let after = await ctx.page.evaluate(() => window.__a11y.live());
|
|
99
|
+
while (Date.now() < deadline && !(after.present && after.visible && (after.text || after.named))) {
|
|
100
|
+
await ctx.settle(80);
|
|
101
|
+
after = await ctx.page.evaluate(() => window.__a11y.live());
|
|
102
|
+
}
|
|
103
|
+
return { before, after, appeared: after.present && after.visible };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const NO_MESSAGE = "The message didn't appear when Enter was pressed on the trigger.";
|
|
107
|
+
|
|
108
|
+
const LIVE_REGION_CHECKS = [
|
|
109
|
+
{
|
|
110
|
+
name: "message-in-live-region",
|
|
111
|
+
criteria: ["4.1.3"],
|
|
112
|
+
async run(ctx) {
|
|
113
|
+
const { after, appeared } = await showMessage(ctx);
|
|
114
|
+
if (!appeared) return fail(NO_MESSAGE);
|
|
115
|
+
if (!after.region) return fail("The message isn't inside an element with role alert, status, or log, or with an aria-live attribute, so a screen reader isn't told when it appears.");
|
|
116
|
+
if (after.region.politeness === "off") return fail(`The message sits in a region that doesn't announce (${after.region.role ? `role ${after.region.role}` : "aria-live"}${after.region.ariaLive ? `, aria-live="${after.region.ariaLive}"` : ""}), so a screen reader isn't told when it appears.`);
|
|
117
|
+
return pass(`The message is in a live region (${after.region.role ? `role ${after.region.role}, ` : ""}${after.region.politeness}).`);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "message-has-text",
|
|
122
|
+
criteria: ["4.1.3"],
|
|
123
|
+
async run(ctx) {
|
|
124
|
+
const { after, appeared } = await showMessage(ctx);
|
|
125
|
+
if (!appeared) return na(`${NO_MESSAGE} Its text wasn't checked.`);
|
|
126
|
+
return after.text || after.named ? pass(`The message says "${after.text || "(named by aria-label)"}".`) : fail("The message has no text and no accessible name, so there's nothing to announce.");
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "region-exists-before-message",
|
|
131
|
+
criteria: ["4.1.3"],
|
|
132
|
+
async run(ctx) {
|
|
133
|
+
const { before, after, appeared } = await showMessage(ctx);
|
|
134
|
+
if (!appeared) return na(`${NO_MESSAGE} Its region wasn't checked.`);
|
|
135
|
+
if (!after.region || after.region.politeness === "off") return na("The message isn't in a live region, so the region's timing doesn't apply.");
|
|
136
|
+
if (before.regionUids.includes(after.region.uid)) return pass("The live region was already in the page before the message, so assistive technology was watching it.");
|
|
137
|
+
if (after.region.role === "alert") return pass("The live region was added together with its message. role=\"alert\" is announced when it's inserted, so this works.");
|
|
138
|
+
return before.ancestorUids.includes(after.region.uid)
|
|
139
|
+
? fail("The element was in the page before the message, but it only became a live region when the message arrived. Screen readers often miss that. Put role or aria-live in the page's HTML, and change only what's inside the region.")
|
|
140
|
+
: fail("The live region was added to the page together with its message. Screen readers often miss that. Keep the region in the page and change what's inside it.");
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "live-politeness-fits-role",
|
|
145
|
+
criteria: ["4.1.3"],
|
|
146
|
+
async run(ctx) {
|
|
147
|
+
const { after, appeared } = await showMessage(ctx);
|
|
148
|
+
if (!appeared) return na(`${NO_MESSAGE} Its politeness wasn't checked.`);
|
|
149
|
+
const r = after.region;
|
|
150
|
+
if (!r) return na("The message isn't in a live region, so there's no politeness to check.");
|
|
151
|
+
if (r.role === "alert" && r.ariaLive && r.ariaLive.toLowerCase() !== "assertive") return fail(`role="alert" is assertive, but aria-live="${r.ariaLive}" on the same element overrides it.`);
|
|
152
|
+
if (r.role === "status" && (r.ariaLive ?? "").toLowerCase() === "off") return fail('role="status" is polite, but aria-live="off" on the same element turns announcing off.');
|
|
153
|
+
return pass(`The region announces ${r.politeness}${r.role ? `, as role ${r.role} implies` : ""}.`);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "focus-stays-on-trigger",
|
|
158
|
+
criteria: ["4.1.3"],
|
|
159
|
+
async run(ctx) {
|
|
160
|
+
const { appeared } = await showMessage(ctx);
|
|
161
|
+
if (!appeared) return na(`${NO_MESSAGE} Focus wasn't checked.`);
|
|
162
|
+
await ctx.settle(200);
|
|
163
|
+
const s = await ctx.snap();
|
|
164
|
+
return s.activeIsTrigger ? pass("Focus stayed on the trigger when the message appeared.") : fail(`Focus moved to ${where(s)} when the message appeared. A status message is announced without taking focus.`);
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "dismiss-works-by-keyboard",
|
|
169
|
+
criteria: ["2.1.1"],
|
|
170
|
+
async run(ctx) {
|
|
171
|
+
const { appeared } = await showMessage(ctx);
|
|
172
|
+
if (!appeared) return na(`${NO_MESSAGE} Its controls weren't checked.`);
|
|
173
|
+
const found = await ctx.page.evaluate(() => window.__a11y.focusDismiss());
|
|
174
|
+
if (!found.found) return na("The message has no control inside it, so there's nothing to dismiss.");
|
|
175
|
+
await ctx.settle();
|
|
176
|
+
await ctx.press("Enter");
|
|
177
|
+
const gone = await ctx.waitFor((s) => !s.rootExists || !s.rootVisible, 1500);
|
|
178
|
+
return gone ? pass(`Enter on the ${found.tag}${found.label ? ` "${found.label}"` : ""} removed the message.`) : fail(`Enter on the ${found.tag}${found.label ? ` "${found.label}"` : ""} didn't remove the message.`);
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "focus-kept-after-removal",
|
|
183
|
+
criteria: ["2.4.3"],
|
|
184
|
+
async run(ctx) {
|
|
185
|
+
const { appeared } = await showMessage(ctx);
|
|
186
|
+
if (!appeared) return na(`${NO_MESSAGE} Focus after removal wasn't checked.`);
|
|
187
|
+
const found = await ctx.page.evaluate(() => window.__a11y.focusDismiss());
|
|
188
|
+
if (!found.found) return na("The message has no control inside it, so nothing removes it from the keyboard.");
|
|
189
|
+
await ctx.settle();
|
|
190
|
+
await ctx.press("Enter");
|
|
191
|
+
if (!(await ctx.waitFor((s) => !s.rootExists || !s.rootVisible, 1500))) return na("Enter didn't remove the message, so where focus goes wasn't checked.");
|
|
192
|
+
await ctx.settle(200);
|
|
193
|
+
const s = await ctx.snap();
|
|
194
|
+
return s.bodyActive ? fail("Focus fell back to the page when the message was removed. Move it to the trigger or another control.") : pass(`Focus moved to ${where(s)} when the message was removed.`);
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
|
|
88
199
|
// ---- The tables ----
|
|
89
200
|
|
|
90
201
|
export const ARCHETYPE_CHECKS = {
|
|
202
|
+
"live-region": LIVE_REGION_CHECKS,
|
|
203
|
+
|
|
91
204
|
button: [
|
|
92
205
|
{ name: "enter-activates", criteria: ["2.1.1"], run: activates("Enter", "Enter") },
|
|
93
206
|
{ name: "space-activates", criteria: ["2.1.1"], run: activates("Space", "Space") },
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare the resolved styles of a control with and without keyboard focus, and say which changes can show a focus indicator.
|
|
3
|
+
* A change counts only if a person could see it: an outline with a width and a color, a box shadow, a border, a color change,
|
|
4
|
+
* a text decoration, or an element that appeared inside. An outline offset alone doesn't count, because it moves nothing visible.
|
|
5
|
+
* @param {Record<string, Record<string, any>>} focused Parts of the focused control, keyed by where they are.
|
|
6
|
+
* @param {Record<string, Record<string, any>> | undefined} unfocused The same parts without focus.
|
|
7
|
+
* @returns {string[]} Each change, such as `the element: outline`.
|
|
8
|
+
*/
|
|
9
|
+
export function focusIndicatorChanges(focused, unfocused) {
|
|
10
|
+
const changes = [];
|
|
11
|
+
for (const [where, now] of Object.entries(focused)) {
|
|
12
|
+
const before = unfocused?.[where];
|
|
13
|
+
if (!before) {
|
|
14
|
+
if (now.rendered) changes.push(`${where}: appeared on focus`);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
// A ring that stays in the page but only shows on focus, such as a ripple, goes from not rendered to rendered.
|
|
18
|
+
if (now.rendered && !before.rendered) {
|
|
19
|
+
changes.push(`${where}: appeared on focus`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const found = [];
|
|
23
|
+
const drawnOutline = (s) => s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0 && !transparent(s.outlineColor);
|
|
24
|
+
if (drawnOutline(now) && (!drawnOutline(before) || now.outlineWidth !== before.outlineWidth || now.outlineColor !== before.outlineColor || now.outlineStyle !== before.outlineStyle)) found.push("outline");
|
|
25
|
+
if (now.boxShadow !== before.boxShadow) found.push("box-shadow");
|
|
26
|
+
const drawnBorder = (s) => s.borderTopStyle !== "none" && parseFloat(s.borderTopWidth) > 0;
|
|
27
|
+
if (drawnBorder(now) && (now.borderTopColor !== before.borderTopColor || now.borderTopWidth !== before.borderTopWidth || !drawnBorder(before))) found.push("border");
|
|
28
|
+
if (now.backgroundColor !== before.backgroundColor) found.push("background color");
|
|
29
|
+
if (now.color !== before.color) found.push("text color");
|
|
30
|
+
if (now.textDecorationLine !== before.textDecorationLine) found.push("text decoration");
|
|
31
|
+
if (!now.rendered && !before.rendered) continue;
|
|
32
|
+
for (const what of found) changes.push(`${where}: ${what}`);
|
|
33
|
+
}
|
|
34
|
+
return changes;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function transparent(color) {
|
|
38
|
+
return color === "transparent" || /^rgba\(.*,\s*0\)$/.test(color) || /\/\s*0\)$/.test(color);
|
|
39
|
+
}
|