@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
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -16,13 +18,12 @@
16
18
  * `"false"` silently drops the error message from the accessibility
17
19
  * tree — authors almost always intend it to be exposed.
18
20
  * @implementation-notes
19
- * - This is deliberately narrow: a widely-used reference engine's own
20
- * `aria-conditional-attr` rule covers a broader table of attribute/condition
21
- * pairs. This rule implements only the one pairing (`aria-errormessage` /
22
- * `aria-invalid`) that is unambiguous and explicitly stated in the
23
- * ARIA spec, to keep `fail` high-confidence — matches this repo's
24
- * established pattern (see `aria-required-attr`/`aria-prohibited-attr`
25
- * for the same "narrower than that reference engine, but zero false positives"
21
+ * - This is deliberately narrow: the broader space is a table of many
22
+ * attribute/condition pairs. This rule implements only the one pairing
23
+ * (`aria-errormessage` / `aria-invalid`) that is unambiguous and
24
+ * explicitly stated in the ARIA spec, to keep `fail` high-confidence —
25
+ * matches this repo's established pattern (see `aria-required-attr`/
26
+ * `aria-prohibited-attr` for the same "narrow but zero false positives"
26
27
  * trade-off).
27
28
  * - Does not check whether the `aria-errormessage` ID reference itself
28
29
  * resolves to an existing element — that is `aria-valid-attr-value`'s
@@ -1,33 +1,40 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check aria-deprecated-role
5
7
  * @atomic true
6
- * @summary An explicit role attribute must not use a deprecated or author-prohibited ARIA role
8
+ * @summary An explicit role attribute should not use a deprecated or author-discouraged ARIA role
7
9
  * @standard WCAG 2.2
8
10
  * @sc 4.1.2
9
11
  * @applicability
10
12
  * Applies to any element whose role attribute's first (used) token is a
11
- * valid, non-abstract ARIA role that authors must never explicitly
13
+ * valid, non-abstract ARIA role that authors should not explicitly
12
14
  * declare — either because WAI-ARIA has deprecated it (e.g. "directory",
13
- * superseded by role="list") or because it's reserved for user-agent-
14
- * internal use only, not a spec deprecation but the same "valid token,
15
- * prohibited for authors" shape (role="generic" — verified 2026-07-20
16
- * against both the WAI-ARIA 1.2 spec text and MDN's explicit "It should
17
- * not be used by web authors" guidance).
15
+ * superseded by role="list") or because it is reserved for user-agent-
16
+ * internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
17
+ * NOT use in content).
18
18
  * @expectation
19
- * The role in use is neither deprecated nor author-prohibited. This is a
20
- * distinct, atomic decision from aria-roles-valid (existence/
21
- * abstractness): a role can be perfectly valid and non-abstract while
22
- * still being off-limits for explicit author use.
19
+ * The role in use is neither deprecated nor reserved. Graded by the
20
+ * strength of the rule ARIA states:
21
+ * - CANTTELL at SHOULD NOT, which leaves the usage conforming, so the
22
+ * author decides whether it matters: a deprecated role ("directory") or
23
+ * one reserved for user agents ("generic").
24
+ * - FAIL at MUST NOT. No ARIA 1.2 or 1.3 role carries an author MUST NOT
25
+ * outside the abstract roles, so this outcome is reserved for a later
26
+ * revision promoting a role to that strength.
27
+ * Distinct, atomic decision from aria-roles-valid (existence/
28
+ * abstractness): a role can be valid and non-abstract while still being
29
+ * discouraged in explicit author use.
23
30
  */
24
31
 
25
32
  const id = 'aria-deprecated-role';
26
33
 
27
34
  const meta = {
28
- title: 'role attribute must not use a deprecated or author-prohibited ARIA role',
35
+ title: 'role attribute should not use a deprecated or author-discouraged ARIA role',
29
36
  description:
30
- 'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use only (e.g. role="generic").',
37
+ 'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use (e.g. role="generic").',
31
38
  i18n: {
32
39
  titleKey: 'ariaDeprecatedRole_title',
33
40
  descriptionKey: 'ariaDeprecatedRole_description'
@@ -63,12 +70,31 @@ function runInPage(ctx) {
63
70
  ? helpers.queryAllSmart('[role]')
64
71
  : helpers.queryAll('[role]');
65
72
 
66
- const occurrences = [];
73
+ const failOccurrences = [];
74
+ const cantTellOccurrences = [];
67
75
  let applicableCount = 0;
68
76
 
77
+ // A role on an element hidden from assistive technology has no effect, so
78
+ // ACT 674b10 does not apply to it.
79
+ function isHidden(el) {
80
+ try {
81
+ if (typeof helpers.isDomVisibleEligible === 'function') {
82
+ if (!helpers.isDomVisibleEligible(el, ctx)) return true;
83
+ }
84
+ for (let n = el; n && n.getAttribute; n = n.parentElement) {
85
+ if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
86
+ }
87
+ } catch {
88
+ return false;
89
+ }
90
+ return false;
91
+ }
92
+
69
93
  for (const el of nodes) {
70
94
  if (!el || !el.getAttribute) continue;
71
95
 
96
+ if (isHidden(el)) continue;
97
+
72
98
  const role = ariaHelpers.getExplicitRole(el);
73
99
  if (!role) continue;
74
100
 
@@ -78,7 +104,14 @@ function runInPage(ctx) {
78
104
 
79
105
  applicableCount += 1;
80
106
 
81
- if (!ariaHelpers.isDeprecatedRole(role)) continue;
107
+ const deprecated = ariaHelpers.isDeprecatedRole(role);
108
+ const discouraged =
109
+ typeof ariaHelpers.isAuthorDiscouragedRole === 'function' &&
110
+ ariaHelpers.isAuthorDiscouragedRole(role);
111
+ const prohibited =
112
+ typeof ariaHelpers.isAuthorProhibitedRole === 'function' &&
113
+ ariaHelpers.isAuthorProhibitedRole(role);
114
+ if (!deprecated && !discouraged && !prohibited) continue;
82
115
 
83
116
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
84
117
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
@@ -86,34 +119,70 @@ function runInPage(ctx) {
86
119
  ? ariaHelpers.getDeprecatedRoleGuidance(role)
87
120
  : 'Replace the deprecated role with its recommended replacement.';
88
121
 
89
- occurrences.push({
90
- selector: stableSelector,
91
- html,
92
- summary: `This element uses role="${role}", which authors must not explicitly declare.`,
93
- hint: guidance,
94
- i18n: {
95
- summaryKey: 'ariaDeprecatedRole_summary_fail',
96
- hintKey: 'ariaDeprecatedRole_hint_fail',
97
- params: { role, guidance }
98
- },
99
- data: {
100
- details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
101
- }
102
- });
122
+ if (prohibited) {
123
+ // Author MUST NOT: the usage is non-conforming, not merely discouraged.
124
+ failOccurrences.push({
125
+ selector: stableSelector,
126
+ html,
127
+ summary: `This element uses role="${role}", which authors must not explicitly declare.`,
128
+ hint: guidance,
129
+ occurrenceOutcome: 'fail',
130
+ i18n: {
131
+ summaryKey: 'ariaDeprecatedRole_summary_fail',
132
+ hintKey: 'ariaDeprecatedRole_hint_fail',
133
+ params: { role, guidance }
134
+ },
135
+ data: {
136
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance }
137
+ }
138
+ });
139
+ } else if (discouraged) {
140
+ // Reserved for user-agent-internal use, at SHOULD NOT strength.
141
+ cantTellOccurrences.push({
142
+ selector: stableSelector,
143
+ html,
144
+ summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
145
+ hint: guidance,
146
+ occurrenceOutcome: 'cantTell',
147
+ i18n: {
148
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
149
+ hintKey: 'ariaDeprecatedRole_hint_cantTell',
150
+ params: { role, guidance }
151
+ },
152
+ data: {
153
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance }
154
+ }
155
+ });
156
+ } else {
157
+ // Deprecated but still valid: surfaced for the author to decide.
158
+ cantTellOccurrences.push({
159
+ selector: stableSelector,
160
+ html,
161
+ summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
162
+ hint: guidance,
163
+ occurrenceOutcome: 'cantTell',
164
+ i18n: {
165
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell',
166
+ hintKey: 'ariaDeprecatedRole_hint_cantTell',
167
+ params: { role, guidance }
168
+ },
169
+ data: {
170
+ details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
171
+ }
172
+ });
173
+ }
103
174
  }
104
175
 
105
176
  if (applicableCount === 0) {
106
177
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
107
178
  }
108
- if (occurrences.length) {
109
- return {
110
- ruleId: rule.ruleId,
111
- outcome: 'fail',
112
- severity: rule.defaultSeverity || 'moderate',
113
- occurrences
114
- };
115
- }
116
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
179
+
180
+ const resolved = helpers.resolveTieredOutcome(
181
+ failOccurrences,
182
+ cantTellOccurrences,
183
+ rule.defaultSeverity || 'moderate'
184
+ );
185
+ return { ruleId: rule.ruleId, ...resolved };
117
186
  }
118
187
 
119
188
  module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -14,10 +16,10 @@
14
16
  * @expectation
15
17
  * <body> does not have aria-hidden="true". Hiding the document body
16
18
  * removes the entire page's content and structure from the
17
- * accessibility tree at once — a widely-used reference engine tags this under both 1.3.1
18
- * (Info and Relationships: the page's structure becomes entirely
19
- * non-determinable) and 4.1.2 (Name, Role, Value: nothing in the
20
- * document exposes a role/name/value any longer).
19
+ * accessibility tree at once — both 1.3.1 (Info and Relationships: the
20
+ * page's structure becomes entirely non-determinable) and 4.1.2 (Name,
21
+ * Role, Value: nothing in the document exposes a role/name/value any
22
+ * longer) apply.
21
23
  */
22
24
 
23
25
  const id = 'aria-hidden-body';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -12,7 +14,7 @@
12
14
  * No element with aria-hidden="true" may itself be focusable, and no focusable element
13
15
  * may exist within an aria-hidden="true" subtree.
14
16
  *
15
- * Notes on parity with a widely-used reference engine's aria-hidden-focus check:
17
+ * Notes:
16
18
  * - Focusability is computed via ctx.helpers.getFocusableInfo (native + tabindex + contenteditable).
17
19
  * - Elements that are not rendered (e.g., display:none, visibility:hidden, [hidden]) are excluded.
18
20
  * - Elements hidden via CSS in ways that still allow keyboard focus (e.g., opacity:0, off-screen, clip)
@@ -485,14 +487,11 @@ function runInPage(ctx) {
485
487
  // An explicit negative tabindex removes the element from the keyboard
486
488
  // tab sequence entirely, regardless of tag — the standard, WAI-
487
489
  // recommended technique for safely hiding focusable content behind
488
- // aria-hidden (verified against a widely-used reference engine's own aria-hidden-focus check,
489
- // which requires tabbability — not raw focusability — via its
490
- // `focusable-not-tabbable` sub-check; confirmed via a real page:
491
- // Wikipedia's sticky header uses <button tabindex="-1">/<a tabindex="-1">
492
- // inside aria-hidden divs, a correct pattern this rule was previously
493
- // flagging as a false positive). Such an element is still
494
- // programmatically focusable (script could call .focus()), but that's
495
- // not what "no focusable content behind aria-hidden" cares about.
490
+ // aria-hidden (e.g. <button tabindex="-1"> / <a tabindex="-1"> inside
491
+ // an aria-hidden container). This cares about tabbability, not raw
492
+ // focusability. Such an element is still programmatically focusable
493
+ // (script could call .focus()), but that's not what "no focusable
494
+ // content behind aria-hidden" cares about.
496
495
  const explicitTabindex = trim(el.getAttribute('tabindex'));
497
496
  if (
498
497
  explicitTabindex !== '' &&
@@ -640,10 +639,9 @@ function runInPage(ctx) {
640
639
  // cheaper first cannot change which elements end up in the bucket —
641
640
  // it only skips the expensive check for the (typically vast) majority
642
641
  // of focusable candidates that were never inside an aria-hidden root
643
- // in the first place. On a real page with a large focusable-candidate
644
- // count and a complex stylesheet (Daily Mail: ~1400 links, ~3600 CSS
645
- // rules), this cut this check's runtime from ~30s to well under 1s —
646
- // a pure ordering change, not a behavior change.
642
+ // in the first place. On pages with many focusable candidates and a
643
+ // large stylesheet this is a big speedup, and a pure ordering change,
644
+ // not a behavior change.
647
645
  const rootEl = closestAriaHiddenTrue(el);
648
646
  if (!rootEl) continue;
649
647
 
@@ -777,6 +775,7 @@ function runInPage(ctx) {
777
775
  hint: downgradedToCantTell
778
776
  ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.'
779
777
  : 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
778
+ occurrenceOutcome: downgradedToCantTell ? 'cantTell' : 'fail',
780
779
  i18n: {
781
780
  summaryKey: downgradedToCantTell
782
781
  ? 'ariaHidden_focus_summary_cantTell_redirect'
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -7,15 +9,17 @@
7
9
  * @standard WCAG 2.2
8
10
  * @sc 4.1.2
9
11
  * @applicability
10
- * Applies to (a) elements whose explicit, valid role is one of the small
11
- * set of WAI-ARIA 1.2 roles with a documented "Prohibited ARIA States and
12
- * Properties" list (pure text-semantics / non-naming structural roles:
13
- * caption, code, deletion, emphasis, generic, insertion, mark, none,
14
- * paragraph, presentation, strong, subscript, suggestion, superscript,
15
- * time), and (b) a small, curated set of native HTML tags verified to
16
- * carry no explicit or implicit ARIA role at all (see ROLELESS_NATIVE_TAGS
17
- * below) — in both cases, only elements that also carry aria-label or
18
- * aria-labelledby.
12
+ * Applies to (a) elements whose explicit, valid role is one of the ARIA
13
+ * 1.2 roles with a documented "Prohibited ARIA States and Properties"
14
+ * list for naming attributes (pure text-semantics / non-naming
15
+ * structural roles: caption, code, deletion, emphasis, generic,
16
+ * insertion, mark, none, paragraph, presentation, strong, subscript,
17
+ * suggestion, superscript, time), and (b) elements with no role at all —
18
+ * a curated set of native HTML tags verified to carry no implicit role
19
+ * (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
20
+ * hyphenated, author-defined tag per the Custom Elements spec; see
21
+ * isRolelessCustomElementTag below) — in both cases, only elements that
22
+ * also carry aria-label or aria-labelledby.
19
23
  * @expectation
20
24
  * Prohibited attributes must not be present on (a); for (b), the naming
21
25
  * attribute is at best unreliable (nothing accessible-name-aware to hang
@@ -24,97 +28,55 @@
24
28
  * this produces.
25
29
  * @implementation-notes
26
30
  * - Deliberately scoped to the single, well-established prohibition class
27
- * (naming attributes on pure text-semantics roles) rather than
28
- * attempting an exhaustive per-role prohibited-attribute table; see
31
+ * (naming attributes on pure text-semantics roles) rather than an
32
+ * exhaustive per-role prohibited-attribute table; see
29
33
  * src/core/aria-helpers.js file header for this engine's confidence-
30
- * scoping rationale.
31
- * - Role list widened 2026-07-19 (Tier 4) from 10 to 13 roles, adding
32
- * `mark`, `suggestion`, and `time` — the other ARIA 1.2 "HTML-alignment"
33
- * text-level roles that share the same documented prohibition as the
34
- * original 10. Still deliberately not claiming full parity with a widely-used
35
- * reference engine:
36
- * only roles/attrs this engine has high confidence in from the spec
37
- * text are included, per the file's own "wrong entries cause false-
38
- * positive fails" caution.
39
- * - Widened again 2026-07-21 to add `presentation`/`none`, verified
40
- * directly against a widely-used reference engine's own role data table
41
- * (both have `prohibitedAttrs: ['aria-label', 'aria-labelledby']`), and
42
- * corroborated by the W3C
43
- * WAI-ARIA 1.2 spec's own §5.2.8.6 "Roles which cannot be named"
44
- * listing `presentation` explicitly (`none` is `presentation`'s
45
- * documented 1.2-introduced alias, identical semantics). The
46
- * pre-existing `presentation-role-conflict` rule already treats
47
- * aria-label/aria-labelledby as conflicting on these two roles, but at
48
- * `manual`/cantTell confidence across a ~24-attribute general list —
49
- * this addition lets the specific, unambiguous naming-prohibition case
50
- * also fire as a hard, WCAG-normative `fail` via this rule, matching
51
- * this engine's "one rule = one normative decision" pattern rather than
52
- * only ever surfacing it as advisory.
53
- * - Investigated, but deliberately did NOT add, `definition`/`term`
54
- * despite both appearing on MDN's aria-label reference page's
55
- * "not supported" list: that MDN list is demonstrably wrong for these
56
- * two — a widely-used reference engine's own role data explicitly declares
57
- * `nameFrom: ['author']` (`definition`) / `nameFrom: ['author',
58
- * 'contents']` (`term`), and the W3C spec's own §5.2.8.4 "Roles
59
- * Supporting Name From Author" index lists both by name; MDN's
60
- * `definition_role` page even demonstrates `aria-labelledby` usage on
61
- * it directly. A real, confirmed documentation bug on MDN's side, not
62
- * a gap here.
34
+ * scoping rationale. The 14-role list comes from the W3C WAI-ARIA 1.2
35
+ * spec's §5.2.8.6 "Roles which cannot be named"; only roles/attrs with
36
+ * high confidence from the spec text are included, since a wrong entry
37
+ * here causes a false-positive fail.
38
+ * - The pre-existing presentation-role-conflict rule already treats
39
+ * aria-label/aria-labelledby as conflicting on presentation/none, but at
40
+ * manual/cantTell confidence across a broad attribute list; this rule's
41
+ * narrower, unambiguous naming-prohibition case fires as a hard,
42
+ * WCAG-normative fail instead, matching this engine's "one rule = one
43
+ * normative decision" pattern.
44
+ * - Deliberately excludes `definition`/`term` despite both appearing on
45
+ * MDN's aria-label "not supported" list: both support name from author
46
+ * (`nameFrom: ['author']` for definition, `['author', 'contents']` for
47
+ * term), and the W3C spec's §5.2.8.4 "Roles Supporting Name From Author"
48
+ * index lists both by name.
63
49
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
64
50
  * property, while engine-level hidden-subtree filtering still applies
65
51
  * unless engineOptions.includeHiddenElements is true.
66
- * - Widened 2026-07-31 to add a second, independent branch covering
67
- * naming attributes on ROLELESS elements (no explicit role="", no
68
- * implicit/native role either) — found on the emoji-mart demo page
69
- * (missive.github.io/emoji-mart): hundreds of
70
- * `<span aria-label="party_parrot" class="emoji-mart-emoji...">` tiles,
71
- * plain roleless spans with no other accessible-name source, which this
72
- * rule previously ignored entirely, since its own Tier-1 branch only ever
73
- * looked at the EXPLICIT role="" attribute, never at "no role at all."
74
- * Empirically determined (not guessed) which native tags genuinely carry
75
- * no role at all, by resolving each candidate tag's role against a live
76
- * Chromium page — several surprises: common text-level tags like `<p>`,
77
- * `<strong>`, `<em>`, `<code>`, `<mark>`, `<time>` have no implicit role
78
- * at all (their prohibited-attrs entries only ever matter for an
79
- * EXPLICIT `role="paragraph"`/`role="strong"`/etc. restatement, a rare
80
- * case — the native tag itself resolves to role `null`, same as a bare
81
- * `<div>`/`<span>`, and falls into this same roleless branch). See
82
- * ROLELESS_NATIVE_TAGS below for the resulting curated list —
83
- * deliberately conservative: `<section>`/`<form>`/`<a>` are excluded
84
- * even though they can also resolve to no role, because their native
85
- * role is conditional (name-dependent/href-dependent) and already has
86
- * dedicated, more nuanced handling elsewhere in this engine
87
- * (`getElementRoleKey`'s `section`/`section[named]`/`header`/
88
- * `header[toplevel]` branches) that this rule doesn't attempt to
89
- * duplicate.
52
+ * - Second, independent branch: naming attributes on ROLELESS elements (no
53
+ * explicit role="", no implicit/native role either) — e.g. icon-only
54
+ * `<span aria-label="...">` tiles with no other accessible-name source.
55
+ * ROLELESS_NATIVE_TAGS below is a curated, deliberately conservative
56
+ * list of native tags confirmed to carry no implicit role (common
57
+ * text-level tags like `<p>`/`<strong>`/`<em>`/`<code>`/`<mark>`/`<time>`
58
+ * resolve to role `null`, same as a bare `<div>`/`<span>`);
59
+ * `<section>`/`<form>`/`<a>` are excluded even though they can also
60
+ * resolve to no role, because their native role is conditional
61
+ * (name-dependent/href-dependent) and already has dedicated handling in
62
+ * `getElementRoleKey`'s `section`/`section[named]`/`header`/
63
+ * `header[toplevel]` branches that this rule doesn't duplicate.
90
64
  * Two confidence tiers instead of a flat fail: if the element's subtree
91
- * ALREADY produces a non-empty accessible name from its content
92
- * (computed the same way link-name-present/button-name-present do, via
93
- * `helpers.getContentNameInfo`), the naming attribute might just be a
94
- * redundant/intentional override — reported as `cantTell`, not a hard
95
- * fail. Only a roleless element with NO other accessible-name source at
96
- * all (the emoji-mart case: an icon-only span, background-image styled,
97
- * no text anywhere in its subtree) is a confident, deterministic `fail`
98
- * — nothing else could ever expose this element's name, and no role
99
- * exists to make it a Name/Role/Value candidate in the first place.
100
- * The widget-ancestor exemption (skip when the closest real ancestor role
101
- * is a "widget"-type role) avoids over-flagging roleless helper
102
- * spans/divs used as internal decoration inside a custom composite
103
- * widget.
104
- * - Fixed 2026-07-31 (same day as introduced): the Tier-2 "already has a
105
- * role, not this branch's concern" guard checked only whether `role=""`
106
- * was present (`getExplicitRole`), not whether the value was a real,
107
- * recognized ARIA role. An invalid/typo'd role token (e.g.
108
- * `role="totally-bogus"`) therefore silently suppressed detection of an
109
- * otherwise-flaggable roleless naming attribute — identical markup with
110
- * the bogus role attribute removed entirely correctly failed, but with
111
- * it present the element was skipped as if it had a real role. Per spec
112
- * (and per this same file's own `getNearestAncestorRole` helper a few
113
- * lines below, which already gets this right), an unrecognized role
114
- * token is ignored by the accessibility tree, not honored — the element
115
- * is still effectively roleless. Now validates via the existing
116
- * `isValidConcreteRole` before treating an explicit role as real,
117
- * matching `getNearestAncestorRole`'s own pattern.
65
+ * already produces a non-empty accessible name from its content (via
66
+ * `helpers.getContentNameInfo`, same as
67
+ * link-name-present/button-name-present), the naming attribute might
68
+ * just be a redundant/intentional override — reported as `cantTell`, not
69
+ * a hard fail. Only a roleless element with no other accessible-name
70
+ * source at all is a confident, deterministic `fail`. The
71
+ * widget-ancestor exemption (skip when the closest real ancestor role is
72
+ * a "widget"-type role) avoids over-flagging roleless helper spans/divs
73
+ * used as internal decoration inside a custom composite widget.
74
+ * - The Tier-2 "already has a role, not this branch's concern" guard
75
+ * validates the explicit role via `isValidConcreteRole` before treating
76
+ * it as real (matching `getNearestAncestorRole`'s own pattern): an
77
+ * unrecognized role token (e.g. `role="totally-bogus"`) is ignored by
78
+ * the accessibility tree, not honored, so the element is still
79
+ * effectively roleless and must still be checked by this branch.
118
80
  */
119
81
 
120
82
  const id = 'aria-prohibited-attr';
@@ -212,6 +174,7 @@ function runInPage(ctx) {
212
174
  failOccurrences.push({
213
175
  selector: stableSelector,
214
176
  html,
177
+ occurrenceOutcome: 'fail',
215
178
  summary: 'This attribute is prohibited on this element’s role.',
216
179
  hint: 'Remove this attribute; this role must not carry an accessible name.',
217
180
  i18n: {
@@ -229,11 +192,10 @@ function runInPage(ctx) {
229
192
  // --- Tier 2: no role at all (see header comment for the full rationale
230
193
  // and how ROLELESS_NATIVE_TAGS/WIDGET_TYPE_ROLES were derived) ---
231
194
 
232
- // Small, curated set of native tags empirically verified (against a
233
- // widely-used reference engine's own getRole() at runtime, not guessed)
234
- // to carry no explicit or implicit ARIA role. Deliberately excludes
235
- // <section>/<form>/<a> — all conditionally roleless too, but already
236
- // handled with more nuance elsewhere in this engine (see header comment).
195
+ // Small, curated set of native tags verified to carry no explicit or
196
+ // implicit ARIA role. Deliberately excludes <section>/<form>/<a> — all
197
+ // conditionally roleless too, but already handled with more nuance
198
+ // elsewhere in this engine (see header comment).
237
199
  const ROLELESS_NATIVE_TAGS = new Set([
238
200
  'p',
239
201
  'b',
@@ -268,10 +230,8 @@ function runInPage(ctx) {
268
230
  'legend'
269
231
  ]);
270
232
 
271
- // WAI-ARIA roles a widely-used reference engine's own role table types as
272
- // "widget" (verified directly against its source, not the six-category
273
- // WAI-ARIA taxonomy — this engine's algorithm branches on its own `type`
274
- // field, so parity means matching that field exactly).
233
+ // WAI-ARIA roles typed as "widget" (the role set the roleless-branch
234
+ // exemption below branches on, not the six-category WAI-ARIA taxonomy).
275
235
  const WIDGET_TYPE_ROLES = new Set([
276
236
  'alert',
277
237
  'alertdialog',
@@ -337,6 +297,37 @@ function runInPage(ctx) {
337
297
  return '';
338
298
  }
339
299
 
300
+ // A small, spec-reserved set of hyphenated tag names that are NOT
301
+ // autonomous custom elements despite containing a hyphen (legacy SVG/
302
+ // MathML tags predating the Custom Elements spec) — see
303
+ // https://html.spec.whatwg.org/#valid-custom-element-name's own
304
+ // exclusion list. Excluded so this doesn't misclassify them as
305
+ // always-roleless the same way a real custom element is.
306
+ const RESERVED_HYPHENATED_TAGS = new Set([
307
+ 'annotation-xml',
308
+ 'color-profile',
309
+ 'font-face',
310
+ 'font-face-src',
311
+ 'font-face-uri',
312
+ 'font-face-format',
313
+ 'font-face-name',
314
+ 'missing-glyph'
315
+ ]);
316
+
317
+ // An autonomous custom element (author-defined tag, always containing a
318
+ // hyphen per the Custom Elements spec's naming grammar) has no implicit
319
+ // ARIA role at all -- unlike native tags, there is no conditional-role
320
+ // nuance to worry about here (a native <a>/<section>/<form> can gain an
321
+ // implicit role depending on other attributes, which is exactly why
322
+ // ROLELESS_NATIVE_TAGS is a hand-verified allowlist rather than a
323
+ // blanket rule; a custom element has no such spec-defined conditional
324
+ // role logic whatsoever). Covers e.g. a `<play-button aria-label="...">`
325
+ // or `<app-carousel aria-label="...">` with no other name source, which
326
+ // the fixed native-tag allowlist below would otherwise skip.
327
+ function isRolelessCustomElementTag(tag) {
328
+ return tag.includes('-') && !RESERVED_HYPHENATED_TAGS.has(tag);
329
+ }
330
+
340
331
  const namingSelector = '[aria-label],[aria-labelledby]';
341
332
  const namingNodes = helpers.queryAllSmart
342
333
  ? helpers.queryAllSmart(namingSelector)
@@ -346,7 +337,7 @@ function runInPage(ctx) {
346
337
  if (!el || !el.getAttribute) continue;
347
338
 
348
339
  const tag = String(el.tagName || '').toLowerCase();
349
- if (!ROLELESS_NATIVE_TAGS.has(tag)) continue;
340
+ if (!ROLELESS_NATIVE_TAGS.has(tag) && !isRolelessCustomElementTag(tag)) continue;
350
341
  const explicitRole = ariaHelpers.getExplicitRole(el);
351
342
  if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role — Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
352
343
  if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all — not this branch's concern
@@ -378,6 +369,7 @@ function runInPage(ctx) {
378
369
  cantTellOccurrences.push({
379
370
  selector: stableSelector,
380
371
  html,
372
+ occurrenceOutcome: 'cantTell',
381
373
  summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology — but the element's own content already provides one.`,
382
374
  hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
383
375
  i18n: {
@@ -398,6 +390,7 @@ function runInPage(ctx) {
398
390
  failOccurrences.push({
399
391
  selector: stableSelector,
400
392
  html,
393
+ occurrenceOutcome: 'fail',
401
394
  summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
402
395
  hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
403
396
  i18n: {