pour-engine 1.42.2 → 1.42.3
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
|
@@ -150,6 +150,13 @@ function visibleContentText(element, includeHidden, inLabelledBy, visited) {
|
|
|
150
150
|
* the generated content decorative and contributes nothing, which is how
|
|
151
151
|
* the same syntax silences icon glyphs. */
|
|
152
152
|
function generatedContent(element, pseudo, includeHidden) {
|
|
153
|
+
// SVG elements generate no ::before/::after box: the computed style still
|
|
154
|
+
// reports the content, but nothing is painted and Chromium's accessible
|
|
155
|
+
// name leaves it out (measured 2026-09-13: an icon font's ::before "h" on
|
|
156
|
+
// the <svg> inside an icon-only button paints 0 pixels and Chromium names
|
|
157
|
+
// the button ""; the same rule on a <span> paints and names it "h"). Naming
|
|
158
|
+
// the button "h" from it hid a nameless close button on channel4.com.
|
|
159
|
+
if (element.namespaceURI === 'http://www.w3.org/2000/svg') return '';
|
|
153
160
|
const style = getComputedStyle(element, pseudo);
|
|
154
161
|
// Generated nodes follow the same hidden-content exclusion as ordinary
|
|
155
162
|
// descendants. A hover label can exist in computed content while its
|
package/engine/lib/contrast.js
CHANGED
|
@@ -318,7 +318,7 @@ const transparentImages = new Set();
|
|
|
318
318
|
/** True when every layer of this computed background-image is a url() known
|
|
319
319
|
* to paint nothing — so the layer cannot change what is behind it. Any
|
|
320
320
|
* gradient, or any unsampled image, makes that unknowable. */
|
|
321
|
-
function paintsNothing(backgroundImage) {
|
|
321
|
+
export function paintsNothing(backgroundImage) {
|
|
322
322
|
if (backgroundImage.includes('gradient(')) return false;
|
|
323
323
|
const urls = [...backgroundImage.matchAll(/url\(["']?(.*?)["']?\)/g)].map((m) => m[1]);
|
|
324
324
|
return urls.length > 0 && urls.every((url) => transparentImages.has(url));
|
|
@@ -585,7 +585,7 @@ export function backgroundObscured(element) {
|
|
|
585
585
|
* hit-testing. A Range around the first non-blank text node beats the
|
|
586
586
|
* element's own rect centre: wide containers with left-aligned text would
|
|
587
587
|
* hit-test empty space. Falls back to the element rect. */
|
|
588
|
-
function textSamplePoint(element) {
|
|
588
|
+
export function textSamplePoint(element) {
|
|
589
589
|
for (const node of element.childNodes) {
|
|
590
590
|
if (node.nodeType !== 3 || !node.textContent.trim()) continue;
|
|
591
591
|
const range = element.ownerDocument.createRange();
|
package/engine/lib/dom.js
CHANGED
|
@@ -31,6 +31,47 @@ export function flatTreeParent(node) {
|
|
|
31
31
|
return node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Every element beneath `element` in the FLAT tree: light descendants,
|
|
35
|
+
* open shadow trees, and what slots pull in. Closed shadow roots stay
|
|
36
|
+
* unreachable, as everywhere in the engine. */
|
|
37
|
+
export function flatDescendants(element) {
|
|
38
|
+
const found = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const visit = (scope) => {
|
|
41
|
+
for (const el of scope.querySelectorAll('*')) {
|
|
42
|
+
if (seen.has(el)) continue;
|
|
43
|
+
seen.add(el);
|
|
44
|
+
found.push(el);
|
|
45
|
+
if (el.shadowRoot) visit(el.shadowRoot);
|
|
46
|
+
if (el.tagName === 'SLOT') {
|
|
47
|
+
for (const assigned of el.assignedElements({ flatten: true })) {
|
|
48
|
+
if (!seen.has(assigned)) { seen.add(assigned); found.push(assigned); }
|
|
49
|
+
visit(assigned);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
if (element.shadowRoot) visit(element.shadowRoot);
|
|
55
|
+
visit(element);
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Focusable but left out of sequential (Tab) navigation: a negative
|
|
60
|
+
* tabindex on the element itself, or on a shadow host between it and
|
|
61
|
+
* `within`. HTML leaves a focus navigation scope owner "whose tabindex value
|
|
62
|
+
* is a negative integer" out of the tabindex-ordered focus navigation scope,
|
|
63
|
+
* and its scope's contents go with it (measured in Chromium 2026-09-13: Tab
|
|
64
|
+
* skips the <button> in an mwc-button host with tabindex="-1", while a click
|
|
65
|
+
* or focus() still lands on it). */
|
|
66
|
+
export function outOfSequentialFocus(element, within = null) {
|
|
67
|
+
const negative = (el) => el.hasAttribute('tabindex') && el.tabIndex < 0;
|
|
68
|
+
if (negative(element)) return true;
|
|
69
|
+
for (let node = flatTreeParent(element); node && node !== within; node = flatTreeParent(node)) {
|
|
70
|
+
if (node.shadowRoot && negative(node)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
/**
|
|
35
76
|
* Rendered on screen — ignores aria-hidden. For purely visual rules
|
|
36
77
|
* (contrast, target size): sighted users see aria-hidden content too.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.4.3 Contrast (Minimum) (Level AA)
|
|
2
2
|
import {
|
|
3
|
-
parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
|
|
3
|
+
parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured, paintsNothing, textSamplePoint,
|
|
4
4
|
backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
|
|
5
5
|
sampledGradientRange, gradientStops, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
|
|
6
6
|
opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
|
|
@@ -263,6 +263,75 @@ function unaccountedScrim(element, imageCarrier) {
|
|
|
263
263
|
});
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The nearest layer in the browser's hit-test stack that is NOT an ancestor
|
|
268
|
+
* of the text and paints between the glyphs and the ancestor carrying the
|
|
269
|
+
* walked background-image. The ancestor walk (backgroundImageSource) is
|
|
270
|
+
* structurally blind to it: react.dev lays a light card gradient
|
|
271
|
+
* (position: absolute; inset: 0) inside a section whose own background is a
|
|
272
|
+
* dark conic gradient, and the walk sampled that dark section behind dark
|
|
273
|
+
* text (1.18:1) while the pixels under the glyphs read 13.8:1 (2026-09-13
|
|
274
|
+
* verdict crawl). Returns { image, css, overlays } when such a layer paints
|
|
275
|
+
* an image or gradient (the real backdrop to sample, with the translucent
|
|
276
|
+
* ancestor colours passed on the way as overlays), { covered } when it is an
|
|
277
|
+
* opaque colour or a replaced element (the walked image never shows behind
|
|
278
|
+
* the glyphs; the colour paths below resolve it), and null when the stack
|
|
279
|
+
* reaches the carrier first, cannot be read, or the text is off screen.
|
|
280
|
+
*/
|
|
281
|
+
function coveringLayer(element, imageCarrier) {
|
|
282
|
+
const doc = element.ownerDocument;
|
|
283
|
+
const win = doc.defaultView;
|
|
284
|
+
// undefined: the hit-test cannot answer (off screen, retargeted, unread);
|
|
285
|
+
// null: it answered, and nothing covers the walked image.
|
|
286
|
+
if (typeof doc.elementsFromPoint !== 'function') return undefined;
|
|
287
|
+
const point = textSamplePoint(element);
|
|
288
|
+
if (!point || point.x < 0 || point.y < 0 || point.x >= win.innerWidth || point.y >= win.innerHeight) return undefined;
|
|
289
|
+
const stack = doc.elementsFromPoint(point.x, point.y);
|
|
290
|
+
const start = stack.indexOf(element);
|
|
291
|
+
if (start === -1) return undefined;
|
|
292
|
+
const overlays = [];
|
|
293
|
+
for (const layer of stack.slice(start + 1)) {
|
|
294
|
+
if (layer === imageCarrier) return null;
|
|
295
|
+
if (layer.contains(element)) {
|
|
296
|
+
const own = parseColor(getComputedStyle(layer).backgroundColor);
|
|
297
|
+
if (own && own.a > 0 && own.a < 1) overlays.push(own);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
// Paint inside a shadow tree is retargeted to its host: unknowable here.
|
|
301
|
+
if (layer.shadowRoot) return undefined;
|
|
302
|
+
if (/^(img|video|canvas|picture)$/i.test(layer.tagName)) return { covered: layer };
|
|
303
|
+
const style = getComputedStyle(layer);
|
|
304
|
+
if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return { image: layer, css: style.backgroundImage, overlays };
|
|
305
|
+
const color = parseColor(style.backgroundColor);
|
|
306
|
+
if (color && color.a >= 1) return { covered: layer };
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Scroll-independent stand-in for coveringLayer when the hit-test is blind:
|
|
313
|
+
* is there a positioned element inside the image carrier, not an ancestor of
|
|
314
|
+
* the text, painting an image or an opaque colour over the text's sample
|
|
315
|
+
* point? It paints above the carrier's background, and whether it lies
|
|
316
|
+
* behind the glyphs or over them is paint order this engine does not model,
|
|
317
|
+
* so the caller asks rather than asserts the walked image.
|
|
318
|
+
*/
|
|
319
|
+
function positionedPaintInside(carrier, element) {
|
|
320
|
+
const point = textSamplePoint(element);
|
|
321
|
+
if (!point) return false;
|
|
322
|
+
for (const node of carrier.querySelectorAll('*')) {
|
|
323
|
+
if (node.contains(element) || element.contains(node)) continue;
|
|
324
|
+
const style = getComputedStyle(node);
|
|
325
|
+
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
326
|
+
const paints = (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage))
|
|
327
|
+
|| (parseColor(style.backgroundColor)?.a ?? 0) >= 1;
|
|
328
|
+
if (!paints || node.checkVisibility?.({ checkOpacity: true, checkVisibilityCSS: true }) === false) continue;
|
|
329
|
+
const box = node.getBoundingClientRect();
|
|
330
|
+
if (point.x >= box.left && point.x < box.right && point.y >= box.top && point.y < box.bottom) return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
266
335
|
/** An image layer with no opaque pixels: it covers the background without
|
|
267
336
|
* changing it, so the callers step over it rather than reporting on it. */
|
|
268
337
|
const PAINTS_NOTHING = Symbol('fully transparent image layer');
|
|
@@ -753,7 +822,22 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
753
822
|
// A background image on an ancestor: sample its pixels and bracket —
|
|
754
823
|
// but only when it's actually painted under this text. Icon-in-padding
|
|
755
824
|
// images (external-link arrows etc.) don't affect the text's contrast.
|
|
756
|
-
|
|
825
|
+
let imageSource = backgroundImageSource(element);
|
|
826
|
+
// Where the browser's hit-test can answer, it outranks the ancestor walk:
|
|
827
|
+
// a non-ancestor layer painted between the text and the walked image is
|
|
828
|
+
// the backdrop instead (see coveringLayer). The element's own image is
|
|
829
|
+
// the walk's by construction and is left alone.
|
|
830
|
+
if (imageSource && imageSource.element !== element) {
|
|
831
|
+
const cover = coveringLayer(element, imageSource.element);
|
|
832
|
+
if (cover?.image) imageSource = { css: cover.css, element: cover.image, overlays: cover.overlays };
|
|
833
|
+
else if (cover) imageSource = null;
|
|
834
|
+
else if (cover === undefined && positionedPaintInside(imageSource.element, element)) {
|
|
835
|
+
return {
|
|
836
|
+
status: 'incomplete',
|
|
837
|
+
message: 'This text sits in a section with a background image or gradient, and a positioned element inside that section paints its own background where the text is. Which of the two is behind the glyphs depends on paint order, and the text is off screen where the browser cannot be asked, so check the contrast by eye.',
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
757
841
|
if (imageSource) {
|
|
758
842
|
if (ownMultiply) return { status: 'incomplete', message: 'This text multiplies against an image or gradient. Its contrast depends on the pixels behind each glyph; check it by eye.' };
|
|
759
843
|
const { relation, intrinsic, dimensionless, isGradient, sizedSmall, paint } = await imageVsText(imageSource, element, doc);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
-
import { isInert } from '../../lib/dom.js';
|
|
2
|
+
import { isInert, flatDescendants, flatTreeParent, outOfSequentialFocus } from '../../lib/dom.js';
|
|
3
3
|
// Deliberately excludes bare [tabindex]: a focusable container (e.g. a
|
|
4
4
|
// scrollable region with tabindex="0") holding links is correct markup.
|
|
5
5
|
const INTERACTIVE =
|
|
@@ -28,8 +28,14 @@ export default {
|
|
|
28
28
|
const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
|
|
29
29
|
// Negative tabindex removes sequential focus navigation, not focusability.
|
|
30
30
|
// https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// The FLAT tree: a custom element's shadow tree can hold the real
|
|
32
|
+
// control (material.io's cookie banner: div role="button" holding an
|
|
33
|
+
// <mwc-button tabindex="-1"> whose shadow tree renders a <button>).
|
|
34
|
+
// Sequential focusability follows the host: a negative tabindex on a
|
|
35
|
+
// shadow host takes its whole scope out of the tab order (HTML focus
|
|
36
|
+
// navigation scopes), so such a control is the negative-tabindex shape.
|
|
37
|
+
const candidates = flatDescendants(element).filter((el) =>
|
|
38
|
+
el.matches?.(INTERACTIVE) && !el.matches(':disabled') && !isInert(el)
|
|
33
39
|
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
|
|
34
40
|
// aria-hidden content that Tab reaches is aria-hidden-focus's finding.
|
|
35
41
|
// aria-hidden content with a negative tabindex is not, yet a click or a
|
|
@@ -38,23 +44,29 @@ export default {
|
|
|
38
44
|
// interaction with the user or indirectly via programmatic means", so
|
|
39
45
|
// it stays a candidate here.
|
|
40
46
|
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
|
|
41
|
-
&& !(el.closest('[aria-hidden="true"]') && !(el
|
|
47
|
+
&& !(el.closest('[aria-hidden="true"]') && !outOfSequentialFocus(el, element))
|
|
42
48
|
&& (el.matches(NATIVE) || el.hasAttribute('tabindex')));
|
|
43
49
|
// A child requiring review must not conceal a later definite finding.
|
|
44
|
-
const nested = candidates.find((el) => !(el
|
|
50
|
+
const nested = candidates.find((el) => !outOfSequentialFocus(el, element)) ?? candidates[0];
|
|
45
51
|
if (!nested) return { status: 'pass' };
|
|
46
52
|
// A programmatically focusable child must not silently pass. In an
|
|
47
53
|
// ARIA widget its name and role can survive presentational-descendant
|
|
48
54
|
// processing, so nesting alone does not prove a 4.1.2 failure.
|
|
49
55
|
// Native links/buttons separately forbid interactive descendants.
|
|
50
56
|
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
|
|
51
|
-
if (nested
|
|
52
|
-
|
|
57
|
+
if (outOfSequentialFocus(nested, element) && !element.matches('a[href], button')) {
|
|
58
|
+
let host = null;
|
|
59
|
+
for (let node = flatTreeParent(nested); node && node !== element; node = flatTreeParent(node)) {
|
|
60
|
+
if (node.shadowRoot && node.hasAttribute('tabindex') && node.tabIndex < 0) { host = node; break; }
|
|
61
|
+
}
|
|
62
|
+
const child = `<${nested.tagName.toLowerCase()}>`;
|
|
53
63
|
return {
|
|
54
64
|
status: 'incomplete',
|
|
55
|
-
message:
|
|
56
|
-
? `This control contains
|
|
57
|
-
:
|
|
65
|
+
message: host
|
|
66
|
+
? `This control contains a custom element (<${host.tagName.toLowerCase()}>) whose shadow tree holds another control (${child}). The host's negative tabindex keeps it out of the tab order, but a click or a script can still focus it. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`
|
|
67
|
+
: nested.closest('[aria-hidden="true"]')
|
|
68
|
+
? `This control contains an element (${child}) that is hidden from assistive technology but can still receive focus from a click or a script. Focus landing there lands on content a screen reader cannot see. Check whether it can take focus, and if it can, remove it from focus or from aria-hidden.`
|
|
69
|
+
: `This control contains an element (${child}) with a negative tabindex. It can still receive focus. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`,
|
|
58
70
|
};
|
|
59
71
|
}
|
|
60
72
|
// <summary> is the one outer control HTML does not forbid this on: its
|
package/package.json
CHANGED