pour-engine 1.28.0 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config/project.config.js
CHANGED
package/engine/index.js
CHANGED
|
@@ -185,7 +185,9 @@ export async function run(context = document, options = {}, onProgress) {
|
|
|
185
185
|
|
|
186
186
|
let outcomes;
|
|
187
187
|
if (rule.evaluateAll) {
|
|
188
|
-
|
|
188
|
+
// Awaiting a plain array is a no-op, so sync evaluateAll rules are
|
|
189
|
+
// untouched — async ones (the deferred-reference probe) resolve here.
|
|
190
|
+
outcomes = await rule.evaluateAll(elements, ruleHelpers);
|
|
189
191
|
} else {
|
|
190
192
|
// Evaluate sequentially with periodic yields so ONE heavy rule on a
|
|
191
193
|
// huge element set can't freeze the page for its whole duration —
|
|
@@ -6,6 +6,105 @@ const REF_ATTRIBUTES = [
|
|
|
6
6
|
'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
|
|
7
7
|
];
|
|
8
8
|
|
|
9
|
+
// Deferred-tooltip pattern: libraries (Deque's own docs component included)
|
|
10
|
+
// write aria-describedby="tooltip7" at rest and only CREATE #tooltip7 when
|
|
11
|
+
// the trigger is hovered or focused — which is exactly when a screen reader
|
|
12
|
+
// announces the description, so the pattern works. Only DESCRIPTIVE refs
|
|
13
|
+
// get this benefit of the doubt: a NAME that exists only on hover is broken
|
|
14
|
+
// for rotor navigation even when the deferral works, so aria-labelledby
|
|
15
|
+
// (and the rest) keep failing at rest.
|
|
16
|
+
const PROBEABLE = new Set(['aria-describedby', 'aria-details']);
|
|
17
|
+
// A handful of probes covers any real page; a template bug that dangles
|
|
18
|
+
// fifty refs is broken enough that the un-probed remainder failing as
|
|
19
|
+
// before is the right answer.
|
|
20
|
+
const PROBE_BUDGET = 8;
|
|
21
|
+
const PROBE_WAIT_MS = 250;
|
|
22
|
+
|
|
23
|
+
/** Fire the events a tooltip library listens for, wait for it to react,
|
|
24
|
+
* check whether the ids materialised, then put the page back. */
|
|
25
|
+
async function probeDeferred(element, ids) {
|
|
26
|
+
const over = ['pointerover', 'mouseover', 'mouseenter'];
|
|
27
|
+
const out = ['pointerout', 'mouseout', 'mouseleave'];
|
|
28
|
+
for (const type of over) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' }));
|
|
29
|
+
element.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
|
30
|
+
try { element.focus?.({ preventScroll: true }); } catch { /* non-focusable */ }
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, PROBE_WAIT_MS));
|
|
32
|
+
const root = element.getRootNode();
|
|
33
|
+
const appeared = ids.every((id) => root.getElementById?.(id));
|
|
34
|
+
// Restore: the audit must leave the page as it found it, and later rules
|
|
35
|
+
// must not meet a tooltip that only exists because we hovered.
|
|
36
|
+
for (const type of out) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseleave' }));
|
|
37
|
+
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
|
38
|
+
try { element.blur?.(); } catch { /* ignore */ }
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
40
|
+
return appeared;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function inspect(element) {
|
|
44
|
+
// ARIA id references cannot cross shadow boundaries: they resolve only
|
|
45
|
+
// within the element's own root (document or shadow tree).
|
|
46
|
+
const root = element.getRootNode();
|
|
47
|
+
// Collapsed-disclosure pattern: a trigger with aria-expanded="false"
|
|
48
|
+
// whose aria-controls panel doesn't exist YET (rendered on expand) is
|
|
49
|
+
// ubiquitous and harmless while collapsed — name computation doesn't
|
|
50
|
+
// touch aria-controls, so nothing resolves wrongly today.
|
|
51
|
+
const collapsed = element.getAttribute('aria-expanded') === 'false';
|
|
52
|
+
const missing = [];
|
|
53
|
+
const ambiguous = [];
|
|
54
|
+
for (const attr of REF_ATTRIBUTES) {
|
|
55
|
+
for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
|
|
56
|
+
if (!root.getElementById?.(id)) {
|
|
57
|
+
if (attr === 'aria-controls' && collapsed) continue;
|
|
58
|
+
missing.push({ attr, id });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const count = root.querySelectorAll(`[id="${CSS.escape(id)}"]`).length;
|
|
62
|
+
if (count > 1) {
|
|
63
|
+
ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { missing, ambiguous };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function outcome({ missing, ambiguous }, probed, element, accessibleName) {
|
|
71
|
+
if (missing.length) {
|
|
72
|
+
// A dangling ref is skipped by the accname computation, so the HARM
|
|
73
|
+
// depends on what remains. A labelledby that dangles while the element
|
|
74
|
+
// still names itself (contents, aria-label) is inert clutter — real,
|
|
75
|
+
// fragile, validator-flagged, but announced correctly today. The same
|
|
76
|
+
// dangle on an otherwise nameless element is the 4.1.2 failure proper:
|
|
77
|
+
// names are consumed at REST (element lists, rotor), so the finding
|
|
78
|
+
// says which of the two this is instead of one blanket accusation.
|
|
79
|
+
const restingName = (accessibleName?.(element) ?? '').trim();
|
|
80
|
+
const detail = missing
|
|
81
|
+
.map(({ attr, id }) => {
|
|
82
|
+
if (attr === 'aria-labelledby') {
|
|
83
|
+
return restingName
|
|
84
|
+
? `aria-labelledby="${id}" points to nothing — the reference is ignored and this element's name ("${restingName.slice(0, 40)}") currently comes from elsewhere; announced correctly today, but the dead reference is fragile`
|
|
85
|
+
: `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`;
|
|
86
|
+
}
|
|
87
|
+
return `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
|
|
88
|
+
})
|
|
89
|
+
.join('; ');
|
|
90
|
+
return {
|
|
91
|
+
status: 'fail',
|
|
92
|
+
message: `Broken ARIA references: ${detail}.${probed ? ' Probed with focus and hover in case the target is created on demand — it never appeared.' : ''}`,
|
|
93
|
+
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (ambiguous.length) {
|
|
97
|
+
// Duplicate ids resolve deterministically to the first copy — often
|
|
98
|
+
// the right one (desktop/mobile double-renders). Real, but a human
|
|
99
|
+
// must judge whether the first copy is the wrong copy.
|
|
100
|
+
return {
|
|
101
|
+
status: 'incomplete',
|
|
102
|
+
message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { status: 'pass' };
|
|
106
|
+
}
|
|
107
|
+
|
|
9
108
|
export default {
|
|
10
109
|
id: 'aria-valid-refs',
|
|
11
110
|
// Moderate, not critical: with 4.1.1 Parsing retired in WCAG 2.2 these
|
|
@@ -16,46 +115,26 @@ export default {
|
|
|
16
115
|
help: 'ARIA id references must point to elements that exist',
|
|
17
116
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
18
117
|
selector: REF_ATTRIBUTES.map((attr) => `[${attr}]`).join(', '),
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!root.getElementById?.(id)) {
|
|
33
|
-
if (attr === 'aria-controls' && collapsed) continue;
|
|
34
|
-
missing.push(`${attr}="${id}" points to nothing — assistive technology silently ignores it`);
|
|
118
|
+
async evaluateAll(elements, { accessibleName } = {}) {
|
|
119
|
+
let budget = PROBE_BUDGET;
|
|
120
|
+
const outcomes = new Array(elements.length);
|
|
121
|
+
for (let i = 0; i < elements.length; i++) {
|
|
122
|
+
const element = elements[i];
|
|
123
|
+
const found = inspect(element);
|
|
124
|
+
const deferredOnly = found.missing.length > 0
|
|
125
|
+
&& found.missing.every(({ attr }) => PROBEABLE.has(attr));
|
|
126
|
+
if (deferredOnly && budget > 0) {
|
|
127
|
+
budget--;
|
|
128
|
+
if (await probeDeferred(element, found.missing.map(({ id }) => id))) {
|
|
129
|
+
// The description materialises exactly when it would be announced.
|
|
130
|
+
outcomes[i] = { status: 'pass' };
|
|
35
131
|
continue;
|
|
36
132
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
ambiguous.push(`${attr}="${id}" — ${count} elements share this id; the reference binds to the FIRST one in the DOM`);
|
|
40
|
-
}
|
|
133
|
+
outcomes[i] = outcome(found, true, element, accessibleName);
|
|
134
|
+
continue;
|
|
41
135
|
}
|
|
136
|
+
outcomes[i] = outcome(found, false, element, accessibleName);
|
|
42
137
|
}
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
status: 'fail',
|
|
46
|
-
message: `Broken ARIA references: ${missing.join('; ')}.`,
|
|
47
|
-
fix: 'Correct or remove the reference, or give the target element that id.',
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
if (ambiguous.length) {
|
|
51
|
-
// Duplicate ids resolve deterministically to the first copy — often
|
|
52
|
-
// the right one (desktop/mobile double-renders). Real, but a human
|
|
53
|
-
// must judge whether the first copy is the wrong copy.
|
|
54
|
-
return {
|
|
55
|
-
status: 'incomplete',
|
|
56
|
-
message: `Ambiguous ARIA references: ${ambiguous.join('; ')}. If the first copy in the DOM is the intended target, this works today — but it's fragile.`,
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
return { status: 'pass' };
|
|
138
|
+
return outcomes;
|
|
60
139
|
},
|
|
61
140
|
};
|
package/package.json
CHANGED