pour-engine 1.46.1 → 1.46.2
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 +1 -1
- package/engine/lib/contrast.js +7 -6
- package/engine/lib/dom.js +17 -2
- package/engine/rules/best-practice/fieldset-legend.js +2 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +2 -1
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +2 -1
- package/engine/rules/wcag/1.4.3-color-contrast.js +13 -12
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +3 -1
- package/engine/rules/wcag/2.4.10-section-heading.js +3 -1
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +2 -2
- package/engine/rules/wcag/2.5.8-target-size.js +6 -6
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +2 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
|
|
2
|
+
import { containsNode } from './dom.js';
|
|
2
3
|
|
|
3
4
|
// Computed colours do not all come back as `rgb()` any more. A colour the
|
|
4
5
|
// author wrote in a modern syntax is returned in THAT syntax: Chrome and
|
|
@@ -610,7 +611,7 @@ export function backgroundObscured(element) {
|
|
|
610
611
|
if (start === -1) return 'unverifiable';
|
|
611
612
|
|
|
612
613
|
for (const layer of stack.slice(start + 1)) {
|
|
613
|
-
if (layer
|
|
614
|
+
if (containsNode(layer, element)) {
|
|
614
615
|
const style = getComputedStyle(layer);
|
|
615
616
|
if (style.backgroundImage !== 'none') return layer;
|
|
616
617
|
const bg = parseColor(style.backgroundColor);
|
|
@@ -1001,7 +1002,7 @@ function scrimIn(layersAbove, element, win) {
|
|
|
1001
1002
|
const rect = element.getBoundingClientRect();
|
|
1002
1003
|
const found = [];
|
|
1003
1004
|
for (const layer of layersAbove) {
|
|
1004
|
-
if (layer
|
|
1005
|
+
if (containsNode(layer, element) || containsNode(element, layer)) continue;
|
|
1005
1006
|
const style = getComputedStyle(layer);
|
|
1006
1007
|
const color = parseColor(style.backgroundColor);
|
|
1007
1008
|
const alpha = (color ? color.a : 0) * opacityOf(style);
|
|
@@ -1100,7 +1101,7 @@ export function paintedBackdrop(element) {
|
|
|
1100
1101
|
}
|
|
1101
1102
|
const layers = [];
|
|
1102
1103
|
for (const layer of stack.slice(start + 1)) {
|
|
1103
|
-
while (missing.length && !missing[0]
|
|
1104
|
+
while (missing.length && !containsNode(missing[0], layer)) layers.push(missing.shift());
|
|
1104
1105
|
layers.push(layer);
|
|
1105
1106
|
}
|
|
1106
1107
|
layers.push(...missing);
|
|
@@ -1466,7 +1467,7 @@ export function beneathOpaqueAncestor(element, media) {
|
|
|
1466
1467
|
if (inTopLayer(element) || inTopLayer(media)) return false;
|
|
1467
1468
|
const box = element.getBoundingClientRect();
|
|
1468
1469
|
for (let ancestor = element; ancestor; ancestor = ancestor.parentElement) {
|
|
1469
|
-
if (ancestor
|
|
1470
|
+
if (containsNode(ancestor, media)) return false; // from here up the media is inside
|
|
1470
1471
|
if (!createsStackingContext(ancestor)) continue;
|
|
1471
1472
|
const style = getComputedStyle(ancestor);
|
|
1472
1473
|
const color = parseColor(style.backgroundColor);
|
|
@@ -1479,7 +1480,7 @@ export function beneathOpaqueAncestor(element, media) {
|
|
|
1479
1480
|
if (!insideRoundedBox(box, ancestor.getBoundingClientRect(), style)) continue;
|
|
1480
1481
|
// The lowest stacking context holding both.
|
|
1481
1482
|
let shared = ancestor.parentElement;
|
|
1482
|
-
while (shared && !(createsStackingContext(shared) && shared
|
|
1483
|
+
while (shared && !(createsStackingContext(shared) && containsNode(shared, media))) shared = shared.parentElement;
|
|
1483
1484
|
if (!shared) return false;
|
|
1484
1485
|
const over = paintRank(ancestor, shared);
|
|
1485
1486
|
const under = paintRank(media, shared);
|
|
@@ -1489,7 +1490,7 @@ export function beneathOpaqueAncestor(element, media) {
|
|
|
1489
1490
|
if (under.z !== over.z) return under.z < over.z;
|
|
1490
1491
|
// Same layer and z-index: tree order, earlier paints first.
|
|
1491
1492
|
return Boolean(under.node.compareDocumentPosition(over.node) & 4 /* FOLLOWING */)
|
|
1492
|
-
&& !under.node
|
|
1493
|
+
&& !containsNode(under.node, over.node);
|
|
1493
1494
|
}
|
|
1494
1495
|
return false;
|
|
1495
1496
|
}
|
package/engine/lib/dom.js
CHANGED
|
@@ -149,12 +149,12 @@ function siblingPosition(element, index) {
|
|
|
149
149
|
if (!positions) {
|
|
150
150
|
const counts = new Map();
|
|
151
151
|
positions = new WeakMap();
|
|
152
|
-
for (const child of parent
|
|
152
|
+
for (const child of childrenOf(parent)) {
|
|
153
153
|
const position = (counts.get(child.tagName) ?? 0) + 1;
|
|
154
154
|
counts.set(child.tagName, position);
|
|
155
155
|
positions.set(child, { position, repeated: false });
|
|
156
156
|
}
|
|
157
|
-
for (const child of parent
|
|
157
|
+
for (const child of childrenOf(parent)) positions.get(child).repeated = counts.get(child.tagName) > 1;
|
|
158
158
|
index.parents.set(parent, positions);
|
|
159
159
|
}
|
|
160
160
|
return positions.get(element);
|
|
@@ -215,6 +215,21 @@ export function attributesOf(element) {
|
|
|
215
215
|
return attributesGetter ? attributesGetter.call(element) : [];
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** An element's child elements and a node's contains(), read through the
|
|
219
|
+
* prototype for the same reason: a booking form's field named "children"
|
|
220
|
+
* (jetblue.com) or a word search's field named "contains" (buchstaben.com)
|
|
221
|
+
* takes the place of the form's own property, and the audit threw. A
|
|
222
|
+
* select in that place is iterable, so its options cannot be told from
|
|
223
|
+
* the children by shape; the prototype is always asked. */
|
|
224
|
+
const childrenGetter = typeof Element !== 'undefined' ? Object.getOwnPropertyDescriptor(Element.prototype, 'children')?.get : null;
|
|
225
|
+
export function childrenOf(element) {
|
|
226
|
+
return childrenGetter ? childrenGetter.call(element) : element.children;
|
|
227
|
+
}
|
|
228
|
+
const nodeContains = typeof Node !== 'undefined' ? Node.prototype.contains : null;
|
|
229
|
+
export function containsNode(ancestor, node) {
|
|
230
|
+
return nodeContains ? nodeContains.call(ancestor, node) : ancestor.contains(node);
|
|
231
|
+
}
|
|
232
|
+
|
|
218
233
|
/** Element's opening markup (attributes) plus a snippet of its own text —
|
|
219
234
|
* enough to recognise it in a table. Built WITHOUT element.outerHTML, which
|
|
220
235
|
* serializes the whole subtree and turns audits quadratic on huge pages
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// WAI forms tutorial: radio/checkbox groups need a group label — each
|
|
2
2
|
// input's own label isn't enough to convey what the group asks.
|
|
3
3
|
import { accessibleName, labelledByName } from '../../lib/accessible-name.js';
|
|
4
|
+
import { containsNode } from '../../lib/dom.js';
|
|
4
5
|
export default {
|
|
5
6
|
id: 'fieldset-legend',
|
|
6
7
|
name: 'Grouped field labels',
|
|
@@ -41,7 +42,7 @@ export default {
|
|
|
41
42
|
// it empty (2026-08-25 overnight audit).
|
|
42
43
|
const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
|
|
43
44
|
const legend = group?.querySelector(':scope > legend');
|
|
44
|
-
const grouped = group && indexes.every((index) => group
|
|
45
|
+
const grouped = group && indexes.every((index) => containsNode(group, elements[index])) && (
|
|
45
46
|
(legend && accessibleName(legend))
|
|
46
47
|
|| labelledByName(group)
|
|
47
48
|
|| group.getAttribute('aria-label')?.trim()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Composite roles are meaningless without their required child roles —
|
|
3
3
|
// role="list" with no listitems announces as an empty list.
|
|
4
4
|
import { effectiveRole } from '../../lib/roles.js';
|
|
5
|
+
import { childrenOf } from '../../lib/dom.js';
|
|
5
6
|
// Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
|
|
6
7
|
// diffs this map against the spec (2026-09-01, after feed and row were
|
|
7
8
|
// found missing).
|
|
@@ -112,7 +113,7 @@ export default {
|
|
|
112
113
|
const role = effectiveRole(element);
|
|
113
114
|
const required = REQUIRED_CHILDREN[role];
|
|
114
115
|
if (!required) return { status: 'pass' };
|
|
115
|
-
const children = [...element
|
|
116
|
+
const children = [...childrenOf(element), ...(element.shadowRoot?.children ?? [])]
|
|
116
117
|
.filter((c) => !c.matches('script, style, template'));
|
|
117
118
|
// A completely empty container is a lazy-load placeholder more often
|
|
118
119
|
// than a defect — it announces as an empty list, which is accurate.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.4.1 Use of Color (Level A)
|
|
2
2
|
import { parseColor, contrastRatio, composite, effectiveBackground, backgroundImageSource, asRgb } from '../../lib/contrast.js';
|
|
3
|
+
import { containsNode } from '../../lib/dom.js';
|
|
3
4
|
|
|
4
5
|
// Split a computed list value ("a, b(c, d), e") on the commas OUTSIDE
|
|
5
6
|
// parentheses: gradients and colour functions carry commas of their own.
|
|
@@ -326,7 +327,7 @@ export default {
|
|
|
326
327
|
const range = element.ownerDocument.createRange();
|
|
327
328
|
let sharesLine = false;
|
|
328
329
|
for (let node = walker.nextNode(); node && !sharesLine; node = walker.nextNode()) {
|
|
329
|
-
if (!node.textContent.trim() || element
|
|
330
|
+
if (!node.textContent.trim() || containsNode(element, node)) continue;
|
|
330
331
|
range.selectNodeContents(node);
|
|
331
332
|
for (const r of range.getClientRects()) {
|
|
332
333
|
if (r.width > 0 && mids.some((mid) => r.top <= mid && r.bottom >= mid)) { sharesLine = true; break; }
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
9
|
showRatio, asRgb, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop, labelReferrers, beneathOpaqueAncestor, lazyWithheldBackground } from '../../lib/contrast.js';
|
|
10
|
+
import { childrenOf, containsNode } from '../../lib/dom.js';
|
|
10
11
|
|
|
11
12
|
/** The first url() among a background-image list's layers, or null. */
|
|
12
13
|
const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
|
|
@@ -215,7 +216,7 @@ function coveredByTextTwin(element) {
|
|
|
215
216
|
const index = stack.indexOf(element);
|
|
216
217
|
if (index <= 0) return false;
|
|
217
218
|
return stack.slice(0, index).some((layer) =>
|
|
218
|
-
!layer
|
|
219
|
+
!containsNode(layer, element) && !containsNode(element, layer) && layer.textContent.trim() === text);
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
/** Do any of the element's own text boxes MEANINGFULLY overlap the given
|
|
@@ -361,7 +362,7 @@ function unaccountedScrim(element, imageCarrier) {
|
|
|
361
362
|
const start = stack.indexOf(element);
|
|
362
363
|
if (start === -1) return false;
|
|
363
364
|
return stack.slice(start + 1).some((layer) => {
|
|
364
|
-
if (layer
|
|
365
|
+
if (containsNode(layer, element) || layer === imageCarrier) return false; // ancestor chain: already composited
|
|
365
366
|
const color = parseColor(getComputedStyle(layer).backgroundColor);
|
|
366
367
|
return color && color.a > 0;
|
|
367
368
|
});
|
|
@@ -396,7 +397,7 @@ function coveringLayer(element, imageCarrier) {
|
|
|
396
397
|
const overlays = [];
|
|
397
398
|
for (const layer of stack.slice(start + 1)) {
|
|
398
399
|
if (layer === imageCarrier) return null;
|
|
399
|
-
if (layer
|
|
400
|
+
if (containsNode(layer, element)) {
|
|
400
401
|
const own = parseColor(getComputedStyle(layer).backgroundColor);
|
|
401
402
|
if (own && own.a > 0 && own.a < 1) overlays.push(own);
|
|
402
403
|
continue;
|
|
@@ -424,7 +425,7 @@ function positionedPaintInside(carrier, element) {
|
|
|
424
425
|
const point = textSamplePoint(element);
|
|
425
426
|
if (!point) return false;
|
|
426
427
|
for (const node of carrier.querySelectorAll('*')) {
|
|
427
|
-
if (node
|
|
428
|
+
if (containsNode(node, element) || containsNode(element, node)) continue;
|
|
428
429
|
const style = getComputedStyle(node);
|
|
429
430
|
if (style.position !== 'absolute' && style.position !== 'fixed') continue;
|
|
430
431
|
const paints = (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage))
|
|
@@ -744,7 +745,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
744
745
|
// Level 1: B(Cb, Cs) = Cb * Cs. Keep groups, filters and generated paint
|
|
745
746
|
// outside this narrow path; its backdrop is resolved below as usual.
|
|
746
747
|
const ownMultiply = style.mixBlendMode === 'multiply'
|
|
747
|
-
&& styleSource === element && !element.
|
|
748
|
+
&& styleSource === element && !childrenOf(element).length && !element.shadowRoot
|
|
748
749
|
&& parseColor(style.backgroundColor)?.a === 0 && style.backgroundImage === 'none'
|
|
749
750
|
&& style.filter === 'none' && (!style.backdropFilter || style.backdropFilter === 'none')
|
|
750
751
|
&& (!style.textShadow || style.textShadow === 'none')
|
|
@@ -871,7 +872,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
871
872
|
const near = (r) => Math.min(groupRect.right, r.right) - Math.max(groupRect.left, r.left) >= 3
|
|
872
873
|
&& Math.min(groupRect.bottom, r.bottom) - Math.max(groupRect.top, r.top) >= 3;
|
|
873
874
|
const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect }) =>
|
|
874
|
-
near(mediaRect) && !media
|
|
875
|
+
near(mediaRect) && !containsNode(media, element) && !containsNode(element, media) && textIntersects(element, mediaRect));
|
|
875
876
|
if (overMedia) {
|
|
876
877
|
return {
|
|
877
878
|
status: 'incomplete',
|
|
@@ -879,7 +880,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
879
880
|
};
|
|
880
881
|
}
|
|
881
882
|
const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color }) =>
|
|
882
|
-
near(rect) && !panel
|
|
883
|
+
near(rect) && !containsNode(panel, element) && !containsNode(element, panel) && textIntersects(element, rect)
|
|
883
884
|
&& (contrastRatio(composite(group.text, color), composite(group.paint, color)) >= required) !== (groupRatio >= required));
|
|
884
885
|
if (flipping) {
|
|
885
886
|
return {
|
|
@@ -898,7 +899,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
898
899
|
let groupScrim = groupPainted?.scrim ?? null;
|
|
899
900
|
if (!groupScrim && groupPainted === 'offscreen') {
|
|
900
901
|
const veil = viewportVeil(doc);
|
|
901
|
-
if (veil && !veil
|
|
902
|
+
if (veil && !containsNode(veil, element)) groupScrim = [veil];
|
|
902
903
|
}
|
|
903
904
|
if (groupScrim) {
|
|
904
905
|
const groupVeil = scrimPaint(groupScrim);
|
|
@@ -1034,7 +1035,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
1034
1035
|
// in-viewport, hence never routed through this branch.)
|
|
1035
1036
|
if (!scrimLayers && painted === 'offscreen') {
|
|
1036
1037
|
const veil = viewportVeil(doc);
|
|
1037
|
-
if (veil && !veil
|
|
1038
|
+
if (veil && !containsNode(veil, element)) scrimLayers = [veil];
|
|
1038
1039
|
}
|
|
1039
1040
|
if (scrimLayers) {
|
|
1040
1041
|
veilPaint = scrimPaint(scrimLayers);
|
|
@@ -1264,7 +1265,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
1264
1265
|
// to a person: can paint order prove an opaque ancestor lies over it?
|
|
1265
1266
|
const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect, hitTestBlind }) =>
|
|
1266
1267
|
(!blindOnly || hitTestBlind) && near(mediaRect)
|
|
1267
|
-
&& !media
|
|
1268
|
+
&& !containsNode(media, element) && !containsNode(element, media)
|
|
1268
1269
|
&& textIntersects(element, mediaRect)
|
|
1269
1270
|
&& !beneathOpaqueAncestor(element, media));
|
|
1270
1271
|
if (overMedia) {
|
|
@@ -1275,7 +1276,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
1275
1276
|
}
|
|
1276
1277
|
const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color, hitTestBlind }) =>
|
|
1277
1278
|
(!blindOnly || hitTestBlind) && near(rect)
|
|
1278
|
-
&& !panel
|
|
1279
|
+
&& !containsNode(panel, element) && !containsNode(element, panel)
|
|
1279
1280
|
&& textIntersects(element, rect)
|
|
1280
1281
|
&& (ownMultiply || (contrastRatio(foreground, color) >= required) !== (direction === 'pass')));
|
|
1281
1282
|
if (flipping) {
|
|
@@ -1308,7 +1309,7 @@ export function createContrastRule({ id, name, tags, help, helpUrl, thresholds }
|
|
|
1308
1309
|
const inside = block.querySelectorAll('*');
|
|
1309
1310
|
if (inside.length <= 2000) {
|
|
1310
1311
|
for (const sibling of inside) {
|
|
1311
|
-
if (sibling === element || sibling
|
|
1312
|
+
if (sibling === element || containsNode(sibling, element) || containsNode(element, sibling)) continue;
|
|
1312
1313
|
const siblingStyle = getComputedStyle(sibling);
|
|
1313
1314
|
if (siblingStyle.position !== 'static' || siblingStyle.visibility === 'hidden') continue;
|
|
1314
1315
|
const paint = parseColor(siblingStyle.backgroundColor);
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// reason pause-stop-hide skips spinners: flagging every small moving
|
|
22
22
|
// thumbnail would bury the full-bleed hero that matters. Silence there is
|
|
23
23
|
// abstaining, not clearing.
|
|
24
|
+
import { containsNode } from '../../lib/dom.js';
|
|
25
|
+
|
|
24
26
|
/** A visible, enabled button whose aria-controls names the video or an
|
|
25
27
|
* ancestor of it, looked up in the video's own document or shadow tree. */
|
|
26
28
|
function pauseControlFor(video, isVisible) {
|
|
@@ -31,7 +33,7 @@ function pauseControlFor(video, isVisible) {
|
|
|
31
33
|
if (control.disabled || !isVisible(control)) continue;
|
|
32
34
|
for (const id of control.getAttribute('aria-controls').split(/\s+/)) {
|
|
33
35
|
const target = id && byId(id);
|
|
34
|
-
if (target && (target === video || target
|
|
36
|
+
if (target && (target === video || containsNode(target, video))) return control;
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
return null;
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
// aria-labelledby or aria-label has a title by another route and is left
|
|
21
21
|
// alone. Sections with little text, or none, are the UI-component case the
|
|
22
22
|
// criterion excludes.
|
|
23
|
+
import { childrenOf } from '../../lib/dom.js';
|
|
24
|
+
|
|
23
25
|
const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
|
|
24
26
|
const SUBSTANTIVE = 'img, svg, video, audio, canvas, iframe, input, select, textarea, button';
|
|
25
27
|
const SKIP = 'script, style, template, noscript';
|
|
@@ -70,7 +72,7 @@ export default {
|
|
|
70
72
|
// corpus page and helped nobody.
|
|
71
73
|
const item = element.closest('article');
|
|
72
74
|
if (item && item.parentElement && !item.querySelector(HEADING)) {
|
|
73
|
-
const items = (node) => [...node
|
|
75
|
+
const items = (node) => [...childrenOf(node)].filter((child) => child.matches('article') || child.querySelector(':scope > article')).length;
|
|
74
76
|
const container = item.parentElement;
|
|
75
77
|
if (items(container) >= 3 || (container.parentElement && items(container.parentElement) >= 3)) return { status: 'pass' };
|
|
76
78
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// Sites that apply the spec's own sufficient technique — scroll-padding for
|
|
19
19
|
// the overlay's edge — are not flagged at all.
|
|
20
|
-
import { isInert } from '../../lib/dom.js';
|
|
20
|
+
import { isInert, containsNode } from '../../lib/dom.js';
|
|
21
21
|
|
|
22
22
|
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
23
23
|
|
|
@@ -105,7 +105,7 @@ export function createFocusObscuredRule({ id, name, tags, help, helpUrl, partial
|
|
|
105
105
|
if (index <= 0) return null;
|
|
106
106
|
const above = stack.slice(0, index);
|
|
107
107
|
return above.find((layer) =>
|
|
108
|
-
!layer
|
|
108
|
+
!containsNode(layer, element) && !containsNode(element, layer)
|
|
109
109
|
&& isObscuringOverlay(layer)
|
|
110
110
|
&& (partial ? overlaps : contained)(rect, layer.getBoundingClientRect())) ?? null;
|
|
111
111
|
};
|
|
@@ -29,12 +29,12 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
|
|
|
29
29
|
* These are keyboard affordances, not pointer targets — and their phantom
|
|
30
30
|
* rects must not count as "crowding" for the real targets around them.
|
|
31
31
|
*/
|
|
32
|
-
import { isInert } from '../../lib/dom.js';
|
|
32
|
+
import { isInert, containsNode } from '../../lib/dom.js';
|
|
33
33
|
|
|
34
34
|
// A web component's internal link and its enclosing light-DOM link are
|
|
35
35
|
// not independent pointer targets. DOM contains() stops at shadow roots.
|
|
36
36
|
function containsComposed(ancestor, element) {
|
|
37
|
-
if (ancestor
|
|
37
|
+
if (containsNode(ancestor, element)) return true;
|
|
38
38
|
if (ancestor.getRootNode() === element.getRootNode() && !element.assignedSlot) return false;
|
|
39
39
|
for (let node = element; node; node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
|
|
40
40
|
if (node === ancestor) return true;
|
|
@@ -145,7 +145,7 @@ function reachableRects(element, rect) {
|
|
|
145
145
|
// Anything above it that is part of the same control is not an
|
|
146
146
|
// obstruction: a button's own icon paints over the button, an overlay
|
|
147
147
|
// label over its input. Only a genuinely separate element blocks it.
|
|
148
|
-
if (stack.slice(0, index).every((layer) => layer
|
|
148
|
+
if (stack.slice(0, index).every((layer) => containsNode(layer, element) || containsNode(element, layer))) {
|
|
149
149
|
reachable.push(fragment);
|
|
150
150
|
}
|
|
151
151
|
}
|
|
@@ -438,8 +438,8 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
438
438
|
if (!win || typeof doc.elementsFromPoint !== 'function'
|
|
439
439
|
|| x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
|
|
440
440
|
for (const layer of doc.elementsFromPoint(x, y)) {
|
|
441
|
-
const inJ = elements[j] === layer || elements[j]
|
|
442
|
-
const inI = elements[i] === layer || elements[i]
|
|
441
|
+
const inJ = elements[j] === layer || containsNode(elements[j], layer);
|
|
442
|
+
const inI = elements[i] === layer || containsNode(elements[i], layer);
|
|
443
443
|
if (inJ && !inI) return true;
|
|
444
444
|
if (inI && !inJ) return false;
|
|
445
445
|
}
|
|
@@ -504,7 +504,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
504
504
|
const calculateObscuredRect = (i) => {
|
|
505
505
|
let areas = null;
|
|
506
506
|
for (const j of nearbyCandidates(i)) {
|
|
507
|
-
if (elements[j]
|
|
507
|
+
if (containsNode(elements[j], elements[i]) || containsNode(elements[i], elements[j])) continue; // same control, nested markup
|
|
508
508
|
if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
|
|
509
509
|
// Two links to the same place are one control in two boxes (a
|
|
510
510
|
// card's image link over its title link): clicks in the overlap
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
import { cumulativeOpacity } from '../../lib/contrast.js';
|
|
3
|
+
import { containsNode } from '../../lib/dom.js';
|
|
3
4
|
|
|
4
5
|
const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, '
|
|
5
6
|
+ 'audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
|
|
@@ -44,7 +45,7 @@ export default {
|
|
|
44
45
|
let modal = null;
|
|
45
46
|
try { modal = doc.querySelector('dialog:modal'); } catch { /* older engine */ }
|
|
46
47
|
modal ??= [...doc.querySelectorAll('[aria-modal="true"]')].find(isRendered) ?? null;
|
|
47
|
-
if (modal && !element
|
|
48
|
+
if (modal && !containsNode(element, modal) && !containsNode(modal, element)) {
|
|
48
49
|
return {
|
|
49
50
|
status: 'incomplete',
|
|
50
51
|
message: `This aria-hidden element contains ${focusable.length} focusable element(s) while a modal dialog is open — fine if the modal traps keyboard focus, a failure if Tab can reach them. Check the trap by keyboard.`,
|
package/package.json
CHANGED