@surea11y/core 1.3.0 → 1.4.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 (163) hide show
  1. package/CHANGELOG.md +46 -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/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. 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,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: {
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -20,86 +22,54 @@
20
22
  * owned set. Nothing else is a structurally valid direct child of a
21
23
  * composite/container role.
22
24
  * @implementation-notes
23
- * - A distinct atomic decision from aria-required-children (see
24
- * that rule): "does at least one required child exist" vs "is every
25
- * owned child one of the allowed roles." A widely-used reference engine
26
- * bundles both under one check (`aria-required-children`); this repo's
27
- * "one rule = one normative decision" principle splits them, matching
28
- * the established pattern elsewhere of surea11y rules mapping
29
- * many-to-one against a single check in that reference engine.
30
- * - The "allowed owned roles" set is exactly REQUIRED_OWNED_ROLES — not
31
- * a separately authored, broader list. Verified directly against a
32
- * widely-used reference engine's own ariaRequiredChildren/getOwnedRoles
33
- * algorithm: an owned element is only considered allowed if its role is
34
- * literally in the container's required set; that engine does not
35
- * define a superset "allowed but not required" list for this purpose.
36
- * Found and verified via a real page (Red Cross's homepage: a
37
- * <nav role="region"> nested inside a <ul role="menubar"> through a
38
- * role="none" <li> wrapper — a real violation that reference engine
39
- * caught that aria-required-children's own scope (documented
40
- * there as "can only under-report, never over-report") does not).
41
- * - Widened 2026-07-21 to also flag a ROLELESS descendant that has any
42
- * global WAI-ARIA attribute or is focusable, matching a widely-used
43
- * reference engine's own `getOwnedRoles` exactly (verified directly
44
- * against its source: `hasGlobalAriaOrFocusable =
45
- * !!globalAriaAttr || _isFocusable(vNode)` — such a descendant is
46
- * pushed as an owned entry with `role: null`, which can never match a
47
- * container's required-owned-roles set, so it's always "unallowed").
48
- * Previously left out as riskier to replicate — re-evaluated given
49
- * direct access to that engine's exact algorithm (not a guess) plus this
50
- * engine's own already-existing, shared `helpers.getFocusableInfo` for
51
- * the focusability half. Both signals (global-attribute presence,
52
- * focusability) are static, declarative markup facts with no live-DOM/
53
- * hydration risk, unlike e.g. `aria-checked-state-mismatch`'s DOM-
54
- * property comparison.
55
- * - Fixed 2026-07-30: a roleless-but-focusable descendant's message and
56
- * `data.details.attr` used to always claim "carries tabindex" even when
57
- * the element had no tabindex attribute at all and was only focusable
58
- * natively (e.g. an <a href> link). `helpers.getFocusableInfo`'s
59
- * `mechanism` field ('tabindex' | 'native' | ...) is now used to tell
60
- * the two apart, with a distinct `nativeFocusable` attr/message for the
61
- * native case. Found via a real Angular app: a routerLink <a> inside a
62
- * role="list" was reported as "carries tabindex" though the rendered
63
- * markup had no such attribute.
64
- * - Recursion stops at the first non-transparent role boundary, same as
65
- * that reference engine: a nested container with its own real role (e.g. a
66
- * <div role="listbox"> inside a menubar) is evaluated as ITS OWN
67
- * owned-role entry against the outer container (and, separately, gets
68
- * its own applicability pass as a container in the same rule run) —
69
- * its descendants are never misattributed to the outer container.
70
- * - Fixed 2026-07-31: child-role resolution used `ariaHelpers.getExplicitRole`
71
- * (explicit role="" attribute only), unlike aria-required-children's
72
- * descendant matching which uses `ariaHelpers.getContainmentRole` (explicit
25
+ * - A distinct atomic decision from aria-required-children (see that
26
+ * rule): "does at least one required child exist" vs "is every owned
27
+ * child one of the allowed roles" — split per this repo's "one rule =
28
+ * one normative decision" principle.
29
+ * - The "allowed owned roles" set is exactly REQUIRED_OWNED_ROLES, not a
30
+ * separately authored, broader list: an owned element is allowed only if
31
+ * its role is literally in the container's required set.
32
+ * - A ROLELESS descendant that has any global WAI-ARIA attribute or is
33
+ * focusable is also flagged: it's treated as an owned entry with
34
+ * `role: null`, which can never match a container's required-owned-roles
35
+ * set, so it's always "unallowed". Both signals
36
+ * (global-attribute presence, focusability via the shared
37
+ * `helpers.getFocusableInfo`) are static, declarative markup facts with
38
+ * no live-DOM/hydration risk, unlike e.g.
39
+ * `aria-checked-state-mismatch`'s DOM-property comparison.
40
+ * `helpers.getFocusableInfo`'s `mechanism` field ('tabindex' | 'native' |
41
+ * ...) distinguishes an explicit `tabindex` attribute from native
42
+ * focusability (e.g. an `<a href>`), so the reported `data.details.attr`
43
+ * and message correctly say `nativeFocusable` rather than claiming a
44
+ * tabindex attribute that isn't actually present in the markup.
45
+ * - Recursion stops at the first non-transparent role boundary: a nested
46
+ * container with its own real role
47
+ * (e.g. a `<div role="listbox">` inside a menubar) is evaluated as its
48
+ * own owned-role entry against the outer container (and, separately,
49
+ * gets its own applicability pass as a container in the same rule run)
50
+ * — its descendants are never misattributed to the outer container.
51
+ * - Child-role resolution uses `ariaHelpers.getContainmentRole` (explicit
73
52
  * role, falling back to the native-tag map — li/tr/td/th/tbody/ul/ol/
74
- * table/select/input[type=radio] — see that helper's own header comment).
75
- * A bare `<li>` with no role="" attribute — the common CSS-reset
76
- * workaround `<ul role="list"><li>...</li></ul>` that getContainmentRole
77
- * exists specifically to handle — was therefore read as roleless here,
78
- * making it structurally transparent: the walk recursed straight through
79
- * the listitem boundary into its subtree and could report a focusable
80
- * descendant several levels down as a disallowed owned child of the list,
81
- * instead of stopping at the (implicit) listitem the way
82
- * aria-required-children already does. Switched to getContainmentRole so
83
- * both rules resolve an owned child's role identically. This is a general
84
- * fix, not list/listitem-specific: it applies to every container role in
85
- * REQUIRED_OWNED_ROLES whose native-tag counterpart the child map covers
86
- * (e.g. a bare `<tr>`/`<td>` under a role="table"/"grid"/"row" container
87
- * with no explicit role="" was subject to the same flattening bug). Found
88
- * via a real Angular Material-style component library: an `<a routerlink>`
89
- * several DOM levels inside a bare `<li>` under `<ul role="list">` was
90
- * reported as an unallowed owned child of the list.
91
- * - Gated on isAccTreeEligible for the container itself, matching the fix
92
- * applied to aria-required-children (see that rule's header): the
93
- * original "not gated" note here just cited that rule's reasoning
94
- * without re-deriving it, and that reasoning turned out not to hold —
95
- * a closed dialog/flyout menu populated on open is a real false-positive
96
- * shape. In this rule specifically the descendant-level eligibility gate
97
- * already made the container-level gate redundant for correctness (an
98
- * ineligible container has no eligible descendants either, so `owned`
99
- * ends up empty and nothing fails) — but skipping the container up front
100
- * reports `notApplicable` instead of a vacuous `pass`, which is the more
101
- * accurate outcome for a container that isn't currently exposed at all,
102
- * and avoids walking a subtree whose result is already known.
53
+ * table/select/input[type=radio]), the same resolution
54
+ * aria-required-children's descendant matching uses — not
55
+ * `getExplicitRole`, which only sees an explicit role="" attribute. A
56
+ * bare `<li>` with no role="" (the common CSS-reset workaround
57
+ * `<ul role="list"><li>...</li></ul>`) must still resolve to the
58
+ * implicit listitem role so the walk stops at that boundary instead of
59
+ * recursing straight through it into the listitem's own subtree; the
60
+ * same applies to every container role in REQUIRED_OWNED_ROLES whose
61
+ * native-tag counterpart the child map covers (e.g. a bare
62
+ * `<tr>`/`<td>` under a role="table"/"grid"/"row" container).
63
+ * - Gated on isAccTreeEligible for the container itself, matching
64
+ * aria-required-children: a closed dialog/flyout menu populated on open
65
+ * is a real false-positive shape otherwise. The descendant-level
66
+ * eligibility gate alone would make the container-level gate redundant
67
+ * for correctness (an ineligible container has no eligible descendants
68
+ * either, so `owned` ends up empty and nothing fails), but skipping the
69
+ * container up front reports `notApplicable` instead of a vacuous
70
+ * `pass` — the more accurate outcome for a container that isn't
71
+ * currently exposed at all — and avoids walking a subtree whose result
72
+ * is already known.
103
73
  * - No aria-busy exemption here (unlike aria-required-children): the
104
74
  * WAI-ARIA spec's aria-busy escape hatch is specifically about a
105
75
  * container missing its required owned elements while loading, not
@@ -161,7 +131,7 @@ function runInPage(ctx) {
161
131
  // aria-allowed-attr.js's GLOBAL_ATTRS — duplicated, not imported, since
162
132
  // runInPage must be self-contained per scripts/build-core.js). A
163
133
  // roleless descendant carrying any of these is a real accessible-tree
164
- // node a widely-used reference engine's getOwnedRoles also flags, not a transparent wrapper.
134
+ // node, not a transparent wrapper.
165
135
  const GLOBAL_ARIA_ATTRS = [
166
136
  'aria-atomic',
167
137
  'aria-braillelabel',
@@ -205,13 +175,12 @@ function runInPage(ctx) {
205
175
  // non-transparent role boundary otherwise — see header comment. A
206
176
  // roleless descendant is ALSO a non-transparent boundary (an owned
207
177
  // entry with role: null, which can never satisfy a required-role set)
208
- // when it carries a global aria-* attribute or is focusable — matches
209
- // a widely-used reference engine's own getOwnedRoles exactly (see header comment).
210
- // kidRole comes from getContainmentRole, not getExplicitRole (see header
211
- // comment's 2026-07-31 fix): "roleless" here means neither an explicit
212
- // role="" NOR one of the native containment tags (li, tr, td, ...), so a
213
- // bare <li>/<tr>/... is a real listitem/row boundary, not a transparent
214
- // wrapper the walk should pass through.
178
+ // when it carries a global aria-* attribute or is focusable.
179
+ // kidRole comes from getContainmentRole, not getExplicitRole: "roleless"
180
+ // here means neither an explicit role="" NOR one of the native
181
+ // containment tags (li, tr, td, ...), so a bare <li>/<tr>/... is a real
182
+ // listitem/row boundary, not a transparent wrapper the walk should pass
183
+ // through.
215
184
  function collectOwnedRoles(el, requiredSet, out, depth) {
216
185
  if (depth > MAX_DEPTH) return;
217
186
  const kids = el.children ? Array.prototype.slice.call(el.children) : [];
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -18,13 +20,10 @@
18
20
  * - Deliberately scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
19
21
  * which only lists a required property when the spec is unambiguous and
20
22
  * context-independent — see that file's header for the rationale.
21
- * - Widened 2026-07-21 to add `meter` (`aria-valuenow`), verified against
22
- * a widely-used reference engine's own `requiredAttrs` table. Deliberately
23
- * did NOT add two other entries from that same table: `progressbar`'s
24
- * `aria-valuenow` (a legitimately indeterminate progressbar omits it —
25
- * that engine itself excludes progressbar from its own table for this reason)
26
- * and `combobox`'s `aria-controls` (confirmed via MDN's combobox role
27
- * page to be conditional — only required once the popup is actually
23
+ * - `meter`'s `aria-valuenow` is required. Deliberately NOT required:
24
+ * `progressbar`'s `aria-valuenow` (a legitimately indeterminate
25
+ * progressbar omits it) and `combobox`'s `aria-controls` (conditional per
26
+ * MDN's combobox role page — only required once the popup is actually
28
27
  * displayed, not unconditionally). See src/core/aria-helpers.js's
29
28
  * REQUIRED_PROPS_BY_ROLE comment for the full reasoning.
30
29
  * - Gated on isAccTreeEligible for the element itself: unlike a syntax-
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -42,9 +44,7 @@
42
44
  * widget is missing required owned elements due to script execution or
43
45
  * loading, authors MUST mark a containing element with aria-busy equal
44
46
  * to true." A container carrying aria-busy="true" is skipped the same
45
- * way — only the exact string "true" counts (absent/"false" do not),
46
- * matching a widely-used reference engine's own aria-required-children
47
- * behavior.
47
+ * way — only the exact string "true" counts (absent/"false" do not).
48
48
  * - Descendant search tries a fast native querySelectorAll(CANDIDATE_
49
49
  * SELECTOR) first (covers the light-DOM-only common case with no added
50
50
  * cost); only when that finds nothing AND the container has a <slot>
@@ -53,8 +53,7 @@
53
53
  * querySelectorAll only sees a <slot>'s unrendered fallback content, never
54
54
  * what's actually distributed into it. Deliberately scoped to slot
55
55
  * expansion only, not a general "also descend into any nested custom
56
- * element's own shadow root" walk — no confirmed real-world case needs
57
- * that yet.
56
+ * element's own shadow root" walk — no known case needs that yet.
58
57
  */
59
58
 
60
59
  const id = 'aria-required-children';
@@ -133,17 +132,15 @@ function runInPage(ctx) {
133
132
  // <slot></slot>, with the actual role="listitem" elements living in the
134
133
  // light DOM and projected in) would never find them there — same class
135
134
  // of bug as aria-required-parent's ancestor search, just in the opposite
136
- // (descendant) direction. Found via Adobe Spectrum Web Components'
137
- // sp-sidenav-item: its shadow root's role="list" div owns its listitems
138
- // only through slot projection.
135
+ // (descendant) direction.
139
136
  //
140
137
  // Deliberately scoped to slot expansion only — does NOT separately
141
138
  // descend into an unrelated nested custom element's own shadow root
142
139
  // (e.g. a <my-widget> child with no <slot> involvement at all). That's a
143
140
  // qualitatively different question (does an arbitrary component's own
144
141
  // internal structure count as this container's "owned children"?) with
145
- // no confirmed real-world case driving it yet; slot projection is the
146
- // shape actually observed.
142
+ // no known case driving it yet; slot projection is the shape that
143
+ // actually comes up.
147
144
  function collectComposedDescendants(node, out, seen, limit) {
148
145
  if (!node || !node.children) return;
149
146
  for (const child of Array.from(node.children)) {
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -94,24 +96,20 @@ function runInPage(ctx) {
94
96
  }
95
97
 
96
98
  // Roles that may host a nested listitem/treeitem group without breaking
97
- // the required-context chain (verified against a widely-used reference
98
- // engine's own getMissingContext, which special-cases exactly these two
99
- // roles via its ownGroupRoles option).
99
+ // the required-context chain — the group role is transparent for exactly
100
+ // these two roles.
100
101
  const GROUP_TRANSPARENT_FOR_ROLES = new Set(['listitem', 'treeitem']);
101
102
 
102
103
  // A real ancestor role — not "no role at all" and not the two roles that
103
104
  // strip an element from the accessibility tree's parent/child chain
104
- // entirely (presentation/none) — stops the search, matching that reference
105
- // engine's getMissingContext. This is stricter than "any ancestor with the right
106
- // role anywhere up the tree": the required-context relationship is about
107
- // the accessibility tree's actual PARENT, so an intervening ancestor with
108
- // its OWN distinct real role (e.g. a plain <li>'s native "listitem" role)
109
- // blocks the search even if a further-up ancestor has the correct role.
110
- // Found via a real page — Le Monde's review-carousel tablist, where each
111
- // <button role="tab"> sits inside a plain <li> (native listitem) inside
112
- // <ul role="tablist">: that reference engine correctly fails this (the tablist is never the
113
- // tab's accessible-tree parent, listitem is), which the old "walk every
114
- // ancestor" version here missed entirely.
105
+ // entirely (presentation/none) — stops the search. This is stricter than
106
+ // "any ancestor with the right role anywhere up the tree": the
107
+ // required-context relationship is about the accessibility tree's actual
108
+ // PARENT, so an intervening ancestor with its OWN distinct real role
109
+ // (e.g. a plain <li>'s native "listitem" role) blocks the search even if
110
+ // a further-up ancestor has the correct role. E.g. a <button role="tab">
111
+ // inside a plain <li> (native listitem) inside <ul role="tablist"> fails:
112
+ // the tablist is never the tab's accessible-tree parent, the listitem is.
115
113
  function getRealContextRole(el) {
116
114
  const role = ariaHelpers.getContainmentRole(el);
117
115
  if (!role || role === 'presentation' || role === 'none') return '';
@@ -121,13 +119,11 @@ function runInPage(ctx) {
121
119
  // Flat-tree ancestor walk (ctx.helpers.composedParent — assignedSlot wins
122
120
  // over parentNode, then shadow host). A slotted light-DOM element's real
123
121
  // rendered ancestor is whatever the shadow tree wraps its <slot> in (e.g.
124
- // a role="list" container), not its own light-DOM parentElement — found
125
- // via Adobe Spectrum Web Components' <sp-sidenav-item role="listitem">,
126
- // distributed via slot="descendant" into its parent's shadow root, which
127
- // wraps that slot in a <div role="list">. composedParent can return a
128
- // non-Element node (a ShadowRoot, nodeType 11) when climbing out of a
129
- // shadow tree that has no further light-DOM parent — skip those and keep
130
- // climbing rather than treating them as a (roleless) context.
122
+ // a role="list" container), not its own light-DOM parentElement.
123
+ // composedParent can return a non-Element node (a ShadowRoot, nodeType
124
+ // 11) when climbing out of a shadow tree that has no further light-DOM
125
+ // parent — skip those and keep climbing rather than treating them as a
126
+ // (roleless) context.
131
127
  const getComposedParent =
132
128
  helpers && typeof helpers.composedParent === 'function'
133
129
  ? helpers.composedParent
@@ -142,10 +138,9 @@ function runInPage(ctx) {
142
138
  // Mutable working copy: passing a transparent "group" ancestor also
143
139
  // makes the element's OWN role an acceptable context from that point on
144
140
  // (a nested treeitem-under-group-under-treeitem chain is a normal,
145
- // arbitrarily-deep ARIA tree/list, not just one level) — mirroring that
146
- // reference engine's getMissingContext, which pushes explicitRole into
147
- // reqContext at the same point. Cloned lazily so the caller's Set (built
148
- // once per element in runInPage) is never mutated.
141
+ // arbitrarily-deep ARIA tree/list, not just one level). Cloned lazily
142
+ // so the caller's Set (built once per element in runInPage) is never
143
+ // mutated.
149
144
  let roles = acceptableRoles;
150
145
  while (cur && guard++ < 200) {
151
146
  if (cur.nodeType !== 1) {
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**