pour-engine 1.40.0 → 1.41.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 +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +69 -63
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +5 -2
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-form-label.js +5 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +8 -2
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +161 -45
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- package/package.json +1 -1
package/engine/lib/dom.js
CHANGED
|
@@ -13,7 +13,9 @@ export const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'M
|
|
|
13
13
|
* design — like every DOM tool, we can only see what the page exposes.
|
|
14
14
|
*/
|
|
15
15
|
export function collectRoots(context) {
|
|
16
|
-
|
|
16
|
+
// querySelectorAll excludes the context itself, including its own open
|
|
17
|
+
// shadow root when a host element is the requested audit scope.
|
|
18
|
+
const roots = context.shadowRoot ? [context, context.shadowRoot] : [context];
|
|
17
19
|
for (let i = 0; i < roots.length; i++) {
|
|
18
20
|
if (!roots[i].querySelectorAll) continue;
|
|
19
21
|
for (const el of roots[i].querySelectorAll('*')) {
|
|
@@ -25,8 +27,8 @@ export function collectRoots(context) {
|
|
|
25
27
|
|
|
26
28
|
/** Nearest ancestor in the FLAT tree: parent element, or the shadow host
|
|
27
29
|
* when the walk reaches the top of a shadow tree. */
|
|
28
|
-
function flatTreeParent(node) {
|
|
29
|
-
return node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
30
|
+
export function flatTreeParent(node) {
|
|
31
|
+
return node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -46,9 +48,75 @@ export function isRendered(element) {
|
|
|
46
48
|
* hides its shadow content too. */
|
|
47
49
|
export function isVisible(element) {
|
|
48
50
|
for (let node = element; node; node = flatTreeParent(node)) {
|
|
49
|
-
if (node.getAttribute?.('aria-hidden') === 'true') return false;
|
|
51
|
+
if (node.getAttribute?.('aria-hidden') === 'true' || node.hasAttribute?.('inert')) return false;
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
if (isRendered(element)) return true;
|
|
54
|
+
// Boxless semantic containers can still be exposed to AT. Keep the
|
|
55
|
+
// visual helper box-based, but do not skip their names/relationships.
|
|
56
|
+
const style = getComputedStyle(element);
|
|
57
|
+
if (style.display !== 'contents' || style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
58
|
+
for (let node = flatTreeParent(element); node; node = flatTreeParent(node)) {
|
|
59
|
+
if (getComputedStyle(node).display === 'none') return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let pathRoots = new WeakMap();
|
|
65
|
+
const pathObservers = new Set();
|
|
66
|
+
|
|
67
|
+
/** Release observers after an audit, including an aborted audit. */
|
|
68
|
+
export function releaseDOMCaches() {
|
|
69
|
+
for (const observer of pathObservers) observer.disconnect();
|
|
70
|
+
pathObservers.clear();
|
|
71
|
+
pathRoots = new WeakMap();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const resetDOMCaches = releaseDOMCaches;
|
|
75
|
+
|
|
76
|
+
function pathIndex(root) {
|
|
77
|
+
let index = pathRoots.get(root);
|
|
78
|
+
if (!index) {
|
|
79
|
+
const observer = typeof MutationObserver === 'function' ? new MutationObserver(() => {
|
|
80
|
+
index.ids = null;
|
|
81
|
+
index.parents = new WeakMap();
|
|
82
|
+
}) : null;
|
|
83
|
+
index = { ids: null, parents: new WeakMap(), observer };
|
|
84
|
+
if (observer) {
|
|
85
|
+
observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['id'] });
|
|
86
|
+
pathObservers.add(observer);
|
|
87
|
+
}
|
|
88
|
+
pathRoots.set(root, index);
|
|
89
|
+
}
|
|
90
|
+
// Synchronous mutations may precede the observer callback. A selector
|
|
91
|
+
// must describe the current tree even in the same JavaScript task.
|
|
92
|
+
if (index.observer?.takeRecords().length) {
|
|
93
|
+
index.ids = null;
|
|
94
|
+
index.parents = new WeakMap();
|
|
95
|
+
}
|
|
96
|
+
if (!index.ids) {
|
|
97
|
+
index.ids = new Map();
|
|
98
|
+
for (const el of root.querySelectorAll('[id]')) {
|
|
99
|
+
index.ids.set(el.id, (index.ids.get(el.id) ?? 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return index;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function siblingPosition(element, index) {
|
|
106
|
+
const parent = element.parentElement;
|
|
107
|
+
let positions = index.parents.get(parent);
|
|
108
|
+
if (!positions) {
|
|
109
|
+
const counts = new Map();
|
|
110
|
+
positions = new WeakMap();
|
|
111
|
+
for (const child of parent.children) {
|
|
112
|
+
const position = (counts.get(child.tagName) ?? 0) + 1;
|
|
113
|
+
counts.set(child.tagName, position);
|
|
114
|
+
positions.set(child, { position, repeated: false });
|
|
115
|
+
}
|
|
116
|
+
for (const child of parent.children) positions.get(child).repeated = counts.get(child.tagName) > 1;
|
|
117
|
+
index.parents.set(parent, positions);
|
|
118
|
+
}
|
|
119
|
+
return positions.get(element);
|
|
52
120
|
}
|
|
53
121
|
|
|
54
122
|
/** Selector for an element within its own root (document or shadow root). */
|
|
@@ -58,24 +126,16 @@ function cssPathInRoot(element) {
|
|
|
58
126
|
// bare #id selector would round-trip every one of them to the first,
|
|
59
127
|
// collapsing distinct findings onto a single element.
|
|
60
128
|
const root = element.getRootNode();
|
|
61
|
-
const
|
|
129
|
+
const index = pathIndex(root);
|
|
130
|
+
const uniqueId = (el) => el.id && index.ids.get(el.id) === 1;
|
|
62
131
|
if (uniqueId(element)) return `#${CSS.escape(element.id)}`;
|
|
63
132
|
const parts = [];
|
|
64
133
|
let current = element;
|
|
65
134
|
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
|
|
66
135
|
let part = current.tagName.toLowerCase();
|
|
67
136
|
if (current.parentElement) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
let index = 1;
|
|
71
|
-
for (let sib = current.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
|
72
|
-
if (sib.tagName === current.tagName) index += 1;
|
|
73
|
-
}
|
|
74
|
-
let repeated = index > 1;
|
|
75
|
-
for (let sib = current.nextElementSibling; !repeated && sib; sib = sib.nextElementSibling) {
|
|
76
|
-
if (sib.tagName === current.tagName) repeated = true;
|
|
77
|
-
}
|
|
78
|
-
if (repeated) part += `:nth-of-type(${index})`;
|
|
137
|
+
const { position, repeated } = siblingPosition(current, index);
|
|
138
|
+
if (repeated) part += `:nth-of-type(${position})`;
|
|
79
139
|
}
|
|
80
140
|
parts.unshift(part);
|
|
81
141
|
if (current.parentElement && uniqueId(current.parentElement)) {
|
|
@@ -152,7 +212,7 @@ export function isEmbeddedDocument(doc) {
|
|
|
152
212
|
* presence is what counts, whatever the value.
|
|
153
213
|
*/
|
|
154
214
|
export function isInert(element) {
|
|
155
|
-
for (let node = element; node; node = node
|
|
215
|
+
for (let node = element; node; node = flatTreeParent(node)) {
|
|
156
216
|
if (node.nodeType === 1 && node.hasAttribute('inert')) return true;
|
|
157
217
|
}
|
|
158
218
|
return false;
|
package/engine/lib/roles.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// ARIA 1.2 role model: implicit roles for HTML elements, and which aria-*
|
|
2
2
|
// attributes each role supports (flattened with inheritance). Attribute
|
|
3
3
|
// names are stored without the "aria-" prefix.
|
|
4
|
+
import { isInert } from './dom.js';
|
|
4
5
|
|
|
5
6
|
/** Every attribute name in the ARIA 1.2 vocabulary (without the prefix).
|
|
6
7
|
* aria-attr-valid judges membership; aria-allowed-attr judges role fit —
|
|
@@ -222,7 +223,7 @@ function presentationDiscarded(element) {
|
|
|
222
223
|
// A global property is exposed whatever the element's state, so it
|
|
223
224
|
// discards the role on its own, disabled or not.
|
|
224
225
|
if ([...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`))) return true;
|
|
225
|
-
if (element.matches(':disabled') || element
|
|
226
|
+
if (element.matches(':disabled') || isInert(element)) return false;
|
|
226
227
|
if (element.tabIndex >= 0) return true;
|
|
227
228
|
return element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]');
|
|
228
229
|
}
|
|
@@ -231,11 +232,13 @@ export function effectiveRole(element) {
|
|
|
231
232
|
const explicit = element.getAttribute('role')?.trim().split(/\s+/) ?? [];
|
|
232
233
|
for (const token of explicit) {
|
|
233
234
|
const role = token.toLowerCase();
|
|
235
|
+
if (role === 'image') return 'img'; // supported synonym for the image role
|
|
234
236
|
if (!ROLE_ARIA[role]) continue;
|
|
235
237
|
if ((role === 'presentation' || role === 'none') && presentationDiscarded(element)) {
|
|
236
238
|
return implicitRole(element);
|
|
237
239
|
}
|
|
238
240
|
return role;
|
|
239
241
|
}
|
|
240
|
-
|
|
242
|
+
// ARIA 9.1: unrecognized tokens leave native semantics intact.
|
|
243
|
+
return implicitRole(element);
|
|
241
244
|
}
|
|
@@ -12,13 +12,19 @@ export default {
|
|
|
12
12
|
// Judged as a set: only same-name groups of 2+ need grouping, and one
|
|
13
13
|
// finding per group is enough.
|
|
14
14
|
evaluateAll(elements) {
|
|
15
|
-
const groups =
|
|
15
|
+
const groups = new Map();
|
|
16
16
|
elements.forEach((element, index) => {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Form identity matters: two idless forms can use the same field name
|
|
18
|
+
// without making one group. Group controls within their own tree too.
|
|
19
|
+
const owner = element.form ?? element.getRootNode();
|
|
20
|
+
if (!groups.has(owner)) groups.set(owner, new Map());
|
|
21
|
+
const byName = groups.get(owner);
|
|
22
|
+
const key = `${element.type}::${element.name}`;
|
|
23
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
24
|
+
byName.get(key).push(index);
|
|
19
25
|
});
|
|
20
26
|
const outcomes = elements.map(() => ({ status: 'pass' }));
|
|
21
|
-
for (const indexes of
|
|
27
|
+
for (const indexes of [...groups.values()].flatMap((byName) => [...byName.values()])) {
|
|
22
28
|
if (indexes.length < 2) continue;
|
|
23
29
|
const first = elements[indexes[0]];
|
|
24
30
|
// A <fieldset> IS a group; it does not need role="group" written on it
|
|
@@ -35,7 +41,7 @@ export default {
|
|
|
35
41
|
// it empty (2026-08-25 overnight audit).
|
|
36
42
|
const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
|
|
37
43
|
const legend = group?.querySelector(':scope > legend');
|
|
38
|
-
const grouped = group && (
|
|
44
|
+
const grouped = group && indexes.every((index) => group.contains(elements[index])) && (
|
|
39
45
|
(legend && accessibleName(legend))
|
|
40
46
|
|| labelledByName(group)
|
|
41
47
|
|| group.getAttribute('aria-label')?.trim()
|
|
@@ -8,7 +8,12 @@ export default {
|
|
|
8
8
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
|
|
9
9
|
selector: 'map area[href]',
|
|
10
10
|
visibleOnly: false,
|
|
11
|
-
evaluate(element, { accessibleName }) {
|
|
11
|
+
evaluate(element, { accessibleName, isVisible }) {
|
|
12
|
+
const map = element.closest('map');
|
|
13
|
+
const images = [...element.getRootNode().querySelectorAll('img[usemap], object[usemap]')];
|
|
14
|
+
const active = map?.name && images.some((image) =>
|
|
15
|
+
image.getAttribute('usemap') === `#${map.name}` && isVisible(image));
|
|
16
|
+
if (!active) return { status: 'pass' };
|
|
12
17
|
if (accessibleName(element)) return { status: 'pass' };
|
|
13
18
|
return {
|
|
14
19
|
status: 'fail',
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// nothing (F30/F39) — flagged for eyes.
|
|
12
12
|
const FILENAME_ALT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)([?#].*)?$/i;
|
|
13
13
|
const GENERIC_ALT = /^(image|img|photo|photograph|picture|graphic|icon|untitled|placeholder|spacer|\d+)$/i;
|
|
14
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
14
15
|
|
|
15
16
|
export default {
|
|
16
17
|
id: 'image-alt',
|
|
@@ -21,7 +22,7 @@ export default {
|
|
|
21
22
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
|
|
22
23
|
selector: 'img',
|
|
23
24
|
evaluate(element, { accessibleName }) {
|
|
24
|
-
const role = element
|
|
25
|
+
const role = effectiveRole(element);
|
|
25
26
|
if (role === 'presentation' || role === 'none') return { status: 'pass' };
|
|
26
27
|
if (element.hasAttribute('alt')) {
|
|
27
28
|
const alt = element.getAttribute('alt');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.1.1 Non-text Content (Level A)
|
|
2
2
|
import { labelledByName } from '../../lib/accessible-name.js';
|
|
3
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
3
4
|
|
|
4
5
|
export default {
|
|
5
6
|
id: 'svg-img-alt',
|
|
@@ -11,24 +12,20 @@ export default {
|
|
|
11
12
|
// <img> itself is handled by image-alt; this covers SVG (with or without a
|
|
12
13
|
// role) and role="img" divs/spans. Top-level plain <svg> is included
|
|
13
14
|
// because browsers expose it to AT as a graphic even without a role.
|
|
14
|
-
selector: 'svg
|
|
15
|
+
selector: 'svg, [role]',
|
|
15
16
|
evaluate(element, { accessibleName }) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
fix: 'Add aria-label="…" or aria-labelledby pointing at visible text.',
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
if (accessibleName(element)) return { status: 'pass' };
|
|
17
|
+
if (element.tagName === 'IMG') return { status: 'pass' }; // image-alt owns native images
|
|
18
|
+
const svg = element.tagName.toLowerCase() === 'svg';
|
|
19
|
+
const explicitGraphic = effectiveRole(element) === 'img'
|
|
20
|
+
|| ['graphics-document', 'graphics-symbol'].includes(element.getAttribute('role')?.trim());
|
|
21
|
+
if (!svg && !explicitGraphic) return { status: 'pass' };
|
|
22
|
+
if (svg && element.hasAttribute('role') && !explicitGraphic) return { status: 'pass' };
|
|
23
|
+
// Graphic roles name from author, not arbitrary descendant text.
|
|
24
|
+
// SVG adds its own direct title as a native naming source.
|
|
25
|
+
const nativeTitle = svg && [...element.children].find((child) => child.tagName.toLowerCase() === 'title');
|
|
26
|
+
const name = labelledByName(element) || element.getAttribute('aria-label')?.trim()
|
|
27
|
+
|| (nativeTitle && accessibleName(nativeTitle)) || element.getAttribute('title')?.trim();
|
|
28
|
+
if (name) return { status: 'pass' };
|
|
32
29
|
if (!element.getAttribute('role') && element.tagName.toLowerCase() === 'svg') {
|
|
33
30
|
if (element.parentNode instanceof SVGElement) return { status: 'pass' }; // nested svg: part of the outer graphic
|
|
34
31
|
// Inside a control that already has a name, the svg is effectively
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
2
|
// Composite roles are meaningless without their required child roles —
|
|
3
3
|
// role="list" with no listitems announces as an empty list.
|
|
4
|
-
import {
|
|
4
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
5
5
|
// Every row of ARIA 1.2's Required Owned Elements table; verify:aria-tables
|
|
6
6
|
// diffs this map against the spec (2026-09-01, after feed and row were
|
|
7
7
|
// found missing).
|
|
@@ -21,9 +21,7 @@ export const REQUIRED_CHILDREN = {
|
|
|
21
21
|
rowgroup: ['row'],
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const roleOf =
|
|
25
|
-
element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
26
|
-
implicitRole(element);
|
|
24
|
+
const roleOf = effectiveRole;
|
|
27
25
|
|
|
28
26
|
/**
|
|
29
27
|
* Every element in the COMPOSED subtree of `element`: its light-DOM
|
|
@@ -88,6 +86,19 @@ function candidateDescendants(element) {
|
|
|
88
86
|
return found;
|
|
89
87
|
}
|
|
90
88
|
|
|
89
|
+
function exposedChild(element, isVisible) {
|
|
90
|
+
if (isVisible(element)) return true;
|
|
91
|
+
// A collapsed native select still exposes its options through the
|
|
92
|
+
// platform accessibility API, although those options have no CSS boxes.
|
|
93
|
+
// Chromium also retains individually hidden options in that native popup
|
|
94
|
+
// tree. Only the select's exposure gates this narrow native exception;
|
|
95
|
+
// applying CSS-box visibility to its options would assert an empty widget
|
|
96
|
+
// even while the browser exposes its choices.
|
|
97
|
+
if (element.tagName !== 'OPTION') return false;
|
|
98
|
+
const select = element.closest('select');
|
|
99
|
+
return !!select && isVisible(select);
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
export default {
|
|
92
103
|
id: 'aria-required-children',
|
|
93
104
|
name: 'Required ARIA children',
|
|
@@ -95,17 +106,18 @@ export default {
|
|
|
95
106
|
tags: ['wcag2a', 'wcag131'],
|
|
96
107
|
help: 'Composite ARIA roles must contain their required children',
|
|
97
108
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
98
|
-
selector:
|
|
99
|
-
evaluate(element) {
|
|
109
|
+
selector: '[role]',
|
|
110
|
+
evaluate(element, { isVisible }) {
|
|
100
111
|
if (element.getAttribute('aria-busy') === 'true') return { status: 'pass' }; // still loading
|
|
101
|
-
const role = element
|
|
112
|
+
const role = effectiveRole(element);
|
|
102
113
|
const required = REQUIRED_CHILDREN[role];
|
|
114
|
+
if (!required) return { status: 'pass' };
|
|
103
115
|
const children = [...element.children, ...(element.shadowRoot?.children ?? [])]
|
|
104
116
|
.filter((c) => !c.matches('script, style, template'));
|
|
105
117
|
// A completely empty container is a lazy-load placeholder more often
|
|
106
118
|
// than a defect — it announces as an empty list, which is accurate.
|
|
107
119
|
if (!children.length && !element.hasAttribute('aria-owns')) return { status: 'pass' };
|
|
108
|
-
if (candidateDescendants(element).some((child) => required.includes(roleOf(child)))) {
|
|
120
|
+
if (candidateDescendants(element).some((child) => required.includes(roleOf(child)) && exposedChild(child, isVisible))) {
|
|
109
121
|
return { status: 'pass' };
|
|
110
122
|
}
|
|
111
123
|
return {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
2
|
// Child roles that only make sense inside a specific container role.
|
|
3
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
3
4
|
// Explicit roles only — native elements (li outside ul) are covered by
|
|
4
5
|
// listitem-parent/dlitem-parent.
|
|
5
6
|
//
|
|
@@ -33,7 +34,6 @@ export const REQUIRED_PARENT = {
|
|
|
33
34
|
gridcell: ['row'],
|
|
34
35
|
};
|
|
35
36
|
|
|
36
|
-
const IMPLICIT_CONTAINER = { ul: 'list', ol: 'list', menu: 'list', table: 'table', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup', tr: 'row', figure: 'figure' };
|
|
37
37
|
|
|
38
38
|
export default {
|
|
39
39
|
id: 'aria-required-parent',
|
|
@@ -42,10 +42,11 @@ export default {
|
|
|
42
42
|
tags: ['wcag2a', 'wcag131'],
|
|
43
43
|
help: 'ARIA child roles must be inside their required container role',
|
|
44
44
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
45
|
-
selector:
|
|
45
|
+
selector: '[role]',
|
|
46
46
|
evaluate(element) {
|
|
47
|
-
const role = element
|
|
47
|
+
const role = effectiveRole(element);
|
|
48
48
|
const containers = REQUIRED_PARENT[role];
|
|
49
|
+
if (!containers) return { status: 'pass' };
|
|
49
50
|
// The container named in the message and fix: the structure's root, not
|
|
50
51
|
// the group that may sit between it and the item.
|
|
51
52
|
const named = containers.filter((container) => container !== 'group');
|
|
@@ -54,9 +55,7 @@ export default {
|
|
|
54
55
|
// Judging by DOM ancestry alone failed every remotely-owned child.
|
|
55
56
|
if (element.id) {
|
|
56
57
|
const owner = element.getRootNode().querySelector?.(`[aria-owns~="${CSS.escape(element.id)}"]`);
|
|
57
|
-
const ownerRole = owner && (
|
|
58
|
-
owner.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase()
|
|
59
|
-
?? IMPLICIT_CONTAINER[owner.tagName.toLowerCase()]);
|
|
58
|
+
const ownerRole = owner && effectiveRole(owner);
|
|
60
59
|
if (ownerRole && containers.includes(ownerRole)) return { status: 'pass' };
|
|
61
60
|
}
|
|
62
61
|
// FLAT-TREE ancestry, not light-DOM ancestry: assistive technology sees
|
|
@@ -72,9 +71,7 @@ export default {
|
|
|
72
71
|
for (let parent = flatParent(element); parent; parent = flatParent(parent)) {
|
|
73
72
|
// A <slot> is rendering plumbing with no tree presence of its own.
|
|
74
73
|
if (parent.tagName === 'SLOT' && !parent.hasAttribute('role')) continue;
|
|
75
|
-
const parentRole =
|
|
76
|
-
parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
77
|
-
IMPLICIT_CONTAINER[parent.tagName.toLowerCase()];
|
|
74
|
+
const parentRole = effectiveRole(parent);
|
|
78
75
|
// A permitted context (the structure's root, or a group for the roles
|
|
79
76
|
// whose ARIA context lists it) satisfies the requirement outright.
|
|
80
77
|
if (containers.includes(parentRole)) return { status: 'pass' };
|
|
@@ -83,7 +80,7 @@ export default {
|
|
|
83
80
|
// children owned by the next real ancestor — li[role=presentation]
|
|
84
81
|
// between a tablist and its tabs is the canonical pattern.
|
|
85
82
|
if (parentRole === 'presentation' || parentRole === 'none') continue;
|
|
86
|
-
if (parentRole) break; // a different role interrupts the required structure
|
|
83
|
+
if (parentRole && parentRole !== 'generic') break; // a different role interrupts the required structure
|
|
87
84
|
}
|
|
88
85
|
return {
|
|
89
86
|
status: 'fail',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
|
|
3
3
|
// 3.3.2 mapping: a field with no label at all, or only a vanishing
|
|
4
4
|
// placeholder, is failure F82 — the classic 3.3.2 pattern.
|
|
5
|
-
import { labelledByName } from '../../lib/accessible-name.js';
|
|
5
|
+
import { labelledByName, nativeLabelName } from '../../lib/accessible-name.js';
|
|
6
6
|
import { effectiveRole } from '../../lib/roles.js';
|
|
7
7
|
|
|
8
8
|
export default {
|
|
@@ -29,34 +29,10 @@ export default {
|
|
|
29
29
|
const style = getComputedStyle(label);
|
|
30
30
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
31
31
|
});
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
// empty name), while aria-label on the label itself or on things inside
|
|
37
|
-
// it (icon-only labels) and image alts DO feed the name per accname.
|
|
38
|
-
//
|
|
39
|
-
// An UNRENDERED descendant is excluded the same way: accname step 2A
|
|
40
|
-
// returns the empty string for a hidden node that is not the root of
|
|
41
|
-
// the label traversal, and hidden covers display:none and
|
|
42
|
-
// visibility:hidden (the hidden attribute is display:none). A label
|
|
43
|
-
// whose only text sits in a display:none span names nothing (Chromium
|
|
44
|
-
// computes ""), the mirror of the aria-hidden twin above (2026-08-25
|
|
45
|
-
// overnight audit). Computed style rather than checkVisibility: an SVG
|
|
46
|
-
// <title> has no box yet still names its graphic.
|
|
47
|
-
const accessibleText = (node) => {
|
|
48
|
-
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
|
49
|
-
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
50
|
-
if (node.getAttribute('aria-hidden') === 'true') return '';
|
|
51
|
-
const nodeStyle = getComputedStyle(node);
|
|
52
|
-
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return '';
|
|
53
|
-
const aria = node.getAttribute('aria-label');
|
|
54
|
-
if (aria?.trim()) return aria;
|
|
55
|
-
const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
|
|
56
|
-
if (alt?.trim()) return alt;
|
|
57
|
-
return [...node.childNodes].map(accessibleText).join(' ');
|
|
58
|
-
};
|
|
59
|
-
const labelsText = visibleLabels.map(accessibleText).join(' ').trim();
|
|
32
|
+
// Share the recursive native-label computation with other name rules,
|
|
33
|
+
// including generated text and image alternatives. Exclude this field
|
|
34
|
+
// from its wrapping label's traversal to avoid self-naming cycles.
|
|
35
|
+
const labelsText = visibleLabels.map((label) => nativeLabelName(element, label)).join(' ').trim();
|
|
60
36
|
const ariaLabel = element.getAttribute('aria-label')?.trim();
|
|
61
37
|
const labelledby = labelledByName(element);
|
|
62
38
|
if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
2
|
import { NEVER_RENDERED } from '../../lib/dom.js';
|
|
3
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
3
4
|
|
|
4
5
|
const ITEM_ROLES = ['listitem', 'presentation', 'none'];
|
|
5
6
|
|
|
@@ -14,7 +15,7 @@ export default {
|
|
|
14
15
|
evaluate(element, { isRendered }) {
|
|
15
16
|
// A role attribute replaces the HTML list semantics — a <ul role="tablist">
|
|
16
17
|
// is judged by ARIA structure rules, not HTML list rules.
|
|
17
|
-
if (
|
|
18
|
+
if (effectiveRole(element) !== 'list') return { status: 'pass' };
|
|
18
19
|
// Flat-tree children: a <slot> renders as its assigned elements (or its
|
|
19
20
|
// fallback content), so a shadow <ul><slot></slot></ul> receiving <li>s
|
|
20
21
|
// is a perfectly valid list.
|
|
@@ -39,7 +40,7 @@ export default {
|
|
|
39
40
|
// listitems, so the list announces broken/empty. Only a role-less
|
|
40
41
|
// <li> (or an explicit listitem/presentation/none anywhere) keeps
|
|
41
42
|
// the structure intact.
|
|
42
|
-
const role = child
|
|
43
|
+
const role = effectiveRole(child);
|
|
43
44
|
if (child.tagName === 'LI') return !role || ITEM_ROLES.includes(role);
|
|
44
45
|
if (ITEM_ROLES.includes(role)) return true;
|
|
45
46
|
const genericWrapper = !child.hasAttribute('role')
|
|
@@ -62,7 +63,7 @@ export default {
|
|
|
62
63
|
const renderedItems = effectiveChildren(element).filter((child) =>
|
|
63
64
|
child.tagName === 'LI' && (!isRendered || isRendered(child)));
|
|
64
65
|
const neutralised = renderedItems.filter((child) =>
|
|
65
|
-
['presentation', 'none'].includes(child
|
|
66
|
+
['presentation', 'none'].includes(effectiveRole(child)));
|
|
66
67
|
if (renderedItems.length && neutralised.length === renderedItems.length) {
|
|
67
68
|
return {
|
|
68
69
|
status: 'fail',
|
|
@@ -65,7 +65,7 @@ export default {
|
|
|
65
65
|
const headers = cell.getAttribute('headers');
|
|
66
66
|
return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
|
|
67
67
|
const target = element.getRootNode().getElementById?.(id);
|
|
68
|
-
return !target || !cells.includes(target) || !
|
|
68
|
+
return !target || !cells.includes(target) || !isHeaderCell(target);
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
if (badRefs.length) {
|
|
@@ -84,13 +84,34 @@ export default {
|
|
|
84
84
|
message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
// A grid whose every filled cell is nothing but a link or a button is a
|
|
88
|
+
// navigation block laid out as a table, not data with labels: asked,
|
|
89
|
+
// not asserted (2026-09-12).
|
|
90
|
+
const filled = cells.filter((cell) => cell.textContent.trim());
|
|
91
|
+
const navigational = filled.every((cell) => {
|
|
92
|
+
const controls = [...cell.querySelectorAll('a, button')];
|
|
93
|
+
return controls.length
|
|
94
|
+
&& controls.map((control) => control.textContent.trim()).join(' ').replace(/\s+/g, ' ')
|
|
95
|
+
=== cell.textContent.trim().replace(/\s+/g, ' ');
|
|
96
|
+
});
|
|
97
|
+
if (navigational) {
|
|
98
|
+
return {
|
|
99
|
+
status: 'incomplete',
|
|
100
|
+
message: 'This table has no header cells and every cell holds only a link or a button, which is the shape of a navigation block laid out as a table. If it presents data, mark its header cells with <th>; if it is layout, add role="presentation".',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Everything that remains is a grid of at least four short text cells
|
|
104
|
+
// with no nested table, no block content and no layout role: a data
|
|
105
|
+
// table by every signal the markup gives, which is the determination
|
|
106
|
+
// F91 asks for before it applies. Asserted, as the shipped engine
|
|
107
|
+
// asserted it; the message says what to do if it is layout after all.
|
|
87
108
|
// A two-column table is a list of label-and-value pairs; its first
|
|
88
109
|
// column is the row headers, and that is the whole fix (gov.uk
|
|
89
110
|
// inheritance tax guidance, 2026-09-01: five such tables, all td).
|
|
90
111
|
const twoColumns = Math.max(0, ...rows.map((row) => row.cells.length)) === 2;
|
|
91
112
|
return {
|
|
92
113
|
status: 'fail',
|
|
93
|
-
message: 'This looks like a data table but has no header cells
|
|
114
|
+
message: 'This looks like a data table but has no header cells, so screen reader users get the data with no way to tell what each row or column means. If it only arranges content, mark it role="presentation" and dismiss this finding.',
|
|
94
115
|
fix: twoColumns
|
|
95
116
|
? 'Mark the first cell of each row as <th scope="row">: in a two-column table the first column names what the second holds.'
|
|
96
117
|
: 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
|