@orkestrel/test 0.0.7 → 0.0.9
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/dist/src/browser/index.d.ts +1136 -16
- package/dist/src/browser/index.js +1455 -101
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +441 -5
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +267 -5
- package/dist/src/core/index.d.ts +267 -5
- package/dist/src/core/index.js +430 -6
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +441 -3
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +261 -4
- package/dist/src/server/index.d.ts +261 -4
- package/dist/src/server/index.js +432 -5
- package/dist/src/server/index.js.map +1 -1
- package/package.json +7 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { page, userEvent } from "vitest/browser";
|
|
1
|
+
import { commands, page, userEvent } from "vitest/browser";
|
|
2
2
|
//#region src/browser/constants.ts
|
|
3
3
|
/**
|
|
4
4
|
* The interactive ARIA roles a bare accessible name is searched across.
|
|
@@ -26,6 +26,151 @@ var ACCESSIBLE_ROLES = Object.freeze([
|
|
|
26
26
|
"textbox",
|
|
27
27
|
"treeitem"
|
|
28
28
|
]);
|
|
29
|
+
/**
|
|
30
|
+
* The page a browser paints an unstyled document onto.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* This is the floor a backdrop walk ends on wherever the caller wants the browser's own canvas
|
|
34
|
+
* assumed. `readBackdrop` takes its floor as an argument rather than reaching for this one, so a
|
|
35
|
+
* measurement over a surface the canvas never shows through names the color it actually sits on.
|
|
36
|
+
*/
|
|
37
|
+
var CANVAS_COLOR = Object.freeze([
|
|
38
|
+
255,
|
|
39
|
+
255,
|
|
40
|
+
255,
|
|
41
|
+
1
|
|
42
|
+
]);
|
|
43
|
+
/**
|
|
44
|
+
* The attribute marking the runner's tester pane, and the rule that sizes it, while a frame is
|
|
45
|
+
* staged.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* `stagePane` writes it onto the pane and onto the stylesheet it appends, and `releasePane` finds
|
|
49
|
+
* both by it. Nothing else reads it, so a document carrying it after a capture returned is a pane
|
|
50
|
+
* that was never released.
|
|
51
|
+
*/
|
|
52
|
+
var CAPTURE_PANE = "data-capture-pane";
|
|
53
|
+
/**
|
|
54
|
+
* The roles whose accessible name is the text a reader can see inside them.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* `readName` reads an element in this list from its own rendered text, after every `aria-hidden`
|
|
58
|
+
* descendant is dropped, and falls through to `title` for every other role.
|
|
59
|
+
*/
|
|
60
|
+
var CONTENT_ROLES = Object.freeze([
|
|
61
|
+
"button",
|
|
62
|
+
"cell",
|
|
63
|
+
"columnheader",
|
|
64
|
+
"heading",
|
|
65
|
+
"link",
|
|
66
|
+
"listitem",
|
|
67
|
+
"option",
|
|
68
|
+
"row",
|
|
69
|
+
"rowheader",
|
|
70
|
+
"tab"
|
|
71
|
+
]);
|
|
72
|
+
/**
|
|
73
|
+
* The role each `input` type carries.
|
|
74
|
+
*
|
|
75
|
+
* @remarks
|
|
76
|
+
* Membership is the contract. The map answers for `button`, `checkbox`, `email`, `number`,
|
|
77
|
+
* `password`, `radio`, `range`, `reset`, `search`, `submit`, `tel`, `text`, and `url`. A type the
|
|
78
|
+
* map omits — `color`, `date`, `file`, `hidden`, and the rest — exposes no role of its own, so
|
|
79
|
+
* `readRole` returns `undefined` for it and `describeTree` writes no line for it.
|
|
80
|
+
*/
|
|
81
|
+
var FIELD_ROLES = Object.freeze({
|
|
82
|
+
button: "button",
|
|
83
|
+
checkbox: "checkbox",
|
|
84
|
+
email: "textbox",
|
|
85
|
+
number: "spinbutton",
|
|
86
|
+
password: "textbox",
|
|
87
|
+
radio: "radio",
|
|
88
|
+
range: "slider",
|
|
89
|
+
reset: "button",
|
|
90
|
+
search: "searchbox",
|
|
91
|
+
submit: "button",
|
|
92
|
+
tel: "textbox",
|
|
93
|
+
text: "textbox",
|
|
94
|
+
url: "textbox"
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* What sequential keyboard navigation can reach, before disabled and unrendered elements go.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* `describeFocus` queries this selector and then drops what a browser drops: an element the
|
|
101
|
+
* accessibility tree does not present, a disabled control, and one removed from the sequence by
|
|
102
|
+
* `tabindex="-1"`. `traverseAccessible` counts the same population to bound its walk, so this is
|
|
103
|
+
* the one list either one reads.
|
|
104
|
+
*/
|
|
105
|
+
var FOCUSABLE_SELECTOR = "a[href], area[href], button, input, select, summary, textarea, [tabindex]";
|
|
106
|
+
/**
|
|
107
|
+
* The role a `th` carries for the header axis its `scope` names.
|
|
108
|
+
*
|
|
109
|
+
* @remarks
|
|
110
|
+
* A header cell heads a column or a row, and this map answers for the `col` and `row` scopes that
|
|
111
|
+
* say which. A `th` declaring no scope keeps {@link IMPLICIT_ROLES}' `columnheader` rather than the
|
|
112
|
+
* ARIA computation that infers the axis from the table's shape.
|
|
113
|
+
*/
|
|
114
|
+
var HEADER_ROLES = Object.freeze({
|
|
115
|
+
col: "columnheader",
|
|
116
|
+
row: "rowheader"
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* The role each listed tag carries in the accessibility tree when it declares none of its own.
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* Membership is the contract. The map answers for the sectioning elements `ARTICLE`, `ASIDE`,
|
|
123
|
+
* `FOOTER`, `HEADER`, `MAIN`, `NAV`, `SEARCH`, and `SECTION`; the headings `H1` through `H6`; the
|
|
124
|
+
* grouping and list elements `FIELDSET`, `FORM`, `HR`, `LI`, `OL`, and `UL`; the table elements
|
|
125
|
+
* `TABLE`, `TBODY`, `THEAD`, `TR`, `TD`, and `TH`; and the widgets `BUTTON`, `DIALOG`, `IMG`,
|
|
126
|
+
* `OPTION`, `OUTPUT`, `PROGRESS`, `SUMMARY`, and `TEXTAREA`.
|
|
127
|
+
*
|
|
128
|
+
* A tag the map omits carries no implicit role, so `readRole` returns `undefined` for it,
|
|
129
|
+
* `describeTree` writes no line for it, and the walk continues straight into its children at the
|
|
130
|
+
* depth the omitted element sat at. `A`, `INPUT`, and `SELECT` are absent deliberately: each takes
|
|
131
|
+
* its role from an attribute rather than from its tag, and `readRole` answers for them from their
|
|
132
|
+
* own anatomy.
|
|
133
|
+
*
|
|
134
|
+
* `SECTION` maps to `region`, which `readRole` withholds from an unnamed one, because an unnamed
|
|
135
|
+
* section is not a landmark. `TH` maps to `columnheader`, which {@link HEADER_ROLES} replaces when
|
|
136
|
+
* the cell declares a `scope`.
|
|
137
|
+
*/
|
|
138
|
+
var IMPLICIT_ROLES = Object.freeze({
|
|
139
|
+
ARTICLE: "article",
|
|
140
|
+
ASIDE: "complementary",
|
|
141
|
+
BUTTON: "button",
|
|
142
|
+
DIALOG: "dialog",
|
|
143
|
+
FIELDSET: "group",
|
|
144
|
+
FOOTER: "contentinfo",
|
|
145
|
+
FORM: "form",
|
|
146
|
+
H1: "heading",
|
|
147
|
+
H2: "heading",
|
|
148
|
+
H3: "heading",
|
|
149
|
+
H4: "heading",
|
|
150
|
+
H5: "heading",
|
|
151
|
+
H6: "heading",
|
|
152
|
+
HEADER: "banner",
|
|
153
|
+
HR: "separator",
|
|
154
|
+
IMG: "img",
|
|
155
|
+
LI: "listitem",
|
|
156
|
+
MAIN: "main",
|
|
157
|
+
NAV: "navigation",
|
|
158
|
+
OL: "list",
|
|
159
|
+
OPTION: "option",
|
|
160
|
+
OUTPUT: "status",
|
|
161
|
+
PROGRESS: "progressbar",
|
|
162
|
+
SEARCH: "search",
|
|
163
|
+
SECTION: "region",
|
|
164
|
+
SUMMARY: "button",
|
|
165
|
+
TABLE: "table",
|
|
166
|
+
TBODY: "rowgroup",
|
|
167
|
+
TD: "cell",
|
|
168
|
+
TEXTAREA: "textbox",
|
|
169
|
+
TH: "columnheader",
|
|
170
|
+
THEAD: "rowgroup",
|
|
171
|
+
TR: "row",
|
|
172
|
+
UL: "list"
|
|
173
|
+
});
|
|
29
174
|
//#endregion
|
|
30
175
|
//#region src/browser/helpers.ts
|
|
31
176
|
/**
|
|
@@ -43,6 +188,69 @@ function isOutsideViewport(rectangle) {
|
|
|
43
188
|
return rectangle.bottom <= 0 || rectangle.right <= 0 || rectangle.top >= window.innerHeight || rectangle.left >= window.innerWidth;
|
|
44
189
|
}
|
|
45
190
|
/**
|
|
191
|
+
* Determines whether a person can click one element where it currently sits.
|
|
192
|
+
*
|
|
193
|
+
* @param element - The element to judge.
|
|
194
|
+
* @returns `true` when the element is connected, visible, laid out with a non-zero box, in the
|
|
195
|
+
* sequential focus order, neither disabled nor marked `aria-disabled="true"`, and outside every
|
|
196
|
+
* `[inert]` subtree; `false` otherwise.
|
|
197
|
+
*
|
|
198
|
+
* @remarks
|
|
199
|
+
* This is the one reachability filter the layer applies. `resolveRendered`, `clickAccessibleWithin`,
|
|
200
|
+
* and `clickDisclosure` each narrow their own candidates and then keep the ones this accepts, so a
|
|
201
|
+
* journey meets one rule rather than three near-copies of it.
|
|
202
|
+
*
|
|
203
|
+
* It measures geometry, which is what separates it from {@link isRendered}. A control clipped to a
|
|
204
|
+
* zero-size rectangle is announced and is not clickable, so `isRendered` accepts it and this
|
|
205
|
+
* refuses it. Nothing here asks about the viewport: `resolveAccessible` scrolls a wholly
|
|
206
|
+
* off-viewport target into view and measures that separately with {@link isOutsideViewport}.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* isReachable(requireValue(container.querySelector('button')))
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
function isReachable(element) {
|
|
214
|
+
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) return false;
|
|
215
|
+
const rectangle = element.getBoundingClientRect();
|
|
216
|
+
return element.isConnected && element.checkVisibility({
|
|
217
|
+
checkOpacity: true,
|
|
218
|
+
checkVisibilityCSS: true
|
|
219
|
+
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Determines whether the accessibility tree presents one element at all.
|
|
223
|
+
*
|
|
224
|
+
* @param element - The element to judge.
|
|
225
|
+
* @returns `false` when the element is hidden from assistive technology, from sight, or from both;
|
|
226
|
+
* `true` otherwise.
|
|
227
|
+
*
|
|
228
|
+
* @remarks
|
|
229
|
+
* A control clipped to a zero-size rectangle is still announced, which is the whole point of that
|
|
230
|
+
* idiom, so nothing here reads geometry: only the removals a browser honours — `aria-hidden`
|
|
231
|
+
* anywhere above it, the `hidden` attribute, a hidden input, and a `display` or `visibility` that
|
|
232
|
+
* takes it off the page. {@link isReachable} is the clickable half of the pair and does read
|
|
233
|
+
* geometry.
|
|
234
|
+
*
|
|
235
|
+
* The last two are asked about the element's ancestors as well as itself, which reading a computed
|
|
236
|
+
* `display` cannot do: the computed value of a child of a `display: none` container is the child's
|
|
237
|
+
* own, so a control inside a closed drawer reports itself as laid out. `checkVisibility` answers
|
|
238
|
+
* for the box tree, and `visibility` inherits, so between them an ancestor cannot hide a control
|
|
239
|
+
* from a reader and leave it standing in a description.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* isRendered(requireValue(container.querySelector('[aria-hidden="true"] button'))) // false
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
function isRendered(element) {
|
|
247
|
+
if (element.closest("[aria-hidden=\"true\"]") !== null) return false;
|
|
248
|
+
if (element instanceof HTMLElement && element.hidden) return false;
|
|
249
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
250
|
+
if (!element.checkVisibility()) return false;
|
|
251
|
+
return getComputedStyle(element).visibility !== "hidden";
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
46
254
|
* Resolves one rendered, focus-reachable interactive element without requiring it to intersect the
|
|
47
255
|
* viewport yet.
|
|
48
256
|
*
|
|
@@ -71,13 +279,7 @@ function resolveRendered(first, second) {
|
|
|
71
279
|
includeHidden: true
|
|
72
280
|
}).elements()) if (element instanceof HTMLElement && !matches.includes(element)) matches.push(element);
|
|
73
281
|
if (matches.length === 0) throw new Error(`No interactive element has the accessible name "${name}"`);
|
|
74
|
-
const reachable = matches.filter((element) =>
|
|
75
|
-
const rectangle = element.getBoundingClientRect();
|
|
76
|
-
return element.isConnected && element.checkVisibility({
|
|
77
|
-
checkOpacity: true,
|
|
78
|
-
checkVisibilityCSS: true
|
|
79
|
-
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
|
|
80
|
-
});
|
|
282
|
+
const reachable = matches.filter((element) => isReachable(element));
|
|
81
283
|
if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not visible and focus-reachable`);
|
|
82
284
|
if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements`);
|
|
83
285
|
const [target] = reachable;
|
|
@@ -127,14 +329,7 @@ async function clickAccessibleWithin(region, role, name) {
|
|
|
127
329
|
name,
|
|
128
330
|
exact: false,
|
|
129
331
|
includeHidden: true
|
|
130
|
-
}).elements().filter((element) =>
|
|
131
|
-
if (!(element instanceof HTMLElement)) return false;
|
|
132
|
-
const rectangle = element.getBoundingClientRect();
|
|
133
|
-
return element.isConnected && element.checkVisibility({
|
|
134
|
-
checkOpacity: true,
|
|
135
|
-
checkVisibilityCSS: true
|
|
136
|
-
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && !element.matches(":disabled, [aria-disabled=\"true\"]") && element.closest("[inert]") === null;
|
|
137
|
-
});
|
|
332
|
+
}).elements().filter((element) => element instanceof HTMLElement && isReachable(element));
|
|
138
333
|
if (reachable.length === 0) throw new Error(`Interactive target "${name}" is not reachable inside "${region}"`);
|
|
139
334
|
if (reachable.length > 1) throw new Error(`Interactive target "${name}" is ambiguous across ${reachable.length} elements inside "${region}"`);
|
|
140
335
|
const [target] = reachable;
|
|
@@ -146,25 +341,22 @@ async function clickAccessibleWithin(region, role, name) {
|
|
|
146
341
|
*
|
|
147
342
|
* @param name - The summary text a person reads.
|
|
148
343
|
* @returns A promise resolving after trusted activation completes.
|
|
149
|
-
* @throws When no
|
|
344
|
+
* @throws When no native summary with that rendered name passes {@link isReachable}, or several do.
|
|
150
345
|
*
|
|
151
346
|
* @remarks
|
|
152
347
|
* Chromium exposes `<summary>` as a native disclosure rather than through an ARIA role accepted by
|
|
153
348
|
* `getByRole`, so this resolver names the platform element and its rendered text directly.
|
|
154
349
|
*
|
|
350
|
+
* It applies the same {@link isReachable} filter the other acting verbs apply, so a summary marked
|
|
351
|
+
* `aria-disabled="true"` is refused here exactly as a button marked that way is refused there.
|
|
352
|
+
*
|
|
155
353
|
* @example
|
|
156
354
|
* ```ts
|
|
157
355
|
* await clickDisclosure('Advanced')
|
|
158
356
|
* ```
|
|
159
357
|
*/
|
|
160
358
|
async function clickDisclosure(name) {
|
|
161
|
-
const reachable = [...document.querySelectorAll("summary")].filter((element) => element.innerText.replaceAll(/\s+/g, " ").trim() === name).filter((element) =>
|
|
162
|
-
const rectangle = element.getBoundingClientRect();
|
|
163
|
-
return element.isConnected && element.checkVisibility({
|
|
164
|
-
checkOpacity: true,
|
|
165
|
-
checkVisibilityCSS: true
|
|
166
|
-
}) && rectangle.width > 0 && rectangle.height > 0 && element.tabIndex >= 0 && element.closest("[inert]") === null;
|
|
167
|
-
});
|
|
359
|
+
const reachable = [...document.querySelectorAll("summary")].filter((element) => element.innerText.replaceAll(/\s+/g, " ").trim() === name).filter((element) => isReachable(element));
|
|
168
360
|
if (reachable.length === 0) throw new Error(`Native disclosure "${name}" is not visible and focus-reachable`);
|
|
169
361
|
if (reachable.length > 1) throw new Error(`Native disclosure "${name}" is ambiguous across ${reachable.length} elements`);
|
|
170
362
|
const [target] = reachable;
|
|
@@ -236,7 +428,7 @@ async function pressKeys(keys) {
|
|
|
236
428
|
*/
|
|
237
429
|
async function traverseAccessible(name) {
|
|
238
430
|
resolveRendered(name);
|
|
239
|
-
const cap = document.querySelectorAll(
|
|
431
|
+
const cap = document.querySelectorAll(FOCUSABLE_SELECTOR).length * 3 + 10;
|
|
240
432
|
const visited = /* @__PURE__ */ new Set();
|
|
241
433
|
const trail = [];
|
|
242
434
|
for (let attempt = 0; attempt < cap; attempt += 1) {
|
|
@@ -356,6 +548,244 @@ function readValue(role, name) {
|
|
|
356
548
|
return control.value;
|
|
357
549
|
}
|
|
358
550
|
/**
|
|
551
|
+
* Reads one element's rendered text the way a name computation reads it.
|
|
552
|
+
*
|
|
553
|
+
* @param element - The element whose announced words are wanted.
|
|
554
|
+
* @returns The text with every `aria-hidden` descendant dropped and whitespace runs collapsed.
|
|
555
|
+
*
|
|
556
|
+
* @remarks
|
|
557
|
+
* A glyph marked `aria-hidden` contributes nothing to a name, so a control captioned by an icon
|
|
558
|
+
* plus a word reads as the word alone — which is what a reader hears, and what a verdict citing a
|
|
559
|
+
* description has to compare against the copy a template writes. Reach for `readRows` wherever the
|
|
560
|
+
* subject is what the page paints rather than what it announces: that one keeps the glyph.
|
|
561
|
+
*
|
|
562
|
+
* @example
|
|
563
|
+
* ```ts
|
|
564
|
+
* readText(requireValue(container.querySelector('button'))) // 'Save'
|
|
565
|
+
* ```
|
|
566
|
+
*/
|
|
567
|
+
function readText(element) {
|
|
568
|
+
const parts = [];
|
|
569
|
+
const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
570
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
571
|
+
const owner = node.parentElement;
|
|
572
|
+
if (owner === null || owner.closest("[aria-hidden=\"true\"]") !== null) continue;
|
|
573
|
+
parts.push(node.textContent ?? "");
|
|
574
|
+
}
|
|
575
|
+
return parts.join(" ").replaceAll(/\s+/g, " ").trim();
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Reads the role one element carries in the accessibility tree.
|
|
579
|
+
*
|
|
580
|
+
* @param element - The element to classify.
|
|
581
|
+
* @returns The declared role, the implicit one, or `undefined` when the element carries none.
|
|
582
|
+
*
|
|
583
|
+
* @remarks
|
|
584
|
+
* A declared `role` wins outright, and its first token is the answer when several are listed.
|
|
585
|
+
* Otherwise the element's own anatomy decides: an anchor is a link only while it holds an `href`,
|
|
586
|
+
* an `input` takes the role {@link FIELD_ROLES} gives its type, a `select` is a combobox until it
|
|
587
|
+
* offers several rows at once, a `section` is a region only once something names it, and a `th`
|
|
588
|
+
* heads whichever axis its `scope` names. Every other tag answers from {@link IMPLICIT_ROLES},
|
|
589
|
+
* whose membership is the contract for what this can answer at all.
|
|
590
|
+
*
|
|
591
|
+
* @example
|
|
592
|
+
* ```ts
|
|
593
|
+
* readRole(requireValue(container.querySelector('a[href]'))) // 'link'
|
|
594
|
+
* ```
|
|
595
|
+
*/
|
|
596
|
+
function readRole(element) {
|
|
597
|
+
const declared = element.getAttribute("role")?.trim();
|
|
598
|
+
if (declared !== void 0 && declared.length > 0) return declared.split(/\s+/)[0];
|
|
599
|
+
if (element instanceof HTMLAnchorElement) return element.href.length > 0 ? "link" : void 0;
|
|
600
|
+
if (element instanceof HTMLInputElement) return FIELD_ROLES[element.type];
|
|
601
|
+
if (element instanceof HTMLSelectElement) return element.multiple || element.size > 1 ? "listbox" : "combobox";
|
|
602
|
+
const implicit = IMPLICIT_ROLES[element.tagName];
|
|
603
|
+
const scope = element.tagName === "TH" ? element.getAttribute("scope")?.trim() : void 0;
|
|
604
|
+
if (scope !== void 0) return HEADER_ROLES[scope] ?? implicit;
|
|
605
|
+
if (implicit === "region" && !element.hasAttribute("aria-label") && !element.hasAttribute("aria-labelledby")) return;
|
|
606
|
+
return implicit;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Reads the accessible name one element is announced under.
|
|
610
|
+
*
|
|
611
|
+
* @param element - The element to name.
|
|
612
|
+
* @returns The computed name, or an empty string when the element carries none.
|
|
613
|
+
*
|
|
614
|
+
* @remarks
|
|
615
|
+
* The order is the one a browser follows: `aria-labelledby`, then `aria-label`, then a form
|
|
616
|
+
* control's own labels, then an image's `alt`, then the text inside a role {@link CONTENT_ROLES}
|
|
617
|
+
* names, then `title`. A submit, reset, or button input is named by its value, because it renders
|
|
618
|
+
* no text to read. An `aria-labelledby` naming several ids joins their texts in the order the
|
|
619
|
+
* attribute lists them, and an id nothing answers for is skipped rather than fatal.
|
|
620
|
+
*
|
|
621
|
+
* Each step answers only when it has something to say, so a step that carries nothing hands the
|
|
622
|
+
* element to the next one. An image whose `alt` is absent or blank is the case that shows it:
|
|
623
|
+
* `<img title="Chart">` is named `Chart` rather than the empty string its own `alt` step would
|
|
624
|
+
* have returned, and an image carrying both keeps answering `alt`.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* ```ts
|
|
628
|
+
* readName(requireValue(container.querySelector('button'))) // 'Save changes'
|
|
629
|
+
* ```
|
|
630
|
+
*/
|
|
631
|
+
function readName(element) {
|
|
632
|
+
const referenced = element.getAttribute("aria-labelledby");
|
|
633
|
+
if (referenced !== null) {
|
|
634
|
+
const named = referenced.split(/\s+/).map((id) => element.ownerDocument.getElementById(id)).filter((node) => node !== null).map((node) => readText(node)).filter((text) => text.length > 0);
|
|
635
|
+
if (named.length > 0) return named.join(" ");
|
|
636
|
+
}
|
|
637
|
+
const labelled = element.getAttribute("aria-label")?.trim();
|
|
638
|
+
if (labelled !== void 0 && labelled.length > 0) return labelled;
|
|
639
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
640
|
+
const labels = [...element.labels ?? []].map((label) => readText(label)).filter((text) => text.length > 0);
|
|
641
|
+
if (labels.length > 0) return labels.join(" ");
|
|
642
|
+
if (element instanceof HTMLInputElement && element.value.length > 0) {
|
|
643
|
+
if (FIELD_ROLES[element.type] === "button") return element.value;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (element instanceof HTMLImageElement) {
|
|
647
|
+
const alternative = element.alt.trim();
|
|
648
|
+
if (alternative.length > 0) return alternative;
|
|
649
|
+
}
|
|
650
|
+
const role = readRole(element);
|
|
651
|
+
if (role !== void 0 && CONTENT_ROLES.includes(role)) {
|
|
652
|
+
const text = readText(element);
|
|
653
|
+
if (text.length > 0) return text;
|
|
654
|
+
}
|
|
655
|
+
return element.getAttribute("title")?.trim() ?? "";
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Reads the states one element is announced in.
|
|
659
|
+
*
|
|
660
|
+
* @param element - The element to read.
|
|
661
|
+
* @returns Every state the element declares, in one fixed order.
|
|
662
|
+
*
|
|
663
|
+
* @remarks
|
|
664
|
+
* A state a reader is told about is one this records: what is unavailable, disclosed, pressed,
|
|
665
|
+
* current, refused, chosen, announcing itself, demanded, uneditable, described, or busy. The order
|
|
666
|
+
* is fixed, so two descriptions of the same surface are comparable line for line.
|
|
667
|
+
*
|
|
668
|
+
* A native disclosure states its expansion on the parent `details` element's own `open` rather than
|
|
669
|
+
* on an ARIA attribute, so a summary that declares no `aria-expanded` is read from the platform's
|
|
670
|
+
* one copy of that fact.
|
|
671
|
+
*
|
|
672
|
+
* @example
|
|
673
|
+
* ```ts
|
|
674
|
+
* readStates(requireValue(container.querySelector('summary'))) // ['collapsed']
|
|
675
|
+
* ```
|
|
676
|
+
*/
|
|
677
|
+
function readStates(element) {
|
|
678
|
+
const states = [];
|
|
679
|
+
if (element.matches(":disabled") || element.getAttribute("aria-disabled") === "true") states.push("disabled");
|
|
680
|
+
const expanded = element.getAttribute("aria-expanded");
|
|
681
|
+
if (expanded === "true") states.push("expanded");
|
|
682
|
+
if (expanded === "false") states.push("collapsed");
|
|
683
|
+
if (expanded === null && element.tagName === "SUMMARY" && element.parentElement instanceof HTMLDetailsElement) states.push(element.parentElement.open ? "expanded" : "collapsed");
|
|
684
|
+
const pressed = element.getAttribute("aria-pressed");
|
|
685
|
+
if (pressed !== null) states.push(`pressed=${pressed}`);
|
|
686
|
+
const current = element.getAttribute("aria-current");
|
|
687
|
+
if (current !== null && current !== "false") states.push("current");
|
|
688
|
+
if (element.getAttribute("aria-invalid") === "true") states.push("invalid");
|
|
689
|
+
if (element instanceof HTMLInputElement ? element.checked : element.getAttribute("aria-checked") === "true") states.push("checked");
|
|
690
|
+
const selected = element.getAttribute("aria-selected");
|
|
691
|
+
if (selected !== null) states.push(`selected=${selected}`);
|
|
692
|
+
const live = element.getAttribute("aria-live");
|
|
693
|
+
if (live !== null) states.push(`live=${live}`);
|
|
694
|
+
if (element.matches(":required")) states.push("required");
|
|
695
|
+
if (element instanceof HTMLInputElement && element.readOnly) states.push("readonly");
|
|
696
|
+
if (element.hasAttribute("aria-describedby")) states.push("described");
|
|
697
|
+
if (element.getAttribute("aria-busy") === "true") states.push("busy");
|
|
698
|
+
return Object.freeze(states);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Describes the accessible tree one rendered element presents.
|
|
702
|
+
*
|
|
703
|
+
* @param element - The host to walk, which is described first when it carries a role of its own.
|
|
704
|
+
* @returns One indented line per element carrying a role, naming its role, its name, and its
|
|
705
|
+
* states, in document order; an empty string when nothing in the subtree carries one.
|
|
706
|
+
*
|
|
707
|
+
* @remarks
|
|
708
|
+
* The walk is over the real rendered DOM, so what it reports is the tree the shipped markup and the
|
|
709
|
+
* shipped cascade produce together — a landmark lost to a hidden ancestor is missing here exactly as
|
|
710
|
+
* it is missing for a reader. An element {@link isRendered} refuses is dropped with its whole
|
|
711
|
+
* subtree.
|
|
712
|
+
*
|
|
713
|
+
* Depth follows the roles rather than the elements, so the indentation reads as the structure a
|
|
714
|
+
* screen reader announces instead of as the markup's nesting. An element {@link readRole} answers
|
|
715
|
+
* `undefined` for writes no line and adds no depth, so its children sit where it sat. That is how
|
|
716
|
+
* a wrapper `div` disappears, and it is also how an element {@link IMPLICIT_ROLES} does not answer
|
|
717
|
+
* for disappears — visibly, because its roled children stay at the depth it occupied.
|
|
718
|
+
*
|
|
719
|
+
* @example
|
|
720
|
+
* ```ts
|
|
721
|
+
* describeTree(container)
|
|
722
|
+
* // main "Board"
|
|
723
|
+
* // heading "Totals"
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
function describeTree(element) {
|
|
727
|
+
const lines = [];
|
|
728
|
+
const pending = [{
|
|
729
|
+
node: element,
|
|
730
|
+
depth: 0
|
|
731
|
+
}];
|
|
732
|
+
while (pending.length > 0) {
|
|
733
|
+
const entry = pending.pop();
|
|
734
|
+
if (entry === void 0) break;
|
|
735
|
+
if (!isRendered(entry.node)) continue;
|
|
736
|
+
const role = readRole(entry.node);
|
|
737
|
+
let depth = entry.depth;
|
|
738
|
+
if (role !== void 0) {
|
|
739
|
+
const name = readName(entry.node);
|
|
740
|
+
const states = readStates(entry.node);
|
|
741
|
+
lines.push(`${" ".repeat(depth)}${role}${name.length > 0 ? ` "${name}"` : ""}${states.length > 0 ? ` [${states.join(", ")}]` : ""}`);
|
|
742
|
+
depth += 1;
|
|
743
|
+
}
|
|
744
|
+
for (let index = entry.node.children.length - 1; index >= 0; index -= 1) {
|
|
745
|
+
const child = entry.node.children[index];
|
|
746
|
+
if (child !== void 0) pending.push({
|
|
747
|
+
node: child,
|
|
748
|
+
depth
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return lines.join("\n");
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Describes the order sequential keyboard navigation visits one element's controls in.
|
|
756
|
+
*
|
|
757
|
+
* @param element - The host to walk; its own controls are described, and it is not itself one.
|
|
758
|
+
* @returns One numbered line per reachable control, naming its role and its name.
|
|
759
|
+
*
|
|
760
|
+
* @remarks
|
|
761
|
+
* A positive `tabindex` is honoured, because a browser honours it: those controls come first in
|
|
762
|
+
* ascending order and everything else follows in document order. A control removed from the
|
|
763
|
+
* sequence by `tabindex="-1"`, by being disabled, or by not being rendered at all is absent here,
|
|
764
|
+
* which is the fact a focus-order verdict is about. A control {@link readRole} answers `undefined`
|
|
765
|
+
* for is named by its lowercased tag, so it is still counted rather than silently dropped.
|
|
766
|
+
*
|
|
767
|
+
* @example
|
|
768
|
+
* ```ts
|
|
769
|
+
* describeFocus(container)
|
|
770
|
+
* // 1. button "Save"
|
|
771
|
+
* // 2. link "Cancel"
|
|
772
|
+
* ```
|
|
773
|
+
*/
|
|
774
|
+
function describeFocus(element) {
|
|
775
|
+
return [...element.querySelectorAll(FOCUSABLE_SELECTOR)].filter((node) => isRendered(node) && !node.matches(":disabled") && node.getAttribute("tabindex") !== "-1").sort((first, second) => {
|
|
776
|
+
const left = Number.parseInt(first.getAttribute("tabindex") ?? "0", 10);
|
|
777
|
+
const right = Number.parseInt(second.getAttribute("tabindex") ?? "0", 10);
|
|
778
|
+
if (left > 0 && right > 0) return left - right;
|
|
779
|
+
if (left > 0) return -1;
|
|
780
|
+
if (right > 0) return 1;
|
|
781
|
+
return 0;
|
|
782
|
+
}).map((node, index) => {
|
|
783
|
+
const role = readRole(node) ?? node.tagName.toLowerCase();
|
|
784
|
+
const name = readName(node);
|
|
785
|
+
return `${String(index + 1)}. ${role}${name.length > 0 ? ` "${name}"` : ""}`;
|
|
786
|
+
}).join("\n");
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
359
789
|
* Waits for one animation frame to settle pending browser paint work.
|
|
360
790
|
*
|
|
361
791
|
* @returns A promise resolving after one `requestAnimationFrame`.
|
|
@@ -369,29 +799,418 @@ function waitForFrame() {
|
|
|
369
799
|
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
370
800
|
}
|
|
371
801
|
/**
|
|
372
|
-
*
|
|
802
|
+
* Builds one unmounted element of a known tag, wearing the classes, text, and attributes asked for.
|
|
803
|
+
*
|
|
804
|
+
* @param tag - The HTML tag name, which fixes the returned element's exact type.
|
|
805
|
+
* @param options - The class list, the text, and the attributes to apply.
|
|
806
|
+
* @returns The built element, not yet in any document.
|
|
807
|
+
*
|
|
808
|
+
* @remarks
|
|
809
|
+
* The element is unmounted on purpose, so a fixture is assembled before the page ever sees it and a
|
|
810
|
+
* test decides where it goes. Nothing here resolves against the cascade: a built element computes no
|
|
811
|
+
* style and lays out no box until {@link mount} puts it in the document.
|
|
812
|
+
*
|
|
813
|
+
* The text is set as text rather than parsed as markup, so a `<` in it stays a `<`. Use
|
|
814
|
+
* {@link render} where the fixture is markup.
|
|
815
|
+
*
|
|
816
|
+
* @example
|
|
817
|
+
* ```ts
|
|
818
|
+
* const button = build('button', { classes: 'primary', text: 'Save', attributes: { type: 'button' } })
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
function build(tag, options) {
|
|
822
|
+
const element = document.createElement(tag);
|
|
823
|
+
if (options?.classes !== void 0) element.className = options.classes;
|
|
824
|
+
if (options?.text !== void 0) element.textContent = options.text;
|
|
825
|
+
for (const [name, value] of Object.entries(options?.attributes ?? {})) element.setAttribute(name, value);
|
|
826
|
+
return element;
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Puts one element into the document and hands it straight back.
|
|
830
|
+
*
|
|
831
|
+
* @param element - The element to attach.
|
|
832
|
+
* @returns The same element, now appended to `document.body`.
|
|
833
|
+
*
|
|
834
|
+
* @remarks
|
|
835
|
+
* What this buys is the composition, not the attachment: the `append` method returns `void`, and
|
|
836
|
+
* this hands the element back, so it fits where an expression is expected. The {@link render} helper
|
|
837
|
+
* returns its fixture through it, and the {@link rgba} helper probes through `mount(build('span'))`.
|
|
838
|
+
* A bare `append` call breaks each of those call sites.
|
|
839
|
+
*
|
|
840
|
+
* Being connected is what the attachment then buys: `getComputedStyle` resolves against the shipped
|
|
841
|
+
* cascade, custom properties inherit from `:root`, and the element lays out a real box. A detached
|
|
842
|
+
* element answers each of those questions with the initial value instead, which reads as a styling
|
|
843
|
+
* defect rather than as a detached node.
|
|
844
|
+
*
|
|
845
|
+
* Taking it back out belongs to the consumer's teardown, because this records nothing: a browser
|
|
846
|
+
* test file shares one page, so a fixture left behind is the next test's resolver ambiguity. Build a
|
|
847
|
+
* recorded container in a setup module and remove it from an `afterEach` hook.
|
|
848
|
+
*
|
|
849
|
+
* @example
|
|
850
|
+
* ```ts
|
|
851
|
+
* const panel = mount(build('div', { classes: 'surface' }))
|
|
852
|
+
* panel.remove()
|
|
853
|
+
* ```
|
|
854
|
+
*/
|
|
855
|
+
function mount(element) {
|
|
856
|
+
document.body.append(element);
|
|
857
|
+
return element;
|
|
858
|
+
}
|
|
859
|
+
function render(first, second) {
|
|
860
|
+
if (second === void 0) {
|
|
861
|
+
const container = build("div");
|
|
862
|
+
container.innerHTML = first;
|
|
863
|
+
return mount(container);
|
|
864
|
+
}
|
|
865
|
+
const element = document.createElement(first);
|
|
866
|
+
element.className = second;
|
|
867
|
+
return mount(element);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Sets one field's value and announces it the way typing into the field does.
|
|
871
|
+
*
|
|
872
|
+
* @param element - The input or textarea to write into.
|
|
873
|
+
* @param text - The value to set.
|
|
874
|
+
*
|
|
875
|
+
* @remarks
|
|
876
|
+
* This is the synthetic pair of {@link typeAccessible}, for a component that listens for `input` and
|
|
877
|
+
* a test that has the element already. It sets the value in one write and dispatches one bubbling
|
|
878
|
+
* `input` event, so a delegated listener on an ancestor hears it. It sends no keystrokes, so a
|
|
879
|
+
* component reading `key`, composition, or selection sees nothing. The dispatched event is a plain
|
|
880
|
+
* `Event`, never an `InputEvent`, so a component reading `inputType` or testing
|
|
881
|
+
* `instanceof InputEvent` sees neither. Drive a component that reads any of those through
|
|
882
|
+
* `typeAccessible` instead.
|
|
883
|
+
*
|
|
884
|
+
* No `change` event follows. Use {@link commitInput} where the component waits for the field to be
|
|
885
|
+
* committed.
|
|
886
|
+
*
|
|
887
|
+
* @example
|
|
888
|
+
* ```ts
|
|
889
|
+
* typeInput(requireValue(container.querySelector('input')), 'Ada')
|
|
890
|
+
* ```
|
|
891
|
+
*/
|
|
892
|
+
function typeInput(element, text) {
|
|
893
|
+
element.value = text;
|
|
894
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Sets one field's value and commits it, the way typing and then leaving the field does.
|
|
898
|
+
*
|
|
899
|
+
* @param element - The input or textarea to write into.
|
|
900
|
+
* @param text - The value to set.
|
|
901
|
+
*
|
|
902
|
+
* @remarks
|
|
903
|
+
* The order is the browser's: {@link typeInput} first, so `input` is dispatched with the value
|
|
904
|
+
* already set, and one bubbling `change` after it. A component that reads the value from either
|
|
905
|
+
* event therefore reads `text` from both.
|
|
906
|
+
*
|
|
907
|
+
* @example
|
|
908
|
+
* ```ts
|
|
909
|
+
* commitInput(requireValue(container.querySelector('input')), 'Ada')
|
|
910
|
+
* ```
|
|
911
|
+
*/
|
|
912
|
+
function commitInput(element, text) {
|
|
913
|
+
typeInput(element, text);
|
|
914
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Clears both browser storage surfaces.
|
|
918
|
+
*
|
|
919
|
+
* @remarks
|
|
920
|
+
* A browser test file shares one page, so a key written by one test is read by the next one that
|
|
921
|
+
* looks for it. Call this from an `afterEach` hook, which runs after a failed test as well as a
|
|
922
|
+
* passing one, rather than at the end of each test that happens to write a key.
|
|
923
|
+
*
|
|
924
|
+
* @example
|
|
925
|
+
* ```ts
|
|
926
|
+
* afterEach(clearStorage)
|
|
927
|
+
* ```
|
|
928
|
+
*/
|
|
929
|
+
function clearStorage() {
|
|
930
|
+
localStorage.clear();
|
|
931
|
+
sessionStorage.clear();
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Deletes one IndexedDB database and reports what the request actually did.
|
|
935
|
+
*
|
|
936
|
+
* @param name - The database name to delete.
|
|
937
|
+
* @returns A promise resolving after the deletion completes.
|
|
938
|
+
* @throws Thrown when the request errors, and when an open connection blocks it.
|
|
939
|
+
*
|
|
940
|
+
* @remarks
|
|
941
|
+
* Deleting a database that was never created succeeds, so this is safe to call from a teardown hook
|
|
942
|
+
* that runs whether or not the test reached the code that opens one.
|
|
943
|
+
*
|
|
944
|
+
* A block is a rejection rather than a wait. `blocked` fires when another connection is still open,
|
|
945
|
+
* and a suite that swallowed it would leave the next test reading the previous test's records
|
|
946
|
+
* through a database that reports itself deleted. The connection holding it open is the caller's to
|
|
947
|
+
* close, so the block is handed back rather than absorbed.
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* ```ts
|
|
951
|
+
* afterEach(() => removeDatabase('ledger'))
|
|
952
|
+
* ```
|
|
953
|
+
*/
|
|
954
|
+
function removeDatabase(name) {
|
|
955
|
+
return new Promise((resolve, reject) => {
|
|
956
|
+
const request = globalThis.indexedDB.deleteDatabase(name);
|
|
957
|
+
request.addEventListener("success", () => resolve());
|
|
958
|
+
request.addEventListener("error", () => reject(/* @__PURE__ */ new Error(`IndexedDB database "${name}" could not be deleted`)));
|
|
959
|
+
request.addEventListener("blocked", () => reject(/* @__PURE__ */ new Error(`IndexedDB database "${name}" is blocked by an open connection`)));
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Parses one computed CSS color value into straight sRGB channels.
|
|
964
|
+
*
|
|
965
|
+
* @param value - A computed `rgb()`, `rgba()`, or `color(srgb …)` value.
|
|
966
|
+
* @returns The color's channels, or `undefined` when the value names no color this reader speaks.
|
|
967
|
+
*
|
|
968
|
+
* @remarks
|
|
969
|
+
* A computed color resolves to `rgb()` or `rgba()` for every legacy source, and a `color-mix()`
|
|
970
|
+
* declaration resolves to `color(srgb r g b [/ a])` with channels on the 0–1 scale. Both forms are
|
|
971
|
+
* read here and nothing else is: a keyword, a hex triple, an empty string from a detached element,
|
|
972
|
+
* and a color space the cascade never hands back all return `undefined`. Absence is the answer
|
|
973
|
+
* rather than a transparent color, so a caller decides what an unreadable value means instead of
|
|
974
|
+
* measuring a black it never saw.
|
|
975
|
+
*
|
|
976
|
+
* @example
|
|
977
|
+
* ```ts
|
|
978
|
+
* parseColor('rgba(255, 255, 255, 0.5)') // [255, 255, 255, 0.5]
|
|
979
|
+
* parseColor('rebeccapurple') // undefined
|
|
980
|
+
* ```
|
|
981
|
+
*/
|
|
982
|
+
function parseColor(value) {
|
|
983
|
+
const modern = /^color\(srgb\s+(?<red>[\d.]+)\s+(?<green>[\d.]+)\s+(?<blue>[\d.]+)(?:\s*\/\s*(?<alpha>[\d.]+))?\)$/u.exec(value);
|
|
984
|
+
const legacy = /^rgba?\((?<channels>[^)]*)\)$/u.exec(value);
|
|
985
|
+
const [red, green, blue, alpha = 1] = modern?.groups === void 0 ? (legacy?.groups?.channels ?? "").split(/[\s,/]+/u).filter((part) => part.length > 0).map((part) => Number.parseFloat(part)) : [
|
|
986
|
+
Number.parseFloat(modern.groups.red ?? "") * 255,
|
|
987
|
+
Number.parseFloat(modern.groups.green ?? "") * 255,
|
|
988
|
+
Number.parseFloat(modern.groups.blue ?? "") * 255,
|
|
989
|
+
modern.groups.alpha === void 0 ? 1 : Number.parseFloat(modern.groups.alpha)
|
|
990
|
+
];
|
|
991
|
+
if (red === void 0 || green === void 0 || blue === void 0) return void 0;
|
|
992
|
+
if (![
|
|
993
|
+
red,
|
|
994
|
+
green,
|
|
995
|
+
blue,
|
|
996
|
+
alpha
|
|
997
|
+
].every((channel) => Number.isFinite(channel))) return void 0;
|
|
998
|
+
return Object.freeze([
|
|
999
|
+
red,
|
|
1000
|
+
green,
|
|
1001
|
+
blue,
|
|
1002
|
+
alpha
|
|
1003
|
+
]);
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolves any CSS color expression to straight sRGB channels, by asking the browser.
|
|
1007
|
+
*
|
|
1008
|
+
* @param value - Any value the `color` property accepts: a keyword, a hex triple, a `var()`
|
|
1009
|
+
* reference, a `color-mix()`, or an already-computed `rgb()`.
|
|
1010
|
+
* @returns The resolved color's channels, or `undefined` when the CSSOM refuses the value or the
|
|
1011
|
+
* computed result names no color {@link parseColor} speaks.
|
|
1012
|
+
*
|
|
1013
|
+
* @remarks
|
|
1014
|
+
* This is the live half of the pair {@link parseColor} opens. `parseColor` reads text and speaks
|
|
1015
|
+
* only the computed syntaxes a cascade hands back; this stages a probe element, hands it to the real
|
|
1016
|
+
* cascade, and reads back what the engine computed — which is the only way a keyword, a hex triple,
|
|
1017
|
+
* or a `var()` reference becomes channels at all. The read itself goes through `parseColor`, so both
|
|
1018
|
+
* halves agree on what a computed value means.
|
|
1019
|
+
*
|
|
1020
|
+
* The probe is mounted, because an unmounted element inherits nothing and a `var()` reference to a
|
|
1021
|
+
* token declared on `:root` would resolve to the initial value instead. It is removed in a `finally`,
|
|
1022
|
+
* so a value that throws on the way through leaves no node behind.
|
|
1023
|
+
*
|
|
1024
|
+
* Refusal is the CSSOM's: an expression it will not parse leaves the probe's inline `color` empty
|
|
1025
|
+
* and this returns `undefined`. A `var()` naming an undeclared custom property is not refused,
|
|
1026
|
+
* because the cascade accepts it and computes the inherited color, so a test that means to catch a
|
|
1027
|
+
* missing token asserts on {@link token} rather than on this.
|
|
1028
|
+
*
|
|
1029
|
+
* @example
|
|
1030
|
+
* ```ts
|
|
1031
|
+
* rgba('rebeccapurple') // [102, 51, 153, 1]
|
|
1032
|
+
* rgba('not-a-color') // undefined
|
|
1033
|
+
* ```
|
|
1034
|
+
*/
|
|
1035
|
+
function rgba(value) {
|
|
1036
|
+
const probe = mount(build("span"));
|
|
1037
|
+
try {
|
|
1038
|
+
probe.style.color = value;
|
|
1039
|
+
if (probe.style.color === "") return void 0;
|
|
1040
|
+
return parseColor(style(probe, "color"));
|
|
1041
|
+
} finally {
|
|
1042
|
+
probe.remove();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Determines whether two colors render the same, within the rounding a browser does.
|
|
1047
|
+
*
|
|
1048
|
+
* @param first - A CSS color expression or an already-parsed color.
|
|
1049
|
+
* @param second - A CSS color expression or an already-parsed color.
|
|
1050
|
+
* @returns `true` when every channel and the alpha agree within the tolerance; `false` otherwise,
|
|
1051
|
+
* including when either side names no readable color.
|
|
1052
|
+
*
|
|
1053
|
+
* @remarks
|
|
1054
|
+
* Each string side is resolved through {@link rgba}, so a keyword, a token reference, and the
|
|
1055
|
+
* `rgb()` the engine computes for either of them compare equal without a test converting anything
|
|
1056
|
+
* first. A side that resolves to nothing makes the answer `false` rather than a throw, because this
|
|
1057
|
+
* is a predicate.
|
|
1058
|
+
*
|
|
1059
|
+
* The tolerance is half a channel step on the 0–255 scale, and the alpha is scaled onto that same
|
|
1060
|
+
* range before it is compared, so one number covers both. Half a step is what a composite of
|
|
1061
|
+
* translucent layers and a `color-mix()` round trip actually drift by; anything a reader could see
|
|
1062
|
+
* is further than that and reports unequal.
|
|
1063
|
+
*
|
|
1064
|
+
* @example
|
|
1065
|
+
* ```ts
|
|
1066
|
+
* colorEqual('rebeccapurple', 'rgb(102, 51, 153)') // true
|
|
1067
|
+
* colorEqual('red', [0, 0, 255, 1]) // false
|
|
1068
|
+
* ```
|
|
1069
|
+
*/
|
|
1070
|
+
function colorEqual(first, second) {
|
|
1071
|
+
const left = typeof first === "string" ? rgba(first) : first;
|
|
1072
|
+
const right = typeof second === "string" ? rgba(second) : second;
|
|
1073
|
+
if (left === void 0 || right === void 0) return false;
|
|
1074
|
+
const tolerance = .5;
|
|
1075
|
+
const [leftRed, leftGreen, leftBlue, leftAlpha] = left;
|
|
1076
|
+
const [rightRed, rightGreen, rightBlue, rightAlpha] = right;
|
|
1077
|
+
return Math.abs(leftRed - rightRed) <= tolerance && Math.abs(leftGreen - rightGreen) <= tolerance && Math.abs(leftBlue - rightBlue) <= tolerance && Math.abs(leftAlpha - rightAlpha) * 255 <= tolerance;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Composites one color over another.
|
|
373
1081
|
*
|
|
374
|
-
* @param
|
|
375
|
-
* @
|
|
1082
|
+
* @param front - The color painted on top.
|
|
1083
|
+
* @param back - The color already on the surface.
|
|
1084
|
+
* @returns The opaque result a reader sees, its alpha always `1`.
|
|
376
1085
|
*
|
|
377
1086
|
* @example
|
|
378
1087
|
* ```ts
|
|
379
|
-
*
|
|
380
|
-
* container.remove()
|
|
1088
|
+
* blendColor([255, 255, 255, 0.5], [0, 0, 0, 1]) // [127.5, 127.5, 127.5, 1]
|
|
381
1089
|
* ```
|
|
382
1090
|
*/
|
|
383
|
-
function
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
1091
|
+
function blendColor(front, back) {
|
|
1092
|
+
const [red, green, blue, alpha] = front;
|
|
1093
|
+
const [under, over, beneath] = back;
|
|
1094
|
+
return Object.freeze([
|
|
1095
|
+
red * alpha + under * (1 - alpha),
|
|
1096
|
+
green * alpha + over * (1 - alpha),
|
|
1097
|
+
blue * alpha + beneath * (1 - alpha),
|
|
1098
|
+
1
|
|
1099
|
+
]);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Measures one opaque color's WCAG relative luminance.
|
|
1103
|
+
*
|
|
1104
|
+
* @param color - The color to weigh. Its alpha is ignored, so composite before calling.
|
|
1105
|
+
* @returns The relative luminance, from `0` for black to `1` for white.
|
|
1106
|
+
*
|
|
1107
|
+
* @example
|
|
1108
|
+
* ```ts
|
|
1109
|
+
* measureLuminance([255, 255, 255, 1]) // 1
|
|
1110
|
+
* ```
|
|
1111
|
+
*/
|
|
1112
|
+
function measureLuminance(color) {
|
|
1113
|
+
const [red, green, blue] = color;
|
|
1114
|
+
const [first = 0, second = 0, third = 0] = [
|
|
1115
|
+
red,
|
|
1116
|
+
green,
|
|
1117
|
+
blue
|
|
1118
|
+
].map((channel) => {
|
|
1119
|
+
const part = channel / 255;
|
|
1120
|
+
return part <= .04045 ? part / 12.92 : ((part + .055) / 1.055) ** 2.4;
|
|
1121
|
+
});
|
|
1122
|
+
return .2126 * first + .7152 * second + .0722 * third;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Measures the WCAG 2.x contrast ratio between two opaque colors.
|
|
1126
|
+
*
|
|
1127
|
+
* @param front - The foreground color, already composited.
|
|
1128
|
+
* @param back - The opaque backdrop.
|
|
1129
|
+
* @returns The ratio, from `1` for two identical colors to `21` for black against white.
|
|
1130
|
+
*
|
|
1131
|
+
* @remarks
|
|
1132
|
+
* The ratio is symmetric: the brighter of the two luminances is always the numerator, so swapping
|
|
1133
|
+
* the arguments returns the same number.
|
|
1134
|
+
*
|
|
1135
|
+
* @example
|
|
1136
|
+
* ```ts
|
|
1137
|
+
* measureContrast([0, 0, 0, 1], [255, 255, 255, 1]) // 21
|
|
1138
|
+
* ```
|
|
1139
|
+
*/
|
|
1140
|
+
function measureContrast(front, back) {
|
|
1141
|
+
const bright = Math.max(measureLuminance(front), measureLuminance(back));
|
|
1142
|
+
const dark = Math.min(measureLuminance(front), measureLuminance(back));
|
|
1143
|
+
return (bright + .05) / (dark + .05);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Collects the painted layers standing between one element and the surface it sits on.
|
|
1147
|
+
*
|
|
1148
|
+
* @param element - The element to walk up from.
|
|
1149
|
+
* @returns Every layer the walk paints, the element's own first and the deepest last.
|
|
1150
|
+
*
|
|
1151
|
+
* @remarks
|
|
1152
|
+
* A surface token paints one ancestor while every element between it and the text paints nothing,
|
|
1153
|
+
* so a backdrop is found by walking up rather than by reading the element's own `background-color`,
|
|
1154
|
+
* which is almost always transparent. A fully transparent layer paints nothing and is left out, and
|
|
1155
|
+
* the walk stops at the first fully opaque layer, because nothing above that layer is visible.
|
|
1156
|
+
*
|
|
1157
|
+
* The stack is what tells a resolved backdrop from an assumed one: the walk reached an opaque
|
|
1158
|
+
* surface exactly when its last layer's alpha is `1`. {@link contrast} refuses on that reading,
|
|
1159
|
+
* which no comparison of composited colors can replace — 64 half-transparent layers composite to
|
|
1160
|
+
* the same channels over opposite floors, because the floor's remaining share falls below the last
|
|
1161
|
+
* bit a channel carries.
|
|
1162
|
+
*
|
|
1163
|
+
* @example
|
|
1164
|
+
* ```ts
|
|
1165
|
+
* readLayers(requireValue(container.querySelector('p')))
|
|
1166
|
+
* ```
|
|
1167
|
+
*/
|
|
1168
|
+
function readLayers(element) {
|
|
1169
|
+
const layers = [];
|
|
1170
|
+
for (let node = element; node !== null; node = node.parentElement) {
|
|
1171
|
+
const layer = parseColor(getComputedStyle(node).backgroundColor);
|
|
1172
|
+
if (layer === void 0 || layer[3] === 0) continue;
|
|
1173
|
+
layers.push(layer);
|
|
1174
|
+
if (layer[3] >= 1) break;
|
|
1175
|
+
}
|
|
1176
|
+
return Object.freeze(layers);
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Resolves the opaque color standing behind one element.
|
|
1180
|
+
*
|
|
1181
|
+
* @param element - The element whose backdrop to resolve.
|
|
1182
|
+
* @param floor - The opaque color the walk ends on when nothing above it paints.
|
|
1183
|
+
* @returns The composited color a reader sees behind the element.
|
|
1184
|
+
*
|
|
1185
|
+
* @remarks
|
|
1186
|
+
* The layers {@link readLayers} collects composite top-over-bottom onto the floor, so a 3% surface
|
|
1187
|
+
* tint reads as a tint over what shows through it rather than as a full-strength paint.
|
|
1188
|
+
*
|
|
1189
|
+
* The floor is required, because this leaf never guesses what a document sits on. Pass
|
|
1190
|
+
* {@link CANVAS_COLOR} for the page a browser paints behind an unstyled document, or the color of
|
|
1191
|
+
* the surface a fragment is really rendered into. When no layer paints, the floor is returned by
|
|
1192
|
+
* identity.
|
|
1193
|
+
*
|
|
1194
|
+
* The composite alone never says whether the floor is part of the answer. A caller that must know
|
|
1195
|
+
* reads the stack instead.
|
|
1196
|
+
*
|
|
1197
|
+
* @example
|
|
1198
|
+
* ```ts
|
|
1199
|
+
* readBackdrop(requireValue(container.querySelector('p')), CANVAS_COLOR)
|
|
1200
|
+
* ```
|
|
1201
|
+
*/
|
|
1202
|
+
function readBackdrop(element, floor) {
|
|
1203
|
+
return readLayers(element).reduceRight((back, front) => blendColor(front, back), floor);
|
|
388
1204
|
}
|
|
389
1205
|
/**
|
|
390
1206
|
* Measures the WCAG 2.x contrast ratio between an element's computed text and background colors.
|
|
391
1207
|
*
|
|
392
1208
|
* @param element - The element whose rendered text contrast to measure.
|
|
1209
|
+
* @param floor - The opaque color the backdrop walk ends on. Omit it to refuse a stack the floor
|
|
1210
|
+
* would show through instead of assuming one.
|
|
393
1211
|
* @returns The relative-luminance contrast ratio.
|
|
394
|
-
* @throws
|
|
1212
|
+
* @throws Thrown when the element exposes no computed foreground color, and — with `floor` omitted
|
|
1213
|
+
* — when the walk from the element upwards reaches no opaque layer.
|
|
395
1214
|
*
|
|
396
1215
|
* @remarks
|
|
397
1216
|
* A transparent or translucent background resolves through the element's ancestors: every painted
|
|
@@ -400,76 +1219,107 @@ function render(markup) {
|
|
|
400
1219
|
* full-strength paint. A translucent foreground then resolves against that effective background
|
|
401
1220
|
* before luminance is measured.
|
|
402
1221
|
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
1222
|
+
* With `floor` omitted, the walk from the target upwards must reach a fully opaque layer: the
|
|
1223
|
+
* measurement throws rather than assuming a white canvas wherever that canvas would still be part
|
|
1224
|
+
* of the answer. The refusal reads the alpha of the deepest layer {@link readLayers} collected, so
|
|
1225
|
+
* a chain that declares no background color at all, a chain painting only translucent layers, and a
|
|
1226
|
+
* chain deep enough for its composite to round to the canvas's own channels are refused alike,
|
|
1227
|
+
* because the number any of them produces is as much a report of the assumption as of the page.
|
|
1228
|
+
* Supply a floor wherever the caller knows what the stack sits on — a fragment mounted into a
|
|
1229
|
+
* painted host, or a document whose canvas is {@link CANVAS_COLOR} — and the composite is taken
|
|
1230
|
+
* over it rather than refused.
|
|
1231
|
+
*
|
|
1232
|
+
* The element itself must expose a computed foreground color either way. A detached element exposes
|
|
1233
|
+
* none, and the measurement throws rather than guessing one.
|
|
407
1234
|
*
|
|
408
1235
|
* @example
|
|
409
1236
|
* ```ts
|
|
410
1237
|
* const container = render('<p style="background: #000; color: #fff">Ready</p>')
|
|
411
1238
|
* contrast(requireValue(container.firstElementChild)) // 21
|
|
1239
|
+
* contrast(requireValue(container.firstElementChild), CANVAS_COLOR) // 21, and never refuses
|
|
412
1240
|
* ```
|
|
413
1241
|
*/
|
|
414
|
-
function contrast(element) {
|
|
415
|
-
const foreground = getComputedStyle(element).color
|
|
416
|
-
if (foreground ===
|
|
417
|
-
const layers =
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
1242
|
+
function contrast(element, floor) {
|
|
1243
|
+
const foreground = parseColor(getComputedStyle(element).color);
|
|
1244
|
+
if (foreground === void 0) throw new Error("Computed foreground color is unavailable");
|
|
1245
|
+
const layers = readLayers(element);
|
|
1246
|
+
const deepest = layers.at(-1);
|
|
1247
|
+
if (floor === void 0 && (deepest === void 0 || deepest[3] < 1)) throw new Error("Computed background color is unavailable");
|
|
1248
|
+
const backdrop = layers.reduceRight((back, front) => blendColor(front, back), floor ?? CANVAS_COLOR);
|
|
1249
|
+
return measureContrast(blendColor(foreground, backdrop), backdrop);
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Measures the contrast the focus chrome painted on one control reaches against its own backdrop.
|
|
1253
|
+
*
|
|
1254
|
+
* @param control - The control that holds the focus.
|
|
1255
|
+
* @param worn - The element the control's focus chrome is painted onto. Default: `control`.
|
|
1256
|
+
* @returns The strongest ratio the painted focus chrome reaches, or `undefined` when the control is
|
|
1257
|
+
* not showing `:focus-visible` or the cascade paints no chrome of its own.
|
|
1258
|
+
*
|
|
1259
|
+
* @remarks
|
|
1260
|
+
* This reads and never acts. Focus arrives through the published verbs — `traverseAccessible`,
|
|
1261
|
+
* `pressKeys`, a real click — and this measures what the browser painted once it landed. A control
|
|
1262
|
+
* that is not matching `:focus-visible` when the call is made reports nothing, because no
|
|
1263
|
+
* measurement taken then would be about focus.
|
|
1264
|
+
*
|
|
1265
|
+
* Some controls are two elements: one that takes the focus and one a reader can see. A hidden radio
|
|
1266
|
+
* beside the label that carries every pixel of its chrome is the case `worn` exists for, so a
|
|
1267
|
+
* measurement is not taken on a rectangle nobody is looking at. The focus state is still read off
|
|
1268
|
+
* `control`, because that is what holds it.
|
|
1269
|
+
*
|
|
1270
|
+
* The backdrop is the surface behind the element the chrome is worn on, resolved from that element's
|
|
1271
|
+
* parent through {@link readBackdrop} onto {@link CANVAS_COLOR}. A control whose ancestry paints
|
|
1272
|
+
* nothing is therefore measured against the browser's own canvas, which is what a reader looking at
|
|
1273
|
+
* an unstyled document sees.
|
|
1274
|
+
*
|
|
1275
|
+
* Only chrome the cascade paints is measured — an `outline` with a real style and width, and the
|
|
1276
|
+
* first color in a `box-shadow`. A control left the browser's own `outline-style: auto` ring reports
|
|
1277
|
+
* `undefined`, because that ring's two tones are guaranteed against any backdrop and its computed
|
|
1278
|
+
* color names neither. A focus style that only changes the control's own fill reports `undefined`
|
|
1279
|
+
* too: the resting fill is gone by the time focus is on the control, and this never moves focus to
|
|
1280
|
+
* go and read it.
|
|
1281
|
+
*
|
|
1282
|
+
* @example
|
|
1283
|
+
* ```ts
|
|
1284
|
+
* await traverseAccessible('Evaluate')
|
|
1285
|
+
* readRing(resolveRendered('Evaluate')) // the ratio the painted ring reaches
|
|
1286
|
+
* ```
|
|
1287
|
+
*/
|
|
1288
|
+
function readRing(control, worn) {
|
|
1289
|
+
if (!control.matches(":focus-visible")) return void 0;
|
|
1290
|
+
const target = worn ?? control;
|
|
1291
|
+
const declared = getComputedStyle(target);
|
|
1292
|
+
const backdrop = readBackdrop(target.parentElement ?? target, CANVAS_COLOR);
|
|
1293
|
+
const outline = declared.outlineStyle === "none" || declared.outlineStyle === "auto" || Number.parseFloat(declared.outlineWidth) === 0 ? void 0 : parseColor(declared.outlineColor);
|
|
1294
|
+
const shadow = parseColor(/(?:rgba?|color)\([^)]*\)/u.exec(declared.boxShadow)?.[0] ?? "");
|
|
1295
|
+
const ratios = [];
|
|
1296
|
+
for (const painted of [outline, shadow]) {
|
|
1297
|
+
if (painted === void 0) continue;
|
|
1298
|
+
ratios.push(measureContrast(blendColor(painted, backdrop), backdrop));
|
|
449
1299
|
}
|
|
450
|
-
|
|
451
|
-
const backgroundChannels = composed;
|
|
452
|
-
const foregroundLinear = foreground.slice(0, 3).map((channel, index) => {
|
|
453
|
-
const behind = backgroundChannels[index];
|
|
454
|
-
if (behind === void 0) throw new Error("Computed background channel is unavailable");
|
|
455
|
-
return Number(channel) / 255 * alpha + behind * (1 - alpha);
|
|
456
|
-
}).map((channel) => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
|
|
457
|
-
const backgroundLinear = backgroundChannels.map((channel) => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4);
|
|
458
|
-
const foregroundLuminance = .2126 * (foregroundLinear[0] ?? 0) + .7152 * (foregroundLinear[1] ?? 0) + .0722 * (foregroundLinear[2] ?? 0);
|
|
459
|
-
const backgroundLuminance = .2126 * (backgroundLinear[0] ?? 0) + .7152 * (backgroundLinear[1] ?? 0) + .0722 * (backgroundLinear[2] ?? 0);
|
|
460
|
-
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
461
|
-
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
462
|
-
return (lighter + .05) / (darker + .05);
|
|
1300
|
+
return ratios.length === 0 ? void 0 : Math.max(...ratios);
|
|
463
1301
|
}
|
|
464
1302
|
/**
|
|
465
1303
|
* Collects every class token the stylesheets loaded into this document actually define.
|
|
466
1304
|
*
|
|
467
|
-
* @returns The set of class names reachable in the shipped cascade.
|
|
1305
|
+
* @returns The set of class names reachable in the shipped cascade, in {@link readRules} order.
|
|
468
1306
|
*
|
|
469
1307
|
* @remarks
|
|
470
1308
|
* The set is what an authored-class conformance check measures against, so a class no loaded
|
|
471
1309
|
* stylesheet defines — an invented utility, a misspelled framework name — is absent from it.
|
|
472
1310
|
*
|
|
1311
|
+
* The tokens come from the {@link readRules} walk, which decides both the membership and the
|
|
1312
|
+
* insertion order this reader reports, and each answer is a deliberate difference from 0.0.8. A
|
|
1313
|
+
* class declared inside a grouping rule — a media query, a supports block, a layer, a nested style
|
|
1314
|
+
* rule — counts as defined, because a class the cascade defines under a condition is still one the
|
|
1315
|
+
* cascade defines; 0.0.8 read the top-level rules alone. Insertion order is breadth-first, so a
|
|
1316
|
+
* top-level class lands before a class declared inside an earlier grouping rule; 0.0.8 popped a
|
|
1317
|
+
* stack and inserted the deepest rule first. Iterate the set where the order is the subject, and
|
|
1318
|
+
* read `has` where membership is.
|
|
1319
|
+
*
|
|
1320
|
+
* `@keyframes` children are outside that walk, so an animation's own rules define no token here.
|
|
1321
|
+
* Reach the animation itself through {@link findKeyframes}.
|
|
1322
|
+
*
|
|
473
1323
|
* @example
|
|
474
1324
|
* ```ts
|
|
475
1325
|
* readCascade().has('card')
|
|
@@ -477,17 +1327,98 @@ function contrast(element) {
|
|
|
477
1327
|
*/
|
|
478
1328
|
function readCascade() {
|
|
479
1329
|
const known = /* @__PURE__ */ new Set();
|
|
480
|
-
const
|
|
481
|
-
for (const sheet of document.styleSheets) rules.push(...sheet.cssRules);
|
|
482
|
-
while (rules.length > 0) {
|
|
483
|
-
const rule = rules.pop();
|
|
484
|
-
if (rule instanceof CSSGroupingRule) rules.push(...rule.cssRules);
|
|
1330
|
+
for (const rule of readRules()) {
|
|
485
1331
|
if (!(rule instanceof CSSStyleRule)) continue;
|
|
486
1332
|
for (const match of rule.selectorText.matchAll(/\.([a-zA-Z][\w-]*)/g)) known.add(String(match[1]));
|
|
487
1333
|
}
|
|
488
1334
|
return known;
|
|
489
1335
|
}
|
|
490
1336
|
/**
|
|
1337
|
+
* Collects every rule the stylesheets loaded into this document hold, nested grouping rules
|
|
1338
|
+
* included.
|
|
1339
|
+
*
|
|
1340
|
+
* @returns Every rule reachable in the shipped cascade: each sheet's own rules in sheet order, then
|
|
1341
|
+
* the rules nested inside them, level by level.
|
|
1342
|
+
*
|
|
1343
|
+
* @remarks
|
|
1344
|
+
* The walk is iterative and reads the list it is still appending to, which is what expands a media
|
|
1345
|
+
* query, a supports block, a layer, and a nested style rule without recursion. Expanding by level
|
|
1346
|
+
* rather than by depth is why a top-level rule is always met before a rule nested inside an earlier
|
|
1347
|
+
* one; {@link findRule} returns the first match in exactly this order.
|
|
1348
|
+
*
|
|
1349
|
+
* The descent reaches a `CSSGroupingRule` and nothing else, and a `@keyframes` rule is not one. The
|
|
1350
|
+
* `@keyframes` rule itself is collected wherever it sits, and the keyframe rules inside it are not;
|
|
1351
|
+
* {@link findKeyframes} is the door to those.
|
|
1352
|
+
*
|
|
1353
|
+
* A stylesheet the document cannot read — a cross-origin sheet with no CORS grant — throws from its
|
|
1354
|
+
* own `cssRules` getter, and that sheet is skipped rather than ending the walk. What a page loaded
|
|
1355
|
+
* from another origin declares is unreadable to every caller here, so the alternative is a helper
|
|
1356
|
+
* that works until a test page adds a font or an analytics stylesheet.
|
|
1357
|
+
*
|
|
1358
|
+
* @example
|
|
1359
|
+
* ```ts
|
|
1360
|
+
* readRules().filter((rule) => rule instanceof CSSKeyframesRule)
|
|
1361
|
+
* ```
|
|
1362
|
+
*/
|
|
1363
|
+
function readRules() {
|
|
1364
|
+
const rules = [];
|
|
1365
|
+
for (const sheet of document.styleSheets) try {
|
|
1366
|
+
rules.push(...sheet.cssRules);
|
|
1367
|
+
} catch {
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
for (let index = 0; index < rules.length; index += 1) {
|
|
1371
|
+
const rule = rules[index];
|
|
1372
|
+
if (rule instanceof CSSGroupingRule) rules.push(...rule.cssRules);
|
|
1373
|
+
}
|
|
1374
|
+
return rules;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Finds the first style rule in the cascade whose selector carries a fragment.
|
|
1378
|
+
*
|
|
1379
|
+
* @param selector - The selector fragment to look for, matched as a substring of the whole selector
|
|
1380
|
+
* text.
|
|
1381
|
+
* @returns The first matching rule in {@link readRules} order, or `undefined` when no rule carries
|
|
1382
|
+
* the fragment.
|
|
1383
|
+
*
|
|
1384
|
+
* @remarks
|
|
1385
|
+
* This proves a declaration exists in the cascade at all, which is a different question from what an
|
|
1386
|
+
* element resolves to: {@link style} reads the winner, and a rule this finds may be overridden by
|
|
1387
|
+
* another. Assert on this where the subject is the stylesheet, and on `style` where the subject is
|
|
1388
|
+
* the rendered result.
|
|
1389
|
+
*
|
|
1390
|
+
* The match is a substring, so `findRule('.card')` finds `.card`, `.card:hover`, and
|
|
1391
|
+
* `.panel > .card` alike. Pass more of the selector to narrow it.
|
|
1392
|
+
*
|
|
1393
|
+
* @example
|
|
1394
|
+
* ```ts
|
|
1395
|
+
* findRule('.card')?.style.getPropertyValue('padding')
|
|
1396
|
+
* ```
|
|
1397
|
+
*/
|
|
1398
|
+
function findRule(selector) {
|
|
1399
|
+
for (const rule of readRules()) if (rule instanceof CSSStyleRule && rule.selectorText.includes(selector)) return rule;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Finds the animation the cascade declares under one name.
|
|
1403
|
+
*
|
|
1404
|
+
* @param name - The exact `@keyframes` name.
|
|
1405
|
+
* @returns The first matching rule in {@link readRules} order, or `undefined` when the cascade
|
|
1406
|
+
* declares no animation under that name.
|
|
1407
|
+
*
|
|
1408
|
+
* @remarks
|
|
1409
|
+
* The name is matched exactly, which is where this parts from {@link findRule}: a selector is
|
|
1410
|
+
* compound and a fragment of one is a useful question, and an animation name is one atom that either
|
|
1411
|
+
* is or is not the one an `animation` declaration references.
|
|
1412
|
+
*
|
|
1413
|
+
* @example
|
|
1414
|
+
* ```ts
|
|
1415
|
+
* findKeyframes('fade')?.cssRules.length
|
|
1416
|
+
* ```
|
|
1417
|
+
*/
|
|
1418
|
+
function findKeyframes(name) {
|
|
1419
|
+
for (const rule of readRules()) if (rule instanceof CSSKeyframesRule && rule.name === name) return rule;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
491
1422
|
* Reads the normalized visible text of every element a selector matches, in document order.
|
|
492
1423
|
*
|
|
493
1424
|
* @param root - The subtree to search.
|
|
@@ -518,11 +1449,42 @@ function readRows(root, selector) {
|
|
|
518
1449
|
return rows;
|
|
519
1450
|
}
|
|
520
1451
|
/**
|
|
1452
|
+
* Collects every element carrying a component class rendered outside the container it belongs to.
|
|
1453
|
+
*
|
|
1454
|
+
* @param root - The subtree to sweep.
|
|
1455
|
+
* @param child - The component class whose anatomy requires a container, such as `list-group-item`.
|
|
1456
|
+
* @param parent - The container class that child class must render inside, such as `list-group`.
|
|
1457
|
+
* @returns The markup of every element carrying `child` with no `parent` above it, in document
|
|
1458
|
+
* order; an empty list when every one of them is nested correctly.
|
|
1459
|
+
*
|
|
1460
|
+
* @remarks
|
|
1461
|
+
* A component keeps its padding, borders, and radii on the container, so a child class rendered
|
|
1462
|
+
* outside one is an unstyled box wearing a component's name, and the interface has to hand-roll the
|
|
1463
|
+
* chrome back. The search for the container starts at the element's parent, so an element can never
|
|
1464
|
+
* answer the invariant by carrying both classes itself.
|
|
1465
|
+
*
|
|
1466
|
+
* The class names are arguments, so the check belongs to no framework: name the pair your own
|
|
1467
|
+
* cascade defines.
|
|
1468
|
+
*
|
|
1469
|
+
* @example
|
|
1470
|
+
* ```ts
|
|
1471
|
+
* extractOrphans(container, 'list-group-item', 'list-group') // []
|
|
1472
|
+
* ```
|
|
1473
|
+
*/
|
|
1474
|
+
function extractOrphans(root, child, parent) {
|
|
1475
|
+
return [...root.querySelectorAll(`.${child}`)].filter((node) => (node.parentElement?.closest(`.${parent}`) ?? null) === null).map((node) => node.outerHTML);
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
521
1478
|
* Reads one resolved CSS property from a real browser element.
|
|
522
1479
|
*
|
|
523
1480
|
* @param element - The element whose resolved style to inspect.
|
|
524
|
-
* @param property - The CSS property name.
|
|
525
|
-
* @returns The browser's resolved property value
|
|
1481
|
+
* @param property - The CSS property name, registered or custom.
|
|
1482
|
+
* @returns The browser's resolved property value, trimmed; an empty string when the element resolves
|
|
1483
|
+
* none.
|
|
1484
|
+
*
|
|
1485
|
+
* @remarks
|
|
1486
|
+
* The value is trimmed, so what comes back is the value and never the whitespace around it. Internal
|
|
1487
|
+
* whitespace is kept: `--shadow: 0 0 2px` reads back with its spaces.
|
|
526
1488
|
*
|
|
527
1489
|
* @example
|
|
528
1490
|
* ```ts
|
|
@@ -530,7 +1492,214 @@ function readRows(root, selector) {
|
|
|
530
1492
|
* ```
|
|
531
1493
|
*/
|
|
532
1494
|
function style(element, property) {
|
|
533
|
-
return getComputedStyle(element).getPropertyValue(property);
|
|
1495
|
+
return getComputedStyle(element).getPropertyValue(property).trim();
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Reads one custom property from an element's resolved style.
|
|
1499
|
+
*
|
|
1500
|
+
* @param element - The element whose resolved style to inspect.
|
|
1501
|
+
* @param name - The custom property name, with or without its leading dashes.
|
|
1502
|
+
* @returns The resolved value, trimmed; an empty string when the element inherits no such property.
|
|
1503
|
+
*
|
|
1504
|
+
* @remarks
|
|
1505
|
+
* The dashes are optional because a token is spoken about both ways — `--surface` in a stylesheet
|
|
1506
|
+
* and `surface` in prose — and a reader that accepted only one spelling would turn that into a silent
|
|
1507
|
+
* empty string. An absent token reads as `''`, which is what the CSSOM returns and is
|
|
1508
|
+
* indistinguishable from a token declared empty; assert on the value you expect rather than on
|
|
1509
|
+
* presence.
|
|
1510
|
+
*
|
|
1511
|
+
* Resolution is inheritance, so a token declared on `:root` reads from any mounted descendant and
|
|
1512
|
+
* from an unmounted element reads as `''`. Use {@link rootToken} where the declaration is the
|
|
1513
|
+
* document's.
|
|
1514
|
+
*
|
|
1515
|
+
* @example
|
|
1516
|
+
* ```ts
|
|
1517
|
+
* token(panel, 'surface') // '#ffffff'
|
|
1518
|
+
* token(panel, '--surface') // '#ffffff'
|
|
1519
|
+
* ```
|
|
1520
|
+
*/
|
|
1521
|
+
function token(element, name) {
|
|
1522
|
+
return style(element, name.startsWith("--") ? name : `--${name}`);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Reads one custom property from the document element.
|
|
1526
|
+
*
|
|
1527
|
+
* @param name - The custom property name, with or without its leading dashes.
|
|
1528
|
+
* @returns The resolved value, trimmed; an empty string when the document declares no such property.
|
|
1529
|
+
*
|
|
1530
|
+
* @remarks
|
|
1531
|
+
* This is {@link token} against `document.documentElement`, which is where a theme declares its
|
|
1532
|
+
* tokens and where a `[data-theme]` switch retunes them. It exists as its own name because that
|
|
1533
|
+
* element is the one a token question is nearly always about, and naming it at every call site
|
|
1534
|
+
* buries the question.
|
|
1535
|
+
*
|
|
1536
|
+
* @example
|
|
1537
|
+
* ```ts
|
|
1538
|
+
* rootToken('surface')
|
|
1539
|
+
* ```
|
|
1540
|
+
*/
|
|
1541
|
+
function rootToken(name) {
|
|
1542
|
+
return token(document.documentElement, name);
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Reads one resolved CSS length as a number of pixels.
|
|
1546
|
+
*
|
|
1547
|
+
* @param element - The element whose resolved style to inspect.
|
|
1548
|
+
* @param property - The CSS property name, registered or custom.
|
|
1549
|
+
* @returns The leading numeric part of the resolved value, and `0` when it carries none.
|
|
1550
|
+
*
|
|
1551
|
+
* @remarks
|
|
1552
|
+
* A resolved length is text with a unit — `'12px'` — so this reads the number in front of the unit
|
|
1553
|
+
* and discards the rest. The unit is not checked: the resolved value of a length is in pixels in
|
|
1554
|
+
* every case a browser hands back, and a property that resolves to something else is the caller's
|
|
1555
|
+
* mistake rather than this reader's.
|
|
1556
|
+
*
|
|
1557
|
+
* An unparsable value reads as `0` rather than as absence, because every caller of this is measuring
|
|
1558
|
+
* and `'auto'`, `'none'`, and `''` each contribute no pixels to what a reader sees. Where the
|
|
1559
|
+
* distinction matters, read the text with {@link style} instead.
|
|
1560
|
+
*
|
|
1561
|
+
* @example
|
|
1562
|
+
* ```ts
|
|
1563
|
+
* pixels(button, 'padding-left') // 12
|
|
1564
|
+
* pixels(button, 'width') // 0 when the width resolves to `auto`
|
|
1565
|
+
* ```
|
|
1566
|
+
*/
|
|
1567
|
+
function pixels(element, property) {
|
|
1568
|
+
const measured = Number.parseFloat(style(element, property));
|
|
1569
|
+
return Number.isFinite(measured) ? measured : 0;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Sets the tester's viewport and renders the runner's pane at the size that viewport claims.
|
|
1573
|
+
*
|
|
1574
|
+
* @param width - The viewport width in CSS pixels.
|
|
1575
|
+
* @param height - The viewport height in CSS pixels.
|
|
1576
|
+
* @returns A promise resolving after the resized pane has been painted.
|
|
1577
|
+
* @throws Thrown when the tester sits inside no pane a capture can size, and when the staged pane
|
|
1578
|
+
* does not render at the viewport it was given.
|
|
1579
|
+
*
|
|
1580
|
+
* @remarks
|
|
1581
|
+
* This depends on the runner's own tester layout, and that dependency is contract rather than an
|
|
1582
|
+
* accident: `vitest@4.1.11` lays its tester out inside a smaller page, fits it by scaling the pane
|
|
1583
|
+
* the tester sits in, and clips whatever overflows that pane. Layout inside the tester is
|
|
1584
|
+
* unaffected — the tester reports the viewport it was given and every breakpoint answers to it —
|
|
1585
|
+
* but a screenshot is taken off the page the runner painted, so a frame shot through that scale is
|
|
1586
|
+
* a thumbnail of the surface and a frame shot after only unscaling it is a sliver. The tester is
|
|
1587
|
+
* therefore unscaled and lifted to the window's own origin for the shot. The `iframe[data-vitest]`
|
|
1588
|
+
* selector and the `--tester-transform`, `--tester-margin-left`, `--viewport-width`, and
|
|
1589
|
+
* `--viewport-height` custom properties are the runner's, so a Vitest release that renames any of
|
|
1590
|
+
* them reddens the size check below rather than writing a wrong frame.
|
|
1591
|
+
*
|
|
1592
|
+
* Hand the pane straight back with {@link releasePane}. A tester pinned at a viewport taller than
|
|
1593
|
+
* the window puts its lower half beyond what a pointer can reach, so an ordinary press then fails
|
|
1594
|
+
* as a control outside the viewport, in a test that took no picture at all.
|
|
1595
|
+
*
|
|
1596
|
+
* The rule is declared rather than written inline, because the runner writes its own scale onto the
|
|
1597
|
+
* pane as inline custom properties and rewrites them whenever the tester resizes. A declared rule
|
|
1598
|
+
* marked important outranks an inline value and survives every rewrite. It finds the pane by the
|
|
1599
|
+
* tester it contains as well as by {@link CAPTURE_PANE}, because a re-render between the staging and
|
|
1600
|
+
* the shot replaces the node and takes any attribute of ours with it.
|
|
1601
|
+
*
|
|
1602
|
+
* The wait is two frames rather than a delay: the first carries the resize into layout and the
|
|
1603
|
+
* second is the paint a screenshot reads.
|
|
1604
|
+
*
|
|
1605
|
+
* @example
|
|
1606
|
+
* ```ts
|
|
1607
|
+
* await stagePane(390, 844)
|
|
1608
|
+
* ```
|
|
1609
|
+
*/
|
|
1610
|
+
async function stagePane(width, height) {
|
|
1611
|
+
await page.viewport(width, height);
|
|
1612
|
+
const frame = window.frameElement;
|
|
1613
|
+
const pane = frame?.parentElement;
|
|
1614
|
+
const owner = pane?.ownerDocument;
|
|
1615
|
+
if (frame === null || pane === null || pane === void 0 || owner === void 0) throw new Error("Tester pane is unavailable for a capture");
|
|
1616
|
+
pane.setAttribute(CAPTURE_PANE, "");
|
|
1617
|
+
if (owner.querySelector(`style[data-capture-pane]`) === null) {
|
|
1618
|
+
const rule = owner.createElement("style");
|
|
1619
|
+
rule.setAttribute(CAPTURE_PANE, "");
|
|
1620
|
+
rule.textContent = [
|
|
1621
|
+
`[${CAPTURE_PANE}],:has(>iframe[data-vitest])`,
|
|
1622
|
+
"{--tester-transform:none !important;--tester-margin-left:0px !important}",
|
|
1623
|
+
"iframe[data-vitest]",
|
|
1624
|
+
"{position:fixed !important;left:0 !important;top:0 !important;right:auto !important;",
|
|
1625
|
+
"bottom:auto !important;width:var(--viewport-width) !important;",
|
|
1626
|
+
"height:var(--viewport-height) !important;z-index:2147483647 !important}"
|
|
1627
|
+
].join("");
|
|
1628
|
+
owner.head.append(rule);
|
|
1629
|
+
}
|
|
1630
|
+
await waitForFrame();
|
|
1631
|
+
await waitForFrame();
|
|
1632
|
+
const box = frame.getBoundingClientRect();
|
|
1633
|
+
if (Math.round(box.width) !== width || Math.round(box.height) !== height) throw new Error(`Tester pane rendered ${String(Math.round(box.width))}x${String(Math.round(box.height))} for a ${String(width)}x${String(height)} viewport`);
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Hands the tester pane back to the runner's own layout.
|
|
1637
|
+
*
|
|
1638
|
+
* @remarks
|
|
1639
|
+
* A staged pane is the runner's fitting scale suppressed, so a pane left staged outlives the capture
|
|
1640
|
+
* that needed it and every later act in the file happens on a surface the runner is no longer
|
|
1641
|
+
* fitting to its window. What that costs is not a wrong picture: it is a control whose page
|
|
1642
|
+
* coordinates fall outside the pane, which the runner's own layout then intercepts, so an ordinary
|
|
1643
|
+
* press fails with the voice of a control that is covered. Calling this on an unstaged pane does
|
|
1644
|
+
* nothing.
|
|
1645
|
+
*
|
|
1646
|
+
* @example
|
|
1647
|
+
* ```ts
|
|
1648
|
+
* releasePane()
|
|
1649
|
+
* ```
|
|
1650
|
+
*/
|
|
1651
|
+
function releasePane() {
|
|
1652
|
+
const pane = window.frameElement?.parentElement;
|
|
1653
|
+
pane?.removeAttribute(CAPTURE_PANE);
|
|
1654
|
+
pane?.ownerDocument.querySelector(`style[${CAPTURE_PANE}]`)?.remove();
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Shoots one frame at one viewport size and proves the file on disk holds this run's bytes.
|
|
1658
|
+
*
|
|
1659
|
+
* @param options - The path to write, the viewport to shoot at, and the element to shoot.
|
|
1660
|
+
* @returns The absolute path of the written frame, after it has been read back and matched.
|
|
1661
|
+
* @throws Thrown when the pane cannot be staged, when the provider wrote the frame somewhere else,
|
|
1662
|
+
* and when the bytes on disk are not the ones this shot produced.
|
|
1663
|
+
*
|
|
1664
|
+
* @remarks
|
|
1665
|
+
* The path a screenshot call returns is the path it meant to write, so it is not evidence a file
|
|
1666
|
+
* exists. The file is read back through the runner's built-in `readFile` command and compared with
|
|
1667
|
+
* the shot itself, which is what separates a frame this run wrote from one an earlier run left
|
|
1668
|
+
* behind. The provider resolves `options.path` against the calling test file and returns an absolute
|
|
1669
|
+
* path, so the two are compared by the segments that survive resolving `.` and `..` lexically — the
|
|
1670
|
+
* refusal is what a provider resolving that path against a different base would trip.
|
|
1671
|
+
*
|
|
1672
|
+
* Omit `options.element` to shoot the whole page. The pane is staged for the frame and released
|
|
1673
|
+
* before this returns, on the failing path as well as the passing one.
|
|
1674
|
+
*
|
|
1675
|
+
* @example
|
|
1676
|
+
* ```ts
|
|
1677
|
+
* await captureFrame({ path: '../../tmp/capture/start.png', width: 390, height: 844 })
|
|
1678
|
+
* ```
|
|
1679
|
+
*/
|
|
1680
|
+
async function captureFrame(options) {
|
|
1681
|
+
try {
|
|
1682
|
+
await stagePane(options.width, options.height);
|
|
1683
|
+
const shot = options.element === void 0 ? await page.screenshot({
|
|
1684
|
+
path: options.path,
|
|
1685
|
+
base64: true
|
|
1686
|
+
}) : await page.screenshot({
|
|
1687
|
+
element: options.element,
|
|
1688
|
+
path: options.path,
|
|
1689
|
+
base64: true
|
|
1690
|
+
});
|
|
1691
|
+
const segments = [];
|
|
1692
|
+
for (const segment of options.path.replaceAll("\\", "/").split("/")) {
|
|
1693
|
+
if (segment === "" || segment === ".") continue;
|
|
1694
|
+
if (segment === "..") segments.pop();
|
|
1695
|
+
else segments.push(segment);
|
|
1696
|
+
}
|
|
1697
|
+
if (!shot.path.replaceAll("\\", "/").endsWith(segments.join("/"))) throw new Error(`Capture frame was written to ${shot.path} where ${options.path} was asked for`);
|
|
1698
|
+
if (await commands.readFile(shot.path, "base64") !== shot.base64) throw new Error(`Capture frame at ${options.path} is not the one this run shot`);
|
|
1699
|
+
return shot.path;
|
|
1700
|
+
} finally {
|
|
1701
|
+
releasePane();
|
|
1702
|
+
}
|
|
534
1703
|
}
|
|
535
1704
|
/**
|
|
536
1705
|
* Expands a capture registry across every variant into the filenames a complete portfolio holds.
|
|
@@ -558,6 +1727,72 @@ function expandCaptures(states, variants) {
|
|
|
558
1727
|
//#endregion
|
|
559
1728
|
//#region src/browser/factories.ts
|
|
560
1729
|
/**
|
|
1730
|
+
* Creates one real pointer event, ready to dispatch.
|
|
1731
|
+
*
|
|
1732
|
+
* @param name - The event type, such as `pointerdown`.
|
|
1733
|
+
* @param options - Any `PointerEventInit` member, each one overriding the default beneath it.
|
|
1734
|
+
* @returns A real `PointerEvent` of that type.
|
|
1735
|
+
*
|
|
1736
|
+
* @remarks
|
|
1737
|
+
* The defaults are what a browser's own pointer event carries and a hand-built one does not:
|
|
1738
|
+
* `bubbles` and `cancelable` are set, so a delegated listener hears it and a handler can prevent it,
|
|
1739
|
+
* and `pointerId`, `pointerType`, and `isPrimary` describe a single primary mouse, so a component
|
|
1740
|
+
* that branches on the pointer kind takes the branch a mouse takes. Override any of them by naming
|
|
1741
|
+
* it; a touch is `{ pointerType: 'touch' }` and nothing else has to be restated.
|
|
1742
|
+
*
|
|
1743
|
+
* The event is real rather than a shaped object, so `instanceof PointerEvent` holds and the
|
|
1744
|
+
* coordinate and modifier members a handler reads are the ones the platform defines.
|
|
1745
|
+
*
|
|
1746
|
+
* @example
|
|
1747
|
+
* ```ts
|
|
1748
|
+
* element.dispatchEvent(createPointerEvent('pointerdown', { clientX: 10, clientY: 20 }))
|
|
1749
|
+
* ```
|
|
1750
|
+
*/
|
|
1751
|
+
function createPointerEvent(name, options) {
|
|
1752
|
+
return new PointerEvent(name, {
|
|
1753
|
+
bubbles: true,
|
|
1754
|
+
cancelable: true,
|
|
1755
|
+
pointerId: 1,
|
|
1756
|
+
pointerType: "mouse",
|
|
1757
|
+
isPrimary: true,
|
|
1758
|
+
...options
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Creates one real drag event carrying a live data transfer, ready to dispatch.
|
|
1763
|
+
*
|
|
1764
|
+
* @param name - The event type, such as `dragstart`.
|
|
1765
|
+
* @param options - Any `DragEventInit` member, each one overriding the default beneath it.
|
|
1766
|
+
* @returns A real `DragEvent` of that type.
|
|
1767
|
+
*
|
|
1768
|
+
* @remarks
|
|
1769
|
+
* A drag event with no `dataTransfer` is the shape that makes a drop handler fail in a test and work
|
|
1770
|
+
* in a browser, so one is allocated. Pass your own to seed it: a `dataTransfer` given in `options`
|
|
1771
|
+
* replaces the allocated one, which is how a drop is driven with the payload the drag was supposed
|
|
1772
|
+
* to carry.
|
|
1773
|
+
*
|
|
1774
|
+
* The platform declares the `dataTransfer` member on the constructed event as nullable, so calling
|
|
1775
|
+
* code still narrows it even though this always supplies one.
|
|
1776
|
+
*
|
|
1777
|
+
* `bubbles` and `cancelable` are set, because a drop handler that never prevents the default event
|
|
1778
|
+
* is a drop the browser handles itself.
|
|
1779
|
+
*
|
|
1780
|
+
* @example
|
|
1781
|
+
* ```ts
|
|
1782
|
+
* const started = createDragEvent('dragstart')
|
|
1783
|
+
* started.dataTransfer?.setData('text/plain', 'row-3')
|
|
1784
|
+
* element.dispatchEvent(started)
|
|
1785
|
+
* ```
|
|
1786
|
+
*/
|
|
1787
|
+
function createDragEvent(name, options) {
|
|
1788
|
+
return new DragEvent(name, {
|
|
1789
|
+
bubbles: true,
|
|
1790
|
+
cancelable: true,
|
|
1791
|
+
dataTransfer: new DataTransfer(),
|
|
1792
|
+
...options
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
561
1796
|
* Creates the capture portfolio one run places its screenshots through.
|
|
562
1797
|
*
|
|
563
1798
|
* @param options - The state registry, the variant matrix, the variant this run renders, the
|
|
@@ -571,6 +1806,10 @@ function expandCaptures(states, variants) {
|
|
|
571
1806
|
* none of it. The portfolio refuses an unregistered variant at creation. An enabled run refuses an
|
|
572
1807
|
* unregistered state name and a second placement of one state.
|
|
573
1808
|
*
|
|
1809
|
+
* An enabled `place` writes through `captureFrame`, so a placed state carries that helper's staged
|
|
1810
|
+
* pane and its byte readback: a path is recorded only after the file on disk has been proved to hold
|
|
1811
|
+
* this run's own frame.
|
|
1812
|
+
*
|
|
574
1813
|
* @example
|
|
575
1814
|
* ```ts
|
|
576
1815
|
* const portfolio = createPortfolio({
|
|
@@ -599,21 +1838,136 @@ function createPortfolio(options) {
|
|
|
599
1838
|
get paths() {
|
|
600
1839
|
return [...paths];
|
|
601
1840
|
},
|
|
602
|
-
async place(state) {
|
|
1841
|
+
async place(state, element) {
|
|
603
1842
|
if (!enabled) return void 0;
|
|
604
1843
|
if (!registry.includes(state)) throw new Error(`Capture state "${state}" is not registered`);
|
|
605
1844
|
if (placed.includes(state)) throw new Error(`Capture state "${state}" is already placed`);
|
|
606
1845
|
const file = `${state}--${options.variant}.png`;
|
|
607
1846
|
selected.apply?.();
|
|
608
|
-
|
|
609
|
-
|
|
1847
|
+
const written = await captureFrame({
|
|
1848
|
+
path: `${options.directory}/${file}`,
|
|
1849
|
+
width: selected.width,
|
|
1850
|
+
height: selected.height,
|
|
1851
|
+
element
|
|
1852
|
+
});
|
|
610
1853
|
placed.push(state);
|
|
611
1854
|
paths.push(written);
|
|
612
1855
|
return written;
|
|
613
1856
|
}
|
|
614
1857
|
};
|
|
615
1858
|
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Creates one console channel that records every call it receives and hands that call on unchanged.
|
|
1861
|
+
*
|
|
1862
|
+
* @param name - The channel's name, which prefixes each line it records.
|
|
1863
|
+
* @param output - The list each call is recorded into, appended to in place.
|
|
1864
|
+
* @param forward - The channel every call is passed on to after it is recorded.
|
|
1865
|
+
* @returns A channel carrying the console's own call signature.
|
|
1866
|
+
*
|
|
1867
|
+
* @remarks
|
|
1868
|
+
* One call becomes one line. Every argument of that call is put through `String` and joined with a
|
|
1869
|
+
* space, so a call carrying several values reads as the one line the page printed rather than as
|
|
1870
|
+
* several entries.
|
|
1871
|
+
*
|
|
1872
|
+
* Nothing is swallowed. The record happens first and `forward` receives the arguments it would have
|
|
1873
|
+
* received, so a page recorded through this prints exactly what it printed without it. The list
|
|
1874
|
+
* belongs to the caller, so a channel writes into whatever it was handed and holds no state of its
|
|
1875
|
+
* own. {@link createJournal} builds one channel per console method over one list.
|
|
1876
|
+
*
|
|
1877
|
+
* @example
|
|
1878
|
+
* ```ts
|
|
1879
|
+
* const output: string[] = []
|
|
1880
|
+
* console.log = createChannel('log', output, console.log)
|
|
1881
|
+
* ```
|
|
1882
|
+
*/
|
|
1883
|
+
function createChannel(name, output, forward) {
|
|
1884
|
+
return (...data) => {
|
|
1885
|
+
output.push(`${name}: ${data.map((value) => String(value)).join(" ")}`);
|
|
1886
|
+
forward(...data);
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Creates the journal one scenario records its steps and the page's own output into.
|
|
1891
|
+
*
|
|
1892
|
+
* @returns A journal that records nothing until it is started.
|
|
1893
|
+
*
|
|
1894
|
+
* @remarks
|
|
1895
|
+
* The console is recorded rather than replaced: every intercepted call is forwarded to the channel
|
|
1896
|
+
* that was there when the journal started, so a run under a journal prints exactly what it printed
|
|
1897
|
+
* without one. `stop` puts those same function references back by identity.
|
|
1898
|
+
*
|
|
1899
|
+
* Uncaught errors and unhandled rejections are recorded too, through listeners the journal drops
|
|
1900
|
+
* when it stops. `steps` and `output` hand out snapshots, so a list read mid-scenario stays what it
|
|
1901
|
+
* was. Each journal owns its own recording, so a file that needs one per scenario creates one per
|
|
1902
|
+
* scenario.
|
|
1903
|
+
*
|
|
1904
|
+
* @example
|
|
1905
|
+
* ```ts
|
|
1906
|
+
* const journal = createJournal()
|
|
1907
|
+
* journal.start()
|
|
1908
|
+
* journal.record('click', 'Evaluate', 'alerts=0')
|
|
1909
|
+
* journal.stop()
|
|
1910
|
+
* journal.steps // [{ action: 'click', trigger: 'Evaluate', result: 'alerts=0' }]
|
|
1911
|
+
* ```
|
|
1912
|
+
*/
|
|
1913
|
+
function createJournal() {
|
|
1914
|
+
const steps = [];
|
|
1915
|
+
const output = [];
|
|
1916
|
+
let intercepted;
|
|
1917
|
+
let listeners;
|
|
1918
|
+
return {
|
|
1919
|
+
get steps() {
|
|
1920
|
+
return [...steps];
|
|
1921
|
+
},
|
|
1922
|
+
get output() {
|
|
1923
|
+
return [...output];
|
|
1924
|
+
},
|
|
1925
|
+
start() {
|
|
1926
|
+
steps.length = 0;
|
|
1927
|
+
output.length = 0;
|
|
1928
|
+
if (intercepted !== void 0) return;
|
|
1929
|
+
const forwarded = {
|
|
1930
|
+
debug: console.debug,
|
|
1931
|
+
error: console.error,
|
|
1932
|
+
info: console.info,
|
|
1933
|
+
log: console.log,
|
|
1934
|
+
warn: console.warn
|
|
1935
|
+
};
|
|
1936
|
+
intercepted = forwarded;
|
|
1937
|
+
for (const channel of [
|
|
1938
|
+
"debug",
|
|
1939
|
+
"error",
|
|
1940
|
+
"info",
|
|
1941
|
+
"log",
|
|
1942
|
+
"warn"
|
|
1943
|
+
]) console[channel] = createChannel(channel, output, forwarded[channel]);
|
|
1944
|
+
const dropped = new AbortController();
|
|
1945
|
+
listeners = dropped;
|
|
1946
|
+
window.addEventListener("error", (event) => {
|
|
1947
|
+
output.push(`error: ${event.message}`);
|
|
1948
|
+
}, { signal: dropped.signal });
|
|
1949
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
1950
|
+
output.push(`rejection: ${String(event.reason)}`);
|
|
1951
|
+
}, { signal: dropped.signal });
|
|
1952
|
+
},
|
|
1953
|
+
stop() {
|
|
1954
|
+
if (intercepted === void 0) return;
|
|
1955
|
+
Object.assign(console, intercepted);
|
|
1956
|
+
intercepted = void 0;
|
|
1957
|
+
listeners?.abort();
|
|
1958
|
+
listeners = void 0;
|
|
1959
|
+
},
|
|
1960
|
+
record(action, trigger, result) {
|
|
1961
|
+
if (intercepted === void 0) return;
|
|
1962
|
+
steps.push(Object.freeze({
|
|
1963
|
+
action,
|
|
1964
|
+
trigger,
|
|
1965
|
+
result
|
|
1966
|
+
}));
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
616
1970
|
//#endregion
|
|
617
|
-
export { ACCESSIBLE_ROLES, clickAccessible, clickAccessibleWithin, clickDisclosure, contrast, createPortfolio, expandCaptures, fillAccessible, isOutsideViewport, pressKeys, readCascade, readFocus, readPage, readPerception, readRows, readValue, render, resolveAccessible, resolveRendered, style, traverseAccessible, typeAccessible, waitForFrame };
|
|
1971
|
+
export { ACCESSIBLE_ROLES, CANVAS_COLOR, CAPTURE_PANE, CONTENT_ROLES, FIELD_ROLES, FOCUSABLE_SELECTOR, HEADER_ROLES, IMPLICIT_ROLES, blendColor, build, captureFrame, clearStorage, clickAccessible, clickAccessibleWithin, clickDisclosure, colorEqual, commitInput, contrast, createChannel, createDragEvent, createJournal, createPointerEvent, createPortfolio, describeFocus, describeTree, expandCaptures, extractOrphans, fillAccessible, findKeyframes, findRule, isOutsideViewport, isReachable, isRendered, measureContrast, measureLuminance, mount, parseColor, pixels, pressKeys, readBackdrop, readCascade, readFocus, readLayers, readName, readPage, readPerception, readRing, readRole, readRows, readRules, readStates, readText, readValue, releasePane, removeDatabase, render, resolveAccessible, resolveRendered, rgba, rootToken, stagePane, style, token, traverseAccessible, typeAccessible, typeInput, waitForFrame };
|
|
618
1972
|
|
|
619
1973
|
//# sourceMappingURL=index.js.map
|