pour-engine 1.40.0 → 1.42.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 +17 -15
- package/engine/README.md +10 -5
- 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/best-practice/region.js +1 -1
- package/engine/rules/index.js +29 -0
- 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.2.3-video-audio-description.js +42 -0
- 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.3.6-region-purpose.js +35 -0
- 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/1.4.8-text-justified.js +49 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +10 -4
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
- package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
- 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.6-heading-label-placeholder.js +69 -0
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
- package/engine/rules/wcag/2.5.8-target-size.js +161 -45
- package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +10 -13
- package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
- 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 +19 -19
- 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()
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// showing one (the open attribute is set by show() and showModal()) counts,
|
|
10
10
|
// so a closed dialog cannot make a landmark-less page look landmarked
|
|
11
11
|
// (2026-08-25 overnight audit).
|
|
12
|
-
const LANDMARK =
|
|
12
|
+
export const LANDMARK =
|
|
13
13
|
'main, nav, aside, search, form[aria-label], form[aria-labelledby], ' +
|
|
14
14
|
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
15
15
|
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
package/engine/rules/index.js
CHANGED
|
@@ -109,6 +109,25 @@ import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
|
|
|
109
109
|
import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
|
|
110
110
|
import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
|
|
111
111
|
import summaryName from './wcag/4.1.2-summary-name.js';
|
|
112
|
+
// 2026-09-12 manual-to-partial batch (reports/coverage-gaps/2026-09-12): the
|
|
113
|
+
// slices of twelve criteria that had no rule where the DOM shows a real
|
|
114
|
+
// candidate. Two AAA siblings built from existing factories (a timed refresh
|
|
115
|
+
// with no 20-hour allowance, any overlap of a focused element); eight
|
|
116
|
+
// review questions on declared facts (a video without a descriptions
|
|
117
|
+
// track, a declared <section> with no heading, a touch-action surface, a
|
|
118
|
+
// payment form, a CAPTCHA beside a credential, a page with no landmarks,
|
|
119
|
+
// justified running text) and one assert on template text left in a
|
|
120
|
+
// heading or label. Two assert, the rest ask.
|
|
121
|
+
import metaRefreshNoExceptions from './wcag/2.2.4-meta-refresh-no-exceptions.js';
|
|
122
|
+
import focusNotObscuredEnhanced from './wcag/2.4.12-focus-not-obscured-enhanced.js';
|
|
123
|
+
import videoAudioDescription from './wcag/1.2.3-video-audio-description.js';
|
|
124
|
+
import headingLabelPlaceholder from './wcag/2.4.6-heading-label-placeholder.js';
|
|
125
|
+
import textJustified from './wcag/1.4.8-text-justified.js';
|
|
126
|
+
import sectionHeading from './wcag/2.4.10-section-heading.js';
|
|
127
|
+
import captchaAlternative from './wcag/3.3.9-captcha-alternative.js';
|
|
128
|
+
import regionPurpose from './wcag/1.3.6-region-purpose.js';
|
|
129
|
+
import financialFormConfirmation from './wcag/3.3.4-financial-form-confirmation.js';
|
|
130
|
+
import pointerGestureAlternative from './wcag/2.5.1-pointer-gesture-alternative.js';
|
|
112
131
|
|
|
113
132
|
// --- Best practice ---
|
|
114
133
|
import headingOrder from './best-practice/heading-order.js';
|
|
@@ -210,6 +229,16 @@ export default [
|
|
|
210
229
|
errorMessageLinkage,
|
|
211
230
|
compositeWidgetName,
|
|
212
231
|
summaryName,
|
|
232
|
+
metaRefreshNoExceptions,
|
|
233
|
+
focusNotObscuredEnhanced,
|
|
234
|
+
videoAudioDescription,
|
|
235
|
+
headingLabelPlaceholder,
|
|
236
|
+
textJustified,
|
|
237
|
+
sectionHeading,
|
|
238
|
+
captchaAlternative,
|
|
239
|
+
regionPurpose,
|
|
240
|
+
financialFormConfirmation,
|
|
241
|
+
pointerGestureAlternative,
|
|
213
242
|
contrastEnhanced,
|
|
214
243
|
targetSizeEnhanced,
|
|
215
244
|
headingOrder,
|
|
@@ -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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// WCAG SC 1.2.3 Audio Description or Media Alternative (Prerecorded)
|
|
2
|
+
// (Level A) and SC 1.2.5 Audio Description (Prerecorded) (Level AA)
|
|
3
|
+
//
|
|
4
|
+
// Prerecorded video in synchronized media needs audio description of the
|
|
5
|
+
// visual track (1.2.5), or at Level A alternatively a full text alternative
|
|
6
|
+
// for the media (1.2.3). Whether either exists is not decidable from the
|
|
7
|
+
// DOM: description can be a second audio track chosen in the player, a
|
|
8
|
+
// separately produced version, narration mixed into the soundtrack, or a
|
|
9
|
+
// transcript beside the player. And the Understanding of 1.2.5 says no
|
|
10
|
+
// description is needed when the soundtrack already carries every
|
|
11
|
+
// important thing the picture shows (a talking head). So this rule never
|
|
12
|
+
// asserts. It asks, once per video that can actually present sound to
|
|
13
|
+
// someone, and stays silent where the page shows the one thing the DOM can
|
|
14
|
+
// show: a <track kind="descriptions"> (technique H96).
|
|
15
|
+
//
|
|
16
|
+
// Same media gating as media-captions, for the same reasons: no resource
|
|
17
|
+
// means nothing is presented, and muted-with-no-controls is the decorative
|
|
18
|
+
// loop nobody can hear, which is not synchronized media for a viewer.
|
|
19
|
+
// Video-only files (no audio stream at all) are 1.2.1 territory and not
|
|
20
|
+
// visible from the DOM either; the message says so.
|
|
21
|
+
export default {
|
|
22
|
+
id: 'video-audio-description',
|
|
23
|
+
name: 'Video audio description',
|
|
24
|
+
impact: 'serious',
|
|
25
|
+
tags: ['wcag2a', 'wcag123', 'wcag125'],
|
|
26
|
+
help: 'Video content needs audio description or a text alternative',
|
|
27
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/audio-description-prerecorded.html',
|
|
28
|
+
selector: 'video',
|
|
29
|
+
visibleOnly: false,
|
|
30
|
+
evaluate(element) {
|
|
31
|
+
if (element.querySelector('track[kind="descriptions" i]')) return { status: 'pass' };
|
|
32
|
+
if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
|
|
33
|
+
return { status: 'pass' };
|
|
34
|
+
}
|
|
35
|
+
if (element.muted && !element.controls) return { status: 'pass' };
|
|
36
|
+
return {
|
|
37
|
+
status: 'incomplete',
|
|
38
|
+
message: 'This video has no audio-description track. If it shows something its soundtrack does not say (text on screen, actions, charts, who is speaking), blind and low-vision users miss it. 1.2.5 asks for audio description; at Level A, 1.2.3 is also met by a full text alternative describing the visuals, linked near the player. A talking-head video whose soundtrack already carries everything needs neither, and a video with no soundtrack at all is 1.2.1\'s question instead: check which this is, and where the description or alternative lives.',
|
|
39
|
+
fix: 'Provide a described audio track or a described version of the video (a <track kind="descriptions"> file works for text-based description), or publish a full transcript that describes the visuals next to the player.',
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -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).',
|