automatica11y 0.3.3 → 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 +25 -7
- package/package.json +5 -3
- package/skills/automatica11y-runner/SKILL.md +44 -13
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- 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 +1 -0
- package/src/harness/bundle.js +7 -6
- 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 +38 -7
- 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 +10 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +14 -9
- package/src/report/parts.js +36 -12
- package/src/run/audit-npm.js +118 -28
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +19 -4
- package/src/run/summary.js +5 -0
- package/src/schema.js +30 -6
- package/src/tiers/computed/checks.js +44 -5
- package/src/tiers/computed/color.js +8 -0
- package/src/tiers/computed/index.js +2 -2
- package/src/tiers/computed/measure-kit.js +30 -10
- 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 +111 -0
- package/src/tiers/interactions/helpers.js +56 -0
- package/src/tiers/interactions/index.js +16 -3
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -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
|
+
}
|
|
@@ -87,9 +87,120 @@ async function openMenu(ctx) {
|
|
|
87
87
|
return null;
|
|
88
88
|
}
|
|
89
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
|
+
|
|
90
199
|
// ---- The tables ----
|
|
91
200
|
|
|
92
201
|
export const ARCHETYPE_CHECKS = {
|
|
202
|
+
"live-region": LIVE_REGION_CHECKS,
|
|
203
|
+
|
|
93
204
|
button: [
|
|
94
205
|
{ name: "enter-activates", criteria: ["2.1.1"], run: activates("Enter", "Enter") },
|
|
95
206
|
{ name: "space-activates", criteria: ["2.1.1"], run: activates("Space", "Space") },
|
|
@@ -63,6 +63,21 @@ export function installHelpers() {
|
|
|
63
63
|
const byId = (el, id) => (el.getRootNode().getElementById ? el.getRootNode().getElementById(id) : document.getElementById(id));
|
|
64
64
|
const text = (el) => (el?.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80);
|
|
65
65
|
|
|
66
|
+
/** The text a person sees in an element, following slots to the text assigned to them. */
|
|
67
|
+
const flatText = (el) => {
|
|
68
|
+
if (!el) return "";
|
|
69
|
+
let out = "";
|
|
70
|
+
const walk = (node) => {
|
|
71
|
+
if (node.nodeType === 3) out += node.nodeValue;
|
|
72
|
+
else if (node.nodeType === 1 && node.localName === "slot") {
|
|
73
|
+
const assigned = node.assignedNodes({ flatten: true });
|
|
74
|
+
(assigned.length ? assigned : [...node.childNodes]).forEach(walk);
|
|
75
|
+
} else if (node.nodeType === 1) node.childNodes.forEach(walk);
|
|
76
|
+
};
|
|
77
|
+
walk(el);
|
|
78
|
+
return out.trim().replace(/\s+/g, " ").slice(0, 80);
|
|
79
|
+
};
|
|
80
|
+
|
|
66
81
|
/** The element ARIA says is active: the focused element, or the descendant it points to. */
|
|
67
82
|
const activeTarget = () => {
|
|
68
83
|
const active = deepActive();
|
|
@@ -71,6 +86,20 @@ export function installHelpers() {
|
|
|
71
86
|
return (pointed && byId(active, pointed)) || active;
|
|
72
87
|
};
|
|
73
88
|
|
|
89
|
+
const LIVE_ROLES = { alert: "assertive", status: "polite", log: "polite", marquee: "off", timer: "off" };
|
|
90
|
+
/** How a live region announces: its aria-live value if it has a valid one, otherwise what its role implies. */
|
|
91
|
+
const politeness = (el) => {
|
|
92
|
+
const set = (el.getAttribute("aria-live") ?? "").trim().toLowerCase();
|
|
93
|
+
if (["off", "polite", "assertive"].includes(set)) return set;
|
|
94
|
+
return LIVE_ROLES[el.getAttribute("role") ?? ""] ?? "off";
|
|
95
|
+
};
|
|
96
|
+
const isLiveElement = (el) => Object.hasOwn(LIVE_ROLES, el.getAttribute("role") ?? "") || el.hasAttribute("aria-live");
|
|
97
|
+
/** The closest element, from `el` upward, that is a live region or has an aria-live attribute. */
|
|
98
|
+
const liveRegionOf = (el) => {
|
|
99
|
+
for (let node = el; node; node = node.assignedSlot || node.parentElement || node.getRootNode?.().host) if (node.nodeType === 1 && isLiveElement(node)) return node;
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
|
|
74
103
|
window.__a11yClicks = 0;
|
|
75
104
|
document.addEventListener("click", () => (window.__a11yClicks += 1), true);
|
|
76
105
|
|
|
@@ -116,6 +145,33 @@ export function installHelpers() {
|
|
|
116
145
|
visible: visible(el),
|
|
117
146
|
}));
|
|
118
147
|
},
|
|
148
|
+
/** The message (data-a11y-root) and the live region it sits in, as they are right now. */
|
|
149
|
+
live() {
|
|
150
|
+
const root = queryDeep("[data-a11y-root]");
|
|
151
|
+
let region = root ? liveRegionOf(root) : null;
|
|
152
|
+
// A web component can keep the role on a wrapper inside its shadow root, around a slot that shows the message.
|
|
153
|
+
if (!region && root?.shadowRoot) region = [...root.shadowRoot.querySelectorAll("[role], [aria-live]")].find((el) => isLiveElement(el) && el.querySelector("slot")) ?? null;
|
|
154
|
+
const chain = [];
|
|
155
|
+
for (let node = root; node && chain.length < 200; node = node.assignedSlot || node.parentElement || node.getRootNode?.().host) chain.push(uid(node));
|
|
156
|
+
return {
|
|
157
|
+
/** The message and everything above it, so a later check can tell whether the region's element was already in the page. */
|
|
158
|
+
ancestorUids: chain,
|
|
159
|
+
present: Boolean(root),
|
|
160
|
+
visible: visible(root),
|
|
161
|
+
text: flatText(root),
|
|
162
|
+
named: Boolean(root && (root.getAttribute("aria-label") || root.getAttribute("aria-labelledby"))),
|
|
163
|
+
region: region ? { uid: uid(region), role: region.getAttribute("role"), ariaLive: region.getAttribute("aria-live"), politeness: politeness(region), isMessage: region === root } : null,
|
|
164
|
+
regionUids: queryAllDeep("[role], [aria-live]").filter(isLiveElement).map(uid),
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
/** Move focus to the first control inside the message, such as a dismiss button. Returns what it found. */
|
|
168
|
+
focusDismiss() {
|
|
169
|
+
const root = queryDeep("[data-a11y-root]");
|
|
170
|
+
const control = root ? [...root.querySelectorAll('button, a[href], input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])')].find(visible) : null;
|
|
171
|
+
if (!control) return { found: false };
|
|
172
|
+
control.focus();
|
|
173
|
+
return { found: true, tag: control.localName, label: text(control) || control.getAttribute("aria-label") || "" };
|
|
174
|
+
},
|
|
119
175
|
/** The name a control gets from its label, in the order browsers use. */
|
|
120
176
|
nameSources() {
|
|
121
177
|
const control = queryDeep("[data-a11y-trigger]");
|
|
@@ -66,13 +66,25 @@ export function makeContext(page) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Run one check on a fresh page, so no check inherits another's state. */
|
|
69
|
-
export async function runCheck(browser, url, check, { timeoutMs = CHECK_TIMEOUT_MS, kits = [installHelpers] } = {}) {
|
|
69
|
+
export async function runCheck(browser, url, check, { timeoutMs = CHECK_TIMEOUT_MS, kits = [installHelpers], needsTrigger = true, waitUntil = /** @type {"load" | "networkidle"} */ ("load") } = {}) {
|
|
70
70
|
/** @type {Awaited<ReturnType<typeof openPage>> | null} */
|
|
71
71
|
let opened = null;
|
|
72
|
+
/** @type {Array<Awaited<ReturnType<typeof openPage>>>} */
|
|
73
|
+
const variants = [];
|
|
74
|
+
const beforeGoto = async (page) => { for (const kit of kits) await page.addInitScript(kit); };
|
|
72
75
|
try {
|
|
73
|
-
opened = await openPage(browser, url, { beforeGoto
|
|
76
|
+
opened = await openPage(browser, url, { beforeGoto, waitUntil });
|
|
74
77
|
const ctx = makeContext(opened.page);
|
|
75
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Open the same page again with other browser settings (reduced motion, dark mode, a narrow window), and get a context for it.
|
|
80
|
+
* It closes with the check.
|
|
81
|
+
*/
|
|
82
|
+
ctx.variant = async (options) => {
|
|
83
|
+
const other = await openPage(browser, url, { ...options, beforeGoto, waitUntil });
|
|
84
|
+
variants.push(other);
|
|
85
|
+
return makeContext(other.page);
|
|
86
|
+
};
|
|
87
|
+
if (needsTrigger) await ctx.trigger.waitFor({ state: "attached", timeout: 3000 });
|
|
76
88
|
const outcome = await Promise.race([
|
|
77
89
|
check.run(ctx),
|
|
78
90
|
new Promise((_, reject) => setTimeout(() => reject(new Error(`The check took longer than ${timeoutMs / 1000} seconds.`)), timeoutMs)),
|
|
@@ -82,6 +94,7 @@ export async function runCheck(browser, url, check, { timeoutMs = CHECK_TIMEOUT_
|
|
|
82
94
|
return { name: check.name, criteria: check.criteria, result: "error", detail: `The check couldn't finish: ${(error instanceof Error ? error.message : String(error)).split("\n")[0]}` };
|
|
83
95
|
} finally {
|
|
84
96
|
await opened?.close();
|
|
97
|
+
for (const other of variants) await other.close();
|
|
85
98
|
}
|
|
86
99
|
}
|
|
87
100
|
|
package/src/harness/npm-react.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/** React flavor: the entry files and button and link templates that go into a bundle. */
|
|
2
|
-
|
|
3
|
-
/** Mounts a fixture's default export. */
|
|
4
|
-
export const entry = (fixturePath, _pkg) => `import { createElement } from "react";
|
|
5
|
-
import { createRoot } from "react-dom/client";
|
|
6
|
-
import Fixture from ${JSON.stringify(fixturePath)};
|
|
7
|
-
const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
|
|
8
|
-
createRoot(document.getElementById("root")).render(createElement(Fixture, { libA11y }));
|
|
9
|
-
`;
|
|
10
|
-
|
|
11
|
-
/** Loads the whole package, lists its exports, and records compound parts such as Dialog.Trigger. */
|
|
12
|
-
export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
|
|
13
|
-
const out = [];
|
|
14
|
-
for (const name of Object.keys(lib)) {
|
|
15
|
-
const value = lib[name];
|
|
16
|
-
const type = typeof value;
|
|
17
|
-
if (value === null || (type !== "function" && type !== "object")) continue;
|
|
18
|
-
const parts = Object.keys(value).filter((key) => /^[A-Z]/.test(key)).slice(0, 30);
|
|
19
|
-
out.push({ name, type, parts });
|
|
20
|
-
}
|
|
21
|
-
window.__a11yExports = out;
|
|
22
|
-
`;
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* A fixture for the simple archetypes, from the export name alone.
|
|
26
|
-
* Compound components (dialog, tabs, menu) can't be guessed, so they need a fixture someone writes.
|
|
27
|
-
*/
|
|
28
|
-
export function template(archetype, pkg, exportName) {
|
|
29
|
-
const body = {
|
|
30
|
-
button: `<Component data-a11y-trigger data-a11y-root type="button">Save</Component>`,
|
|
31
|
-
link: `<Component data-a11y-trigger data-a11y-root href="#top">Read more</Component>`,
|
|
32
|
-
}[archetype];
|
|
33
|
-
if (!body) return null;
|
|
34
|
-
return `import { ${exportName} as Component } from ${JSON.stringify(pkg)};
|
|
35
|
-
export default function Fixture() {
|
|
36
|
-
return ${body};
|
|
37
|
-
}
|
|
38
|
-
`;
|
|
39
|
-
}
|
package/src/harness/npm-wc.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/** Web component flavor: framework-free entries and templates. */
|
|
2
|
-
|
|
3
|
-
/** Loads the package so its custom elements get defined, then calls the fixture's default export, `mount(container)`. */
|
|
4
|
-
export const entry = (fixturePath, pkg) => `import ${JSON.stringify(pkg)};
|
|
5
|
-
import mount from ${JSON.stringify(fixturePath)};
|
|
6
|
-
const libA11y = new URLSearchParams(location.search).get("libA11y") === "on";
|
|
7
|
-
await mount(document.getElementById("root"), { libA11y });
|
|
8
|
-
`;
|
|
9
|
-
|
|
10
|
-
/** Loads the whole package. The page's init script records every custom element the package defines. */
|
|
11
|
-
export const discoverEntry = (pkg) => `import * as lib from ${JSON.stringify(pkg)};
|
|
12
|
-
window.__a11yExports = Object.keys(lib).map((name) => ({ name, type: typeof lib[name], parts: [] }));
|
|
13
|
-
`;
|
|
14
|
-
|
|
15
|
-
/** A fixture for the simple archetypes, from the tag name alone. */
|
|
16
|
-
export function template(archetype, tag) {
|
|
17
|
-
const attrs = {
|
|
18
|
-
button: `el.textContent = "Save";`,
|
|
19
|
-
link: `el.setAttribute("href", "#top");\n el.textContent = "Read more";`,
|
|
20
|
-
}[archetype];
|
|
21
|
-
if (!attrs) return null;
|
|
22
|
-
return `export default function mount(container) {
|
|
23
|
-
const el = document.createElement(${JSON.stringify(tag)});
|
|
24
|
-
el.setAttribute("data-a11y-trigger", "");
|
|
25
|
-
el.setAttribute("data-a11y-root", "");
|
|
26
|
-
${attrs}
|
|
27
|
-
container.append(el);
|
|
28
|
-
}
|
|
29
|
-
`;
|
|
30
|
-
}
|