pour-engine 1.27.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 —
|
package/engine/lib/contrast.js
CHANGED
|
@@ -130,6 +130,7 @@ let opacityAnimatorsCache = null;
|
|
|
130
130
|
let mediaRectsCache = null;
|
|
131
131
|
let panelRectsCache = null;
|
|
132
132
|
let pseudoCache = new WeakMap();
|
|
133
|
+
let zeroClipCache = new WeakMap();
|
|
133
134
|
const HAS_IMAGE = Symbol('background-image in chain');
|
|
134
135
|
|
|
135
136
|
export function resetAuditCaches() {
|
|
@@ -139,6 +140,49 @@ export function resetAuditCaches() {
|
|
|
139
140
|
mediaRectsCache = null;
|
|
140
141
|
panelRectsCache = null;
|
|
141
142
|
pseudoCache = new WeakMap();
|
|
143
|
+
zeroClipCache = new WeakMap();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* True when the element sits inside a subtree erased by a ZERO-AREA clip —
|
|
148
|
+
* the sr-only recipe (clip: rect(0,0,0,0) / clip-path: inset(50%) on an
|
|
149
|
+
* absolutely positioned wrapper) applied to an ANCESTOR, which is where it
|
|
150
|
+
* usually lives: a chart's accessible data table hides the table, and the
|
|
151
|
+
* judged element is a cell inside it. Only provably zero-area patterns
|
|
152
|
+
* count: a clip rect with no width or height (any `auto` component keeps
|
|
153
|
+
* the element judged), or a percentage inset consuming a full axis.
|
|
154
|
+
* Partial clips never hide. Verdicts cache per ancestor, so a page pays
|
|
155
|
+
* per container, not per descendant.
|
|
156
|
+
*/
|
|
157
|
+
export function inZeroClipSubtree(element) {
|
|
158
|
+
for (let a = element; a; a = a.parentElement) {
|
|
159
|
+
let hidden = zeroClipCache.get(a);
|
|
160
|
+
if (hidden === undefined) {
|
|
161
|
+
const s = getComputedStyle(a);
|
|
162
|
+
hidden = false;
|
|
163
|
+
if (s.clip !== 'auto' && s.position !== 'static') {
|
|
164
|
+
const m = /rect\(([^)]+)\)/.exec(s.clip);
|
|
165
|
+
if (m) {
|
|
166
|
+
const parts = m[1].split(',').map((v) => v.trim());
|
|
167
|
+
if (!parts.includes('auto') && parts.length === 4) {
|
|
168
|
+
const [t, r, b, l] = parts.map(parseFloat);
|
|
169
|
+
if (r - l <= 0 || b - t <= 0) hidden = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!hidden && s.clipPath && s.clipPath !== 'none') {
|
|
174
|
+
const m = /inset\(([^)]+)\)/.exec(s.clipPath);
|
|
175
|
+
if (m && !m[1].includes('px')) {
|
|
176
|
+
const parts = m[1].trim().split(/\s+/).map(parseFloat);
|
|
177
|
+
const [t, r = t, b = t, l = r] = parts;
|
|
178
|
+
if (t + b >= 100 || l + r >= 100) hidden = true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
zeroClipCache.set(a, hidden);
|
|
182
|
+
}
|
|
183
|
+
if (hidden) return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
142
186
|
}
|
|
143
187
|
|
|
144
188
|
/**
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
|
|
4
4
|
backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
|
|
5
5
|
gradientLuminanceRange, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
|
|
6
|
-
opacityAnimating, restingOpacity, mediaRects,
|
|
6
|
+
opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
9
|
showRatio,
|
|
@@ -138,13 +138,15 @@ function textVisuallyHidden(element, style, foreground) {
|
|
|
138
138
|
const indent = parseFloat(style.textIndent) || 0;
|
|
139
139
|
const clipped = /hidden|clip/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)
|
|
140
140
|
|| style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute');
|
|
141
|
-
// Nothing hints at hiding:
|
|
142
|
-
//
|
|
143
|
-
|
|
141
|
+
// Nothing on the element itself hints at hiding: the recipe may still sit
|
|
142
|
+
// on a WRAPPER (a chart's sr-only data table hides the table; the judged
|
|
143
|
+
// cell inside computes clip: auto) — the ancestor walk is cached per
|
|
144
|
+
// container, so this stays affordable on every text element.
|
|
145
|
+
if (!clipped && Math.abs(indent) <= 1000) return inZeroClipSubtree(element);
|
|
144
146
|
const rect = element.getBoundingClientRect();
|
|
145
147
|
if ((rect.width <= 1 || rect.height <= 1) && clipped) return true;
|
|
146
148
|
if (Math.abs(indent) > rect.width && (clipped || Math.abs(indent) > 1000)) return true;
|
|
147
|
-
return
|
|
149
|
+
return inZeroClipSubtree(element);
|
|
148
150
|
}
|
|
149
151
|
|
|
150
152
|
// Turn a luminance-range bracket into a rule outcome (or null = can't tell).
|
|
@@ -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