@surea11y/core 1.3.0 → 1.4.1

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 (166) hide show
  1. package/CHANGELOG.md +87 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/CI_INTEGRATIONS.md +7 -7
  7. package/docs/ENGINE_OPTIONS.md +1 -1
  8. package/docs/I18N.md +12 -9
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +1 -1
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_CATALOG.md +6 -6
  13. package/package.json +52 -16
  14. package/src/baseline.js +0 -0
  15. package/src/checks/automatic/area-alt-present.js +4 -6
  16. package/src/checks/automatic/aria-allowed-attr.js +663 -134
  17. package/src/checks/automatic/aria-allowed-role.js +2 -0
  18. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  19. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  20. package/src/checks/automatic/aria-deprecated-role.js +107 -38
  21. package/src/checks/automatic/aria-hidden-body.js +6 -4
  22. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  23. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  24. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  25. package/src/checks/automatic/aria-required-attr.js +6 -7
  26. package/src/checks/automatic/aria-required-children.js +7 -10
  27. package/src/checks/automatic/aria-required-parent.js +20 -25
  28. package/src/checks/automatic/aria-role-name-present.js +2 -0
  29. package/src/checks/automatic/aria-roles-valid.js +33 -6
  30. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  31. package/src/checks/automatic/aria-valid-attr.js +2 -0
  32. package/src/checks/automatic/autocomplete-valid.js +39 -1
  33. package/src/checks/automatic/avoid-inline-spacing.js +181 -20
  34. package/src/checks/automatic/binary-control-name-present.js +13 -3
  35. package/src/checks/automatic/button-name-present.js +54 -21
  36. package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
  37. package/src/checks/automatic/combobox-name-present.js +10 -1
  38. package/src/checks/automatic/contrast-computable.js +2 -0
  39. package/src/checks/automatic/contrast-enhanced.js +2 -0
  40. package/src/checks/automatic/contrast-minimum.js +2 -0
  41. package/src/checks/automatic/css-orientation-lock.js +21 -27
  42. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  43. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  44. package/src/checks/automatic/dialog-name-present.js +18 -11
  45. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  46. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  47. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  48. package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
  49. package/src/checks/automatic/form-control-single-label.js +110 -43
  50. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  51. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  52. package/src/checks/automatic/iframe-name-present.js +2 -0
  53. package/src/checks/automatic/iframe-title-unique.js +3 -1
  54. package/src/checks/automatic/img-alt-present.js +23 -18
  55. package/src/checks/automatic/input-image-alt-present.js +101 -52
  56. package/src/checks/automatic/label-in-name.js +97 -27
  57. package/src/checks/automatic/language-page-present.js +7 -1
  58. package/src/checks/automatic/link-in-text-block.js +2 -0
  59. package/src/checks/automatic/link-name-present.js +52 -17
  60. package/src/checks/automatic/list-children-valid.js +14 -24
  61. package/src/checks/automatic/listbox-name-present.js +10 -1
  62. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  63. package/src/checks/automatic/menuitem-name-present.js +10 -1
  64. package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
  65. package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
  66. package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
  67. package/src/checks/automatic/meter-name-present.js +12 -4
  68. package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
  69. package/src/checks/automatic/object-text-alternative-present.js +16 -7
  70. package/src/checks/automatic/option-name-present.js +10 -1
  71. package/src/checks/automatic/page-title-present.js +2 -0
  72. package/src/checks/automatic/progressbar-name-present.js +16 -11
  73. package/src/checks/automatic/role-img-alt-present.js +4 -4
  74. package/src/checks/automatic/searchbox-name-present.js +10 -1
  75. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  76. package/src/checks/automatic/slider-name-present.js +13 -2
  77. package/src/checks/automatic/spinbutton-name-present.js +10 -1
  78. package/src/checks/automatic/summary-name-present.js +10 -1
  79. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  80. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  81. package/src/checks/automatic/tab-name-present.js +10 -1
  82. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  83. package/src/checks/automatic/table-th-has-data-cells.js +69 -6
  84. package/src/checks/automatic/target-size-minimum.js +5 -0
  85. package/src/checks/automatic/td-has-header.js +24 -1
  86. package/src/checks/automatic/textbox-name-present.js +10 -1
  87. package/src/checks/automatic/tooltip-name-present.js +10 -1
  88. package/src/checks/automatic/treeitem-name-present.js +10 -1
  89. package/src/checks/automatic/valid-lang.js +18 -3
  90. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  91. package/src/checks/manual/accesskeys-manual.js +3 -1
  92. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  93. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  94. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  95. package/src/checks/manual/aria-text-manual.js +6 -5
  96. package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
  97. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  98. package/src/checks/manual/css-hidden-focus.js +184 -9
  99. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  100. package/src/checks/manual/empty-heading-manual.js +17 -17
  101. package/src/checks/manual/empty-table-header-manual.js +52 -25
  102. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  103. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  104. package/src/checks/manual/heading-order-manual.js +28 -1
  105. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  106. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  107. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
  110. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  111. package/src/checks/manual/label-title-only-manual.js +29 -22
  112. package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
  113. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
  114. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  115. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  116. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  117. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  118. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  119. package/src/checks/manual/landmark-unique-manual.js +37 -52
  120. package/src/checks/manual/link-name-quality-manual.js +2 -0
  121. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  122. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  123. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  124. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  125. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  126. package/src/checks/manual/p-as-heading-manual.js +2 -0
  127. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  128. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  129. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  130. package/src/checks/manual/region-manual.js +27 -36
  131. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  132. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  133. package/src/checks/manual/skip-link-manual.js +7 -6
  134. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  135. package/src/checks/manual/tabindex-manual.js +3 -1
  136. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  137. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  138. package/src/checks/manual/video-caption-manual.js +2 -0
  139. package/src/checks/manual-review.js +2 -0
  140. package/src/core.js +11820 -3317
  141. package/src/index.js +2 -0
  142. package/src/report.js +51 -9
  143. package/src/sarif.js +20 -5
  144. package/surea11y.browser.js +4943 -1388
  145. package/bin/core.js +0 -473
  146. package/docs/CLI.md +0 -128
  147. package/src/catalogs/composites.wcag.js +0 -454
  148. package/src/checks/automatic/bypass-blocks-present.js +0 -215
  149. package/src/checks/rules-and-tags.full.csv +0 -19
  150. package/src/checks/rules-and-tags.full.json +0 -259
  151. package/src/core/aria-helpers.js +0 -1211
  152. package/src/core/contrast-helpers.js +0 -1302
  153. package/src/core/dom-helpers.js +0 -4493
  154. package/src/core/dom-runner.js +0 -787
  155. package/src/core/frame-messaging.js +0 -261
  156. package/src/core/frame-scan.js +0 -190
  157. package/src/core/rollup-composites.js +0 -127
  158. package/src/core/rule-meta.js +0 -176
  159. package/src/coverage/wcag-facets.js +0 -1079
  160. package/src/coverage/wcag-version-map.js +0 -84
  161. package/src/i18n/en.js +0 -1228
  162. package/src/i18n/fr.js +0 -1185
  163. package/src/policy/contracts.js +0 -18
  164. package/src/policy/resolvePolicy.js +0 -59
  165. package/src/policy/schemas/engine-options.schema.json +0 -103
  166. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -7,17 +9,19 @@
7
9
  * @standard WCAG 2.2
8
10
  * @sc 1.4.4
9
11
  * @applicability
10
- * Applies to <meta name="viewport"> elements that carry a non-empty
11
- * content attribute.
12
+ * Applies to <meta name="viewport"> elements whose content attribute sets
13
+ * maximum-scale or user-scalable. Content setting neither cannot restrict
14
+ * zoom.
12
15
  * @expectation
13
- * The content attribute does not set user-scalable to "no"/"0", and does
14
- * not set maximum-scale below 2 (200%). Either disables the user's
15
- * ability to zoom text to at least 200%, which WCAG 1.4.4 (Resize Text)
16
- * requires be possible.
16
+ * user-scalable is absent, yes, device-width, device-height, or a number
17
+ * outside the range -1 to 1; and maximum-scale is absent, device-width,
18
+ * device-height, negative, or 2 or more. Anything else stops the user
19
+ * zooming text to 200%, which WCAG 1.4.4 (Resize Text) requires.
17
20
  * @implementation-notes
18
- * - Malformed/unparseable maximum-scale values are ignored (not flagged) —
19
- * this rule only reports a clearly-detected zoom restriction, matching
20
- * this engine's no-false-positives policy.
21
+ * - An unparseable value counts as a restriction, because CSS Device
22
+ * Adaptation translates it to 0: maximum-scale=yes disables zoom exactly
23
+ * as maximum-scale=0 does. A negative maximum-scale is out of range and
24
+ * dropped by the browser, so it restricts nothing and passes.
21
25
  */
22
26
 
23
27
  const id = 'meta-viewport-zoom-enabled';
@@ -87,20 +91,40 @@ function runInPage(ctx) {
87
91
  const raw = String(el.getAttribute('content') || '').trim();
88
92
  if (!raw) continue;
89
93
 
90
- applicableCount += 1;
91
-
92
94
  const parsed = parseContent(raw);
95
+
96
+ // ACT b4f0c3 applies only when content carries maximum-scale or
97
+ // user-scalable; content that sets neither cannot restrict zoom.
98
+ if (parsed['user-scalable'] === undefined && parsed['maximum-scale'] === undefined) continue;
99
+
100
+ applicableCount += 1;
93
101
  const reasons = [];
94
102
 
103
+ // CSS Device Adaptation translates an unparseable value to 0, so
104
+ // user-scalable=invalid and maximum-scale=yes disable zoom just as
105
+ // user-scalable=no does. A negative maximum-scale is out of range and
106
+ // dropped instead, which is why it does not restrict anything.
95
107
  const userScalable = parsed['user-scalable'];
96
- if (userScalable === 'no' || userScalable === '0') {
97
- reasons.push('user-scalable=' + userScalable);
108
+ if (
109
+ userScalable !== undefined &&
110
+ userScalable !== 'yes' &&
111
+ userScalable !== 'device-width' &&
112
+ userScalable !== 'device-height'
113
+ ) {
114
+ const scale = parseFloat(userScalable);
115
+ if (Number.isNaN(scale) || (scale > -1 && scale < 1)) {
116
+ reasons.push('user-scalable=' + userScalable);
117
+ }
98
118
  }
99
119
 
100
120
  const maxScaleRaw = parsed['maximum-scale'];
101
- if (maxScaleRaw !== undefined) {
121
+ if (
122
+ maxScaleRaw !== undefined &&
123
+ maxScaleRaw !== 'device-width' &&
124
+ maxScaleRaw !== 'device-height'
125
+ ) {
102
126
  const maxScale = parseFloat(maxScaleRaw);
103
- if (!Number.isNaN(maxScale) && maxScale < 2) {
127
+ if (Number.isNaN(maxScale) || (maxScale >= 0 && maxScale < 2)) {
104
128
  reasons.push('maximum-scale=' + maxScaleRaw);
105
129
  }
106
130
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'meter-name-present';
@@ -65,9 +67,16 @@ function runInPage(ctx) {
65
67
  return '';
66
68
  }
67
69
 
70
+ // Naming rules apply only to elements included in the accessibility tree
71
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
72
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
68
73
  function isEligibleAcc(helpers, el, ctx) {
69
74
  const fn =
70
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
75
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
76
+ ? helpers.isIncludedInAccessibilityTree
77
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
78
+ ? helpers.isAccTreeEligible
79
+ : null;
71
80
  if (!fn) return true;
72
81
  try {
73
82
  const r = fn(el, ctx);
@@ -96,9 +105,8 @@ function runInPage(ctx) {
96
105
  const title = getAttr(el, 'title');
97
106
  if (title) return { ok: true, method: 'title' };
98
107
 
99
- // role="meter" is name-from-author-only per WAI-ARIA (verified against
100
- // a widely-used reference engine's own aria-meter-name check: any: ['aria-label',
101
- // 'aria-labelledby', title] — no content-based naming method at all).
108
+ // role="meter" is name-from-author-only per WAI-ARIA: aria-label,
109
+ // aria-labelledby, or title — no content-based naming method at all.
102
110
  // It must NOT fall back to subtree content — visible text near/inside a
103
111
  // custom meter widget is not reliably exposed as its accessible name.
104
112
  return { ok: false, method: 'none' };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -11,23 +13,48 @@
11
13
  * a[href], button, input (not hidden), select, textarea; or an explicit
12
14
  * ARIA widget role: button, link, checkbox, radio, switch, tab, textbox,
13
15
  * combobox, listbox, menuitem, menuitemcheckbox, menuitemradio, option,
14
- * slider, spinbutton, searchbox, treeitem).
16
+ * slider, spinbutton, searchbox, treeitem). The container is applicable
17
+ * regardless of whether it is itself focusable — focusability is only
18
+ * used to decide whether a *descendant* nests an interactive control.
15
19
  * @expectation
16
- * The element does not contain, as a descendant, another element from
17
- * that same interactive-control set (e.g. a <button> wrapping a
18
- * <select>, or a link containing a checkbox). Nested interactive
19
- * controls are not reliably announced or operable via assistive
20
- * technology — activating the outer control and the inner one become
21
- * ambiguous, and some AT only exposes one of the two.
20
+ * The element does not contain, as a descendant, another *operable*
21
+ * interactive control (e.g. a <button> wrapping a <select>, or a link
22
+ * containing a checkbox). Nested interactive controls are not reliably
23
+ * announced or operable via assistive technology — activating the outer
24
+ * control and the inner one become ambiguous, and some AT only exposes
25
+ * one of the two.
22
26
  * @implementation-notes
23
- * - Matches a widely-used reference engine's nested-interactive. Reports on the outer
24
- * (containing) control, not the nested descendant — this engine
25
- * considers the container the fixable unit ("move the nested control
26
- * outside this element").
27
- * - A container can be reported once even with multiple nested
28
- * descendants (listed together); a deeply nested chain (A > B > C, all
29
- * interactive) reports both A and B as separate occurrences, since each
30
- * genuinely contains a nested interactive control.
27
+ * - A descendant counts as a nested interactive control only when it is both
28
+ * (a) a native interactive element or an explicit ARIA widget role and
29
+ * (b) operable: exposed to the accessibility tree and platform-focusable
30
+ * (natively focusable, or focusable via tabindex), per
31
+ * helpers.getFocusableInfo, which accounts for contenteditable, :disabled,
32
+ * inert, and invalid/negative tabindex.
33
+ * - A widget-role child that its container drives rather than exposes as its
34
+ * own focus target creates no operability ambiguity and does not count:
35
+ * a role="option" inside a listbox/combobox, a role="tab" inside a tablist,
36
+ * a role="treeitem" inside a tree, a role="menuitem" inside a menu, or a
37
+ * role="radio" inside a radiogroup. Composites manage such children with one
38
+ * of two focus models, and both are exempt: aria-activedescendant (the child
39
+ * has no tabindex and is not focusable) and roving tabindex (the active
40
+ * child carries tabindex="0" and the rest tabindex="-1"). Under roving
41
+ * tabindex the owned child IS platform-focusable, so focusability alone
42
+ * cannot be the discriminator; membership in the container's owned-child set
43
+ * is what exempts it. A control that is itself focusable while nested in a
44
+ * control it does NOT belong to still counts: a <button> inside an <a href>,
45
+ * a focusable role="button" inside a link, an editable field inside a
46
+ * control, or an orphan role="option" with no owning listbox/combobox.
47
+ * - A disabled native control (<button disabled>, <input disabled>) and an
48
+ * inert descendant are not focusable, so they do not nest an interactive
49
+ * control; a display:none or aria-hidden descendant is not exposed to AT
50
+ * and is likewise out of scope. aria-disabled leaves an element focusable,
51
+ * so it still counts.
52
+ * - Occurrences report the outer (containing) control, the fixable unit
53
+ * ("move the nested control outside this element"), not the descendant.
54
+ * - Only the shallowest operable descendants are attributed to a container.
55
+ * In a chain A > B > C where all three are operable, A is reported for B
56
+ * and B for C; A is not reported for C, whose nesting belongs to its
57
+ * nearest operable ancestor B.
31
58
  */
32
59
 
33
60
  const id = 'nested-interactive-controls-absent';
@@ -89,6 +116,137 @@ function runInPage(ctx) {
89
116
  '[role="treeitem"]'
90
117
  ].join(', ');
91
118
 
119
+ const isAccTreeEligible =
120
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
121
+ const getFocusableInfo =
122
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
123
+
124
+ function isEligible(node) {
125
+ if (!isAccTreeEligible) return true;
126
+ try {
127
+ const r = isAccTreeEligible(node, ctx);
128
+ return !!(r && r.eligible);
129
+ } catch {
130
+ return true;
131
+ }
132
+ }
133
+
134
+ function matchesInteractive(node) {
135
+ return !!(
136
+ node &&
137
+ node.nodeType === 1 &&
138
+ typeof node.matches === 'function' &&
139
+ node.matches(INTERACTIVE_SELECTOR)
140
+ );
141
+ }
142
+
143
+ // A composite widget owns children of a fixed role and drives their focus
144
+ // itself (via roving tabindex or aria-activedescendant). The roving-tabindex
145
+ // model gives the active child tabindex="0" and the rest tabindex="-1", so
146
+ // the owned children ARE platform-focusable even though the container, not
147
+ // the child, is the operable unit. Such a child is part of the composite,
148
+ // not a control nested inside another control, so it must not be reported
149
+ // regardless of its tabindex. The map is keyed by the child role and lists
150
+ // the container roles that legitimately own it.
151
+ const COMPOSITE_CHILD_CONTAINERS = {
152
+ option: ['listbox', 'combobox'],
153
+ tab: ['tablist'],
154
+ treeitem: ['tree'],
155
+ menuitem: ['menu', 'menubar'],
156
+ menuitemcheckbox: ['menu', 'menubar'],
157
+ menuitemradio: ['menu', 'menubar'],
158
+ radio: ['radiogroup']
159
+ };
160
+
161
+ function getExplicitRole(node) {
162
+ if (!node || node.nodeType !== 1 || typeof node.getAttribute !== 'function') return '';
163
+ const raw = node.getAttribute('role');
164
+ if (!raw) return '';
165
+ // role accepts a space-separated fallback list; the first token wins.
166
+ const first = raw.trim().split(/\s+/)[0];
167
+ return first ? first.toLowerCase() : '';
168
+ }
169
+
170
+ function parentElementOf(node) {
171
+ if (!node) return null;
172
+ if (node.parentElement) return node.parentElement;
173
+ const p = node.parentNode;
174
+ return p && p.nodeType === 1 ? p : null;
175
+ }
176
+
177
+ // True when `node` is an owned child of a composite widget: its role is a
178
+ // composite-child role and a matching container role is present on one of
179
+ // its ancestors. The roving tab stop (tabindex="0") such a container places
180
+ // on its active child does not make that child an independently nested
181
+ // control.
182
+ function isManagedCompositeChild(node) {
183
+ const role = getExplicitRole(node);
184
+ const containers = COMPOSITE_CHILD_CONTAINERS[role];
185
+ if (!containers) return false;
186
+ let p = parentElementOf(node);
187
+ while (p && p.nodeType === 1) {
188
+ if (containers.indexOf(getExplicitRole(p)) !== -1) return true;
189
+ p = parentElementOf(p);
190
+ }
191
+ return false;
192
+ }
193
+
194
+ // Operable = a native-interactive / ARIA-widget element that is exposed to
195
+ // the accessibility tree and platform-focusable. A widget-role element with
196
+ // no independent focus (e.g. a role="option" with no tabindex, driven by
197
+ // its container via roving tabindex or aria-activedescendant) is not
198
+ // operable and does not nest an interactive control. When no focus model is
199
+ // available, role membership alone qualifies. (Composite-owned children are
200
+ // handled earlier as attribution boundaries in collectNestedOperable, so
201
+ // they never reach here.)
202
+ function isOperableInteractive(node) {
203
+ if (!matchesInteractive(node)) return false;
204
+ if (!isEligible(node)) return false;
205
+ if (!getFocusableInfo) return true;
206
+ try {
207
+ const info = getFocusableInfo(node, ctx);
208
+ return !!(info && info.focusable);
209
+ } catch {
210
+ return true;
211
+ }
212
+ }
213
+
214
+ // Shallowest operable interactive descendants of `root`. Traversal stops at
215
+ // each counted node instead of descending into it, so a control nested
216
+ // inside another operable control is attributed to its nearest operable
217
+ // ancestor rather than to every enclosing container. Eligibility is applied
218
+ // per node during the walk, so hidden or aria-hidden subtrees drop out.
219
+ function collectNestedOperable(root) {
220
+ const out = [];
221
+ const top = root && root.children;
222
+ if (!top || !top.length) return out;
223
+ const stack = [];
224
+ for (let i = top.length - 1; i >= 0; i--) stack.push(top[i]);
225
+ while (stack.length) {
226
+ const node = stack.pop();
227
+ if (node && node.nodeType === 1) {
228
+ // A composite-owned child (option in a listbox/combobox, tab in a
229
+ // tablist, ...) is not a nested interactive control: its container
230
+ // owns it and drives its focus (roving tabindex or
231
+ // aria-activedescendant). Treat it as an attribution boundary — do not
232
+ // count it, and do not descend past it. Any control genuinely nested
233
+ // inside it is attributed to the child itself (examined as its own
234
+ // container in the main loop), keeping the report at the nearest
235
+ // interactive ancestor rather than bubbling up to the composite.
236
+ if (isManagedCompositeChild(node)) continue;
237
+ if (isOperableInteractive(node)) {
238
+ out.push(node);
239
+ continue; // do not descend into a counted control
240
+ }
241
+ }
242
+ const kids = node && node.children;
243
+ if (kids && kids.length) {
244
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+
92
250
  const nodes = helpers.queryAllSmart
93
251
  ? helpers.queryAllSmart(INTERACTIVE_SELECTOR)
94
252
  : helpers.queryAll(INTERACTIVE_SELECTOR);
@@ -97,22 +255,16 @@ function runInPage(ctx) {
97
255
  let applicableCount = 0;
98
256
 
99
257
  for (const el of nodes) {
100
- if (!el || !el.querySelectorAll) continue;
258
+ if (!el || el.nodeType !== 1) continue;
259
+ if (!isEligible(el)) continue;
101
260
 
102
261
  applicableCount += 1;
103
262
 
104
- let nested;
105
- try {
106
- nested = Array.from(el.querySelectorAll(INTERACTIVE_SELECTOR));
107
- } catch {
108
- nested = [];
109
- }
263
+ const nested = collectNestedOperable(el);
110
264
  if (!nested.length) continue;
111
265
 
112
266
  const nestedTags = nested.map((n) => (n && n.tagName ? n.tagName.toLowerCase() : 'unknown'));
113
- const dedupedNestedTags = [
114
- ...new Set(nested.map((n) => (n && n.tagName ? n.tagName.toLowerCase() : 'unknown')))
115
- ];
267
+ const dedupedNestedTags = [...new Set(nestedTags)];
116
268
 
117
269
  const tag = el.tagName.toLowerCase();
118
270
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -7,9 +9,8 @@
7
9
  * @standard WCAG 2.2
8
10
  * @sc 1.1.1
9
11
  * @applicability
10
- * Applies to <object> elements that are exposed to assistive technologies.
11
- * Objects otherwise hidden from the accessibility tree remain applicable
12
- * if they are tabbable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to <object> elements included in the accessibility tree.
13
+ * Hidden objects are excluded whether or not they are focusable.
13
14
  * role="presentation"/role="none" are excluded only when not focusable.
14
15
  * @expectation
15
16
  * Each applicable <object> provides a text alternative via:
@@ -69,8 +70,15 @@ function runInPage(ctx) {
69
70
  const getEligibilityInfo =
70
71
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
71
72
 
72
- const isAccTreeEligible =
73
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
73
+ // ACT 8fc3b6 applies only to objects included in the accessibility tree, so a
74
+ // focusable object inside aria-hidden is out of scope; aria-hidden-focus
75
+ // reports that markup instead.
76
+ const isEligibleHelper =
77
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
78
+ ? helpers.isIncludedInAccessibilityTree
79
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
80
+ ? helpers.isAccTreeEligible
81
+ : null;
74
82
 
75
83
  const getFocusableInfo =
76
84
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
@@ -146,14 +154,15 @@ function runInPage(ctx) {
146
154
  for (const el of objects) {
147
155
  if (!el || !el.getAttribute) continue;
148
156
 
149
- if (isAccTreeEligible) {
157
+ if (isEligibleHelper) {
150
158
  const elig = (() => {
151
159
  try {
152
- return isAccTreeEligible(el, ctx);
160
+ return isEligibleHelper(el, ctx);
153
161
  } catch {
154
162
  return { eligible: true, reasons: [] };
155
163
  }
156
164
  })();
165
+ if (elig === false) continue;
157
166
  if (elig && elig.eligible === false) continue;
158
167
  }
159
168
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'option-name-present';
@@ -81,9 +83,16 @@ function runInPage(ctx) {
81
83
  return '';
82
84
  }
83
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
84
89
  function isEligibleAcc(helpers, el, ctx) {
85
90
  const fn =
86
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
87
96
  if (!fn) return true;
88
97
  try {
89
98
  const r = fn(el, ctx);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'page-title-present';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'progressbar-name-present';
@@ -65,9 +67,16 @@ function runInPage(ctx) {
65
67
  return '';
66
68
  }
67
69
 
70
+ // Naming rules apply only to elements included in the accessibility tree
71
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
72
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
68
73
  function isEligibleAcc(helpers, el, ctx) {
69
74
  const fn =
70
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
75
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
76
+ ? helpers.isIncludedInAccessibilityTree
77
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
78
+ ? helpers.isAccTreeEligible
79
+ : null;
71
80
  if (!fn) return true;
72
81
  try {
73
82
  const r = fn(el, ctx);
@@ -96,16 +105,12 @@ function runInPage(ctx) {
96
105
  const title = getAttr(el, 'title');
97
106
  if (title) return { ok: true, method: 'title' };
98
107
 
99
- // role="progressbar" is name-from-author-only per WAI-ARIA (verified
100
- // against a widely-used reference engine's own aria-progressbar-name check: any:
101
- // ['aria-label', 'aria-labelledby', title] — no content-based naming
102
- // method at all). It must NOT fall back to subtree content: found on a
103
- // real site, Instacart's <ul role="progressbar"> loading skeleton has
104
- // no name of its own, but nested descendants carry their own
105
- // aria-label="Loading Box" (for a completely different purpose) —
106
- // falling back to subtree content picked that up and wrongly treated
107
- // it as the progressbar's own name, when that reference engine correctly
108
- // still flags it as unnamed.
108
+ // role="progressbar" is name-from-author-only per WAI-ARIA: aria-label,
109
+ // aria-labelledby, or title — no content-based naming method at all. It
110
+ // must NOT fall back to subtree content: e.g. a <ul role="progressbar">
111
+ // loading skeleton with no name of its own, whose nested descendants
112
+ // carry their own aria-label for a different purpose — falling back to
113
+ // subtree content would wrongly treat that as the progressbar's name.
109
114
  return { ok: false, method: 'none' };
110
115
  }
111
116
 
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -14,8 +16,7 @@
14
16
  * Each applicable element with role="img" has an accessible text alternative:
15
17
  * - aria-label with a non-empty value; OR
16
18
  * - aria-labelledby referencing at least one existing element that contributes non-empty text; OR
17
- * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM,
18
- * also accepted by a widely-used reference engine's equivalent role-img-alt rule).
19
+ * - a non-empty title attribute (last-resort accessible-name source per HTML-AAM).
19
20
  */
20
21
 
21
22
  const id = 'role-img-text-alternative-present';
@@ -152,8 +153,7 @@ function runInPage(ctx) {
152
153
  const hasValidAriaLabel = hasAriaLabelAttr && ariaLabel.length > 0;
153
154
  const hasValidAriaLabelledbyAttr = hasAriaLabelledbyAttr && ariaLabelledby.length > 0;
154
155
 
155
- // Last-resort naming mechanism per HTML-AAM (also accepted by
156
- // a widely-used reference engine's role-img-alt): a non-empty title attribute.
156
+ // Last-resort naming mechanism per HTML-AAM: a non-empty title attribute.
157
157
  const titleRaw = (() => {
158
158
  try {
159
159
  return el.getAttribute('title');
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'searchbox-name-present';
@@ -107,9 +109,16 @@ function runInPage(ctx) {
107
109
  return '';
108
110
  }
109
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
110
115
  function isEligibleAcc(helpers, el, ctx) {
111
116
  const fn =
112
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
113
122
  if (!fn) return true;
114
123
  try {
115
124
  const r = fn(el, ctx);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -16,9 +18,8 @@
16
18
  * users. Client-side image maps (<map>/<area>, each with real href/alt)
17
19
  * are the accessible alternative and are not flagged by this rule.
18
20
  * @implementation-notes
19
- * - Matches a widely-used reference engine's server-side-image-map: presence of ismap is itself
20
- * the violation (there is no automatable way to verify a "usable
21
- * alternative" exists elsewhere on the page).
21
+ * - Presence of ismap is itself the violation (there is no automatable way
22
+ * to verify a "usable alternative" exists elsewhere on the page).
22
23
  */
23
24
 
24
25
  const id = 'server-side-image-map-absent';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'slider-name-present';
@@ -123,9 +125,16 @@ function runInPage(ctx) {
123
125
  return '';
124
126
  }
125
127
 
128
+ // Naming rules apply only to elements included in the accessibility tree
129
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
130
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
126
131
  function isEligibleAcc(helpers, el, ctx) {
127
132
  const fn =
128
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
133
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
134
+ ? helpers.isIncludedInAccessibilityTree
135
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
136
+ ? helpers.isAccTreeEligible
137
+ : null;
129
138
  if (!fn) return true;
130
139
  try {
131
140
  const r = fn(el, ctx);
@@ -139,7 +148,9 @@ function runInPage(ctx) {
139
148
  const occurrences = [];
140
149
  let applicableCount = 0;
141
150
 
142
- const selector = 'input[type="range"], [role="slider"]';
151
+ // Native input[type=range] belongs to
152
+ // form-control-programmatic-label-present.
153
+ const selector = '[role="slider"]';
143
154
  const nodes = helpers.queryAllSmart
144
155
  ? helpers.queryAllSmart(selector)
145
156
  : helpers.queryAll(selector);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'spinbutton-name-present';
@@ -107,9 +109,16 @@ function runInPage(ctx) {
107
109
  return '';
108
110
  }
109
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
110
115
  function isEligibleAcc(helpers, el, ctx) {
111
116
  const fn =
112
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
113
122
  if (!fn) return true;
114
123
  try {
115
124
  const r = fn(el, ctx);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'summary-name-present';
@@ -81,9 +83,16 @@ function runInPage(ctx) {
81
83
  return '';
82
84
  }
83
85
 
86
+ // Naming rules apply only to elements included in the accessibility tree
87
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
88
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
84
89
  function isEligibleAcc(helpers, el, ctx) {
85
90
  const fn =
86
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
91
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
92
+ ? helpers.isIncludedInAccessibilityTree
93
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
94
+ ? helpers.isAccTreeEligible
95
+ : null;
87
96
  if (!fn) return true;
88
97
  try {
89
98
  const r = fn(el, ctx);
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**