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.
Files changed (50) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +4 -2
  4. package/engine/README.md +9 -4
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +81 -64
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +18 -3
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/region.js +10 -2
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  17. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  18. package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
  19. package/engine/rules/wcag/1.3.1-form-label.js +12 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
  25. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  26. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  27. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  28. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  29. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  30. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  31. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  32. package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
  33. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  34. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  35. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  36. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  37. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  38. package/engine/rules/wcag/2.5.8-target-size.js +201 -47
  39. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  40. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  41. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  42. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  43. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  44. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  45. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  46. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  47. package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
  48. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  49. package/engine/wcag22.js +7 -7
  50. 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
- const roots = [context];
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
- return isRendered(element);
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 uniqueId = (el) => el.id && root.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1;
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
- // Sibling walk without materializing children arrays — this runs for
69
- // every reported element at every ancestor level.
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.parentElement ?? node.getRootNode?.()?.host ?? null) {
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;
@@ -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
- if (element.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"]')) return true;
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
- return explicit.length ? null : implicitRole(element); // unknown explicit role: don't judge
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
- const key = `${element.type}::${element.form?.id ?? ''}::${element.name}`;
18
- (groups[key] ??= []).push(index);
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 Object.values(groups)) {
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"], [role="search"], [role="form"], [role="main"], main',
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"], [role="region"], ' +
18
- '[role="search"], [role="form"], [role="dialog"], [role="alertdialog"], dialog[open]';
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.getAttribute('role');
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[role="img"], svg[role="graphics-document"], svg[role="graphics-symbol"], [role="img"]:not(img):not(svg), svg:not([role])',
15
+ selector: 'svg, [role]',
15
16
  evaluate(element, { accessibleName }) {
16
- // A non-svg role="img" host names from author only, and the role makes
17
- // its subtree presentational (ARIA: children presentational) — so a
18
- // descendant <svg><title> or text content never reaches AT as a name,
19
- // even though a generic contents walk would find it.
20
- if (element.tagName.toLowerCase() !== 'svg' && element.getAttribute('role') === 'img') {
21
- const authorName = labelledByName(element)
22
- || element.getAttribute('aria-label')?.trim()
23
- || element.getAttribute('title')?.trim();
24
- if (authorName) return { status: 'pass' };
25
- return {
26
- status: 'fail',
27
- message: 'This element is marked as an image but has no accessible name — for role="img", content inside it (including an inner SVG’s <title>) does not count as a name.',
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 { implicitRole } from '../../lib/roles.js';
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 = (element) =>
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: Object.keys(REQUIRED_CHILDREN).map((role) => `[role="${role}"]`).join(', '),
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.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
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: Object.keys(REQUIRED_PARENT).map((role) => `[role="${role}"]`).join(', '),
45
+ selector: '[role]',
46
46
  evaluate(element) {
47
- const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
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) && !NEVER_RENDERED.has(child.tagName) && (!isRendered || isRendered(child)));
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
- // A label's contribution is its ACCESSIBLE text, not its raw textContent:
26
- // aria-hidden subtrees inside the label are excluded from the name
27
- // computation (a custom radio whose only visible text sits in an
28
- // aria-hidden styled twin names NOTHING — the browser's tree computes an
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 (element.hasAttribute('role') && element.getAttribute('role') !== 'list') return { status: 'pass' };
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.getAttribute('role') ?? '';
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.getAttribute('role') ?? ''));
66
+ ['presentation', 'none'].includes(effectiveRole(child)));
66
67
  if (renderedItems.length && neutralised.length === renderedItems.length) {
67
68
  return {
68
69
  status: 'fail',