pour-engine 1.38.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 +81 -64
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +18 -3
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/region.js +10 -2
- 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-definition-list.js +42 -3
- package/engine/rules/wcag/1.3.1-form-label.js +12 -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 +38 -8
- 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 +39 -3
- 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 +201 -47
- 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-nested-interactive.js +20 -8
- 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 —
|
|
@@ -208,22 +209,36 @@ export function implicitRole(element) {
|
|
|
208
209
|
* but adding tabindex or aria-describedby exposes `heading`. Note a non-global
|
|
209
210
|
* property applicable to the implicit role (aria-level on a heading) does NOT
|
|
210
211
|
* trigger it, even though a strict reading of the spec suggests it should.
|
|
212
|
+
*
|
|
213
|
+
* "Focusable" means focusable in fact, not focusable by tag. A disabled
|
|
214
|
+
* control and anything inside an inert subtree are not focusable areas, so
|
|
215
|
+
* their presentational role STANDS and the browser keeps them out of the
|
|
216
|
+
* accessibility tree: Chromium exposes `<button role="none" disabled>` as
|
|
217
|
+
* role none, with no name to owe. Reading focusability from the tag alone
|
|
218
|
+
* (or from tabIndex, which Chromium still reports as 0 on a disabled
|
|
219
|
+
* button) resurrected those elements and asserted a missing name on a
|
|
220
|
+
* control the user is never offered.
|
|
211
221
|
*/
|
|
212
222
|
function presentationDiscarded(element) {
|
|
223
|
+
// A global property is exposed whatever the element's state, so it
|
|
224
|
+
// discards the role on its own, disabled or not.
|
|
225
|
+
if ([...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`))) return true;
|
|
226
|
+
if (element.matches(':disabled') || isInert(element)) return false;
|
|
213
227
|
if (element.tabIndex >= 0) return true;
|
|
214
|
-
|
|
215
|
-
return [...GLOBAL_ARIA].some((name) => element.hasAttribute(`aria-${name}`));
|
|
228
|
+
return element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]');
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
export function effectiveRole(element) {
|
|
219
232
|
const explicit = element.getAttribute('role')?.trim().split(/\s+/) ?? [];
|
|
220
233
|
for (const token of explicit) {
|
|
221
234
|
const role = token.toLowerCase();
|
|
235
|
+
if (role === 'image') return 'img'; // supported synonym for the image role
|
|
222
236
|
if (!ROLE_ARIA[role]) continue;
|
|
223
237
|
if ((role === 'presentation' || role === 'none') && presentationDiscarded(element)) {
|
|
224
238
|
return implicitRole(element);
|
|
225
239
|
}
|
|
226
240
|
return role;
|
|
227
241
|
}
|
|
228
|
-
|
|
242
|
+
// ARIA 9.1: unrecognized tokens leave native semantics intact.
|
|
243
|
+
return implicitRole(element);
|
|
229
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()
|
|
@@ -34,10 +34,18 @@ export default {
|
|
|
34
34
|
helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
|
|
35
35
|
// Implicit landmarks (header, footer, labelled section/form) join the
|
|
36
36
|
// grouping — a page's <header> and a div[role="banner"] ARE duplicates.
|
|
37
|
+
// region and form need a name to BE landmarks (ARIA 1.2 gives both "Name
|
|
38
|
+
// From: author", and Chromium exposes an unnamed one as generic), which is
|
|
39
|
+
// why the native <section> and <form> above are already spelled with the
|
|
40
|
+
// name requirement. Without it, two unnamed div[role="region"] were
|
|
41
|
+
// reported as indistinguishable landmarks when neither is a landmark at
|
|
42
|
+
// all, sending the author to label something no screen reader announces.
|
|
37
43
|
selector:
|
|
38
44
|
'nav, search, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
|
|
39
45
|
'form[aria-label], form[aria-labelledby], [role="navigation"], [role="complementary"], ' +
|
|
40
|
-
'[role="banner"], [role="contentinfo"], [role="region"]
|
|
46
|
+
'[role="banner"], [role="contentinfo"], [role="region"][aria-label], ' +
|
|
47
|
+
'[role="region"][aria-labelledby], [role="form"][aria-label], [role="form"][aria-labelledby], ' +
|
|
48
|
+
'[role="search"], [role="main"], main',
|
|
41
49
|
// Judged as a set: two unlabelled navs are the problem, not either alone.
|
|
42
50
|
evaluateAll(elements) {
|
|
43
51
|
const roles = elements.map(landmarkRole);
|
|
@@ -14,8 +14,16 @@ const LANDMARK =
|
|
|
14
14
|
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
15
15
|
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
|
16
16
|
'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
|
|
17
|
-
'[role="banner"], [role="contentinfo"], [role="complementary"],
|
|
18
|
-
|
|
17
|
+
'[role="banner"], [role="contentinfo"], [role="complementary"], ' +
|
|
18
|
+
// region and form are exposed as landmarks only once they are named (ARIA
|
|
19
|
+
// 1.2 gives both "Name From: author"), which is why the native <section>
|
|
20
|
+
// and <form> above already carry that requirement. Accepting the ARIA
|
|
21
|
+
// spelling bare let a nameless div[role="region"] stand in for a landmark
|
|
22
|
+
// and hid every stray element inside it; Chromium exposes that div as
|
|
23
|
+
// generic, so screen-reader users reach none of it by region navigation.
|
|
24
|
+
'[role="region"][aria-label], [role="region"][aria-labelledby], ' +
|
|
25
|
+
'[role="form"][aria-label], [role="form"][aria-labelledby], ' +
|
|
26
|
+
'[role="search"], [role="dialog"], [role="alertdialog"], dialog[open]';
|
|
19
27
|
|
|
20
28
|
export default {
|
|
21
29
|
id: 'region',
|
|
@@ -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',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
-
import { NEVER_RENDERED } from '../../lib/dom.js';
|
|
2
|
+
import { NEVER_RENDERED, isInert } from '../../lib/dom.js';
|
|
3
3
|
|
|
4
4
|
const ALLOWED = new Set(['DT', 'DD', 'DIV']);
|
|
5
5
|
// NEVER_RENDERED cannot corrupt the term/description pairing: HTML's content
|
|
@@ -17,11 +17,14 @@ export default {
|
|
|
17
17
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
18
18
|
selector: 'dl:not([role])', // a role attribute replaces the dl semantics
|
|
19
19
|
evaluate(element, { isRendered }) {
|
|
20
|
+
const exposed = (child) => !NEVER_RENDERED.has(child.tagName)
|
|
21
|
+
&& !child.closest('[aria-hidden="true"]') && !isInert(child)
|
|
22
|
+
&& (!isRendered || isRendered(child) || getComputedStyle(child).display === 'contents');
|
|
20
23
|
// A child the browser never renders (display:none, hidden) is not in
|
|
21
24
|
// the accessibility tree either, so it cannot break the pairing a
|
|
22
25
|
// reader hears; list-structure applies the same gate.
|
|
23
26
|
const invalid = [...element.children].filter((child) =>
|
|
24
|
-
!ALLOWED.has(child.tagName) &&
|
|
27
|
+
!ALLOWED.has(child.tagName) && exposed(child));
|
|
25
28
|
if (invalid.length) {
|
|
26
29
|
const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
|
|
27
30
|
return {
|
|
@@ -36,7 +39,7 @@ export default {
|
|
|
36
39
|
// out of the dl's content model, so the term/description association is
|
|
37
40
|
// no longer guaranteed to assistive technology.
|
|
38
41
|
const emptyWrappers = [...element.children].filter((child) =>
|
|
39
|
-
child.tagName === 'DIV' && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
|
|
42
|
+
child.tagName === 'DIV' && exposed(child) && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
|
|
40
43
|
if (emptyWrappers.length) {
|
|
41
44
|
return {
|
|
42
45
|
status: 'fail',
|
|
@@ -44,6 +47,42 @@ export default {
|
|
|
44
47
|
fix: 'Make each <div> child of the <dl> contain its <dt>/<dd> pair directly, or flatten the pairs into the <dl> itself.',
|
|
45
48
|
};
|
|
46
49
|
}
|
|
50
|
+
// HTML defines a group as one or more terms followed by one or more
|
|
51
|
+
// descriptions. Check each wrapper independently, so a term in one
|
|
52
|
+
// group cannot supply the missing term of another.
|
|
53
|
+
// https://html.spec.whatwg.org/multipage/grouping-content.html#the-dl-element
|
|
54
|
+
const children = [...element.children].filter(exposed);
|
|
55
|
+
const groups = [[...element.children].filter((child) => child.tagName !== 'DIV'),
|
|
56
|
+
...children.filter((child) => child.tagName === 'DIV').map((child) => [...child.children])];
|
|
57
|
+
for (const members of groups) {
|
|
58
|
+
const group = members.filter(exposed);
|
|
59
|
+
const concealedPairMember = members.some((child) => (child.tagName === 'DT' || child.tagName === 'DD') && !exposed(child));
|
|
60
|
+
const concealedResult = {
|
|
61
|
+
status: 'incomplete',
|
|
62
|
+
message: 'A term or description in this group is hidden from assistive technology. Check that any disclosure control exposes the associated content when opened, and that the term and description relationship remains available.',
|
|
63
|
+
};
|
|
64
|
+
let last = null;
|
|
65
|
+
for (const child of group) {
|
|
66
|
+
if (child.tagName !== 'DT' && child.tagName !== 'DD') continue;
|
|
67
|
+
if (child.tagName === 'DD' && last === null) {
|
|
68
|
+
if (concealedPairMember) return concealedResult;
|
|
69
|
+
return {
|
|
70
|
+
status: 'fail',
|
|
71
|
+
message: 'This description list has a description without a preceding term in its group.',
|
|
72
|
+
fix: 'Add a <dt> before the <dd>, or use a paragraph when the content is not a term and description.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
last = child.tagName;
|
|
76
|
+
}
|
|
77
|
+
if (last === 'DT') {
|
|
78
|
+
if (concealedPairMember) return concealedResult;
|
|
79
|
+
return {
|
|
80
|
+
status: 'fail',
|
|
81
|
+
message: 'This description list ends a group with a term that has no description.',
|
|
82
|
+
fix: 'Follow the <dt> element or elements with at least one <dd> in the same group.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
47
86
|
return { status: 'pass' };
|
|
48
87
|
},
|
|
49
88
|
};
|
|
@@ -2,7 +2,8 @@
|
|
|
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
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
6
7
|
|
|
7
8
|
export default {
|
|
8
9
|
id: 'form-label',
|
|
@@ -14,6 +15,12 @@ export default {
|
|
|
14
15
|
selector:
|
|
15
16
|
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]), select, textarea',
|
|
16
17
|
evaluate(element) {
|
|
18
|
+
// A field whose presentational role survives the conflict resolution is
|
|
19
|
+
// not in the accessibility tree at all: <select role="none" disabled> is
|
|
20
|
+
// exposed as role none, so there is no field to name under 4.1.2 and
|
|
21
|
+
// nothing to fill in under 3.3.2. The role library owns that judgment.
|
|
22
|
+
const role = effectiveRole(element);
|
|
23
|
+
if (role === 'presentation' || role === 'none') return { status: 'pass' };
|
|
17
24
|
// Only perceivable labels count: a display:none / aria-hidden label
|
|
18
25
|
// technically feeds the accessible name, but nobody can see it and
|
|
19
26
|
// that defeats the point of a label.
|
|
@@ -22,34 +29,10 @@ export default {
|
|
|
22
29
|
const style = getComputedStyle(label);
|
|
23
30
|
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
24
31
|
});
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
// empty name), while aria-label on the label itself or on things inside
|
|
30
|
-
// it (icon-only labels) and image alts DO feed the name per accname.
|
|
31
|
-
//
|
|
32
|
-
// An UNRENDERED descendant is excluded the same way: accname step 2A
|
|
33
|
-
// returns the empty string for a hidden node that is not the root of
|
|
34
|
-
// the label traversal, and hidden covers display:none and
|
|
35
|
-
// visibility:hidden (the hidden attribute is display:none). A label
|
|
36
|
-
// whose only text sits in a display:none span names nothing (Chromium
|
|
37
|
-
// computes ""), the mirror of the aria-hidden twin above (2026-08-25
|
|
38
|
-
// overnight audit). Computed style rather than checkVisibility: an SVG
|
|
39
|
-
// <title> has no box yet still names its graphic.
|
|
40
|
-
const accessibleText = (node) => {
|
|
41
|
-
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
|
42
|
-
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
43
|
-
if (node.getAttribute('aria-hidden') === 'true') return '';
|
|
44
|
-
const nodeStyle = getComputedStyle(node);
|
|
45
|
-
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return '';
|
|
46
|
-
const aria = node.getAttribute('aria-label');
|
|
47
|
-
if (aria?.trim()) return aria;
|
|
48
|
-
const alt = (node.tagName === 'IMG' || node.tagName === 'AREA') ? node.getAttribute('alt') : null;
|
|
49
|
-
if (alt?.trim()) return alt;
|
|
50
|
-
return [...node.childNodes].map(accessibleText).join(' ');
|
|
51
|
-
};
|
|
52
|
-
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();
|
|
53
36
|
const ariaLabel = element.getAttribute('aria-label')?.trim();
|
|
54
37
|
const labelledby = labelledByName(element);
|
|
55
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',
|