@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
  /**
@@ -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
  /**
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -65,19 +67,44 @@ function runInPage(ctx) {
65
67
  const occurrences = [];
66
68
  let applicableCount = 0;
67
69
 
70
+ // Programmatically hidden per the ACT glossary: display:none, visibility not
71
+ // visible, or aria-hidden on the element or an ancestor.
72
+ function isHidden(el) {
73
+ try {
74
+ if (typeof helpers.isDomVisibleEligible === 'function') {
75
+ if (!helpers.isDomVisibleEligible(el, ctx)) return true;
76
+ }
77
+ for (let n = el; n && n.getAttribute; n = n.parentElement) {
78
+ if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
79
+ }
80
+ } catch {
81
+ return false;
82
+ }
83
+ return false;
84
+ }
85
+
68
86
  for (const el of nodes) {
69
87
  if (!el || !el.getAttribute) continue;
70
88
 
71
- const role = ariaHelpers.getExplicitRole(el);
72
- if (!role) continue; // role="" or whitespace-only: not this rule's concern
89
+ // ACT 674b10 is not applicable to a programmatically hidden element.
90
+ if (isHidden(el)) continue;
73
91
 
74
- applicableCount += 1;
92
+ // role takes a fallback list and the first token the browser recognises
93
+ // wins, so role="searchfield searchbox" resolves to searchbox. The rule
94
+ // fails only when no token names a concrete role.
95
+ const tokens =
96
+ typeof ariaHelpers.getAllRoleTokens === 'function'
97
+ ? ariaHelpers.getAllRoleTokens(el)
98
+ : [ariaHelpers.getExplicitRole(el)].filter(Boolean);
99
+ if (!tokens.length) continue; // role="" or whitespace-only: not this rule's concern
75
100
 
76
- const isAbstract = ariaHelpers.isAbstractRole(role);
77
- const isKnown = ariaHelpers.isKnownRole(role);
101
+ applicableCount += 1;
78
102
 
79
- if (isKnown && !isAbstract) continue;
103
+ const usable = tokens.find((t) => ariaHelpers.isKnownRole(t) && !ariaHelpers.isAbstractRole(t));
104
+ if (usable) continue;
80
105
 
106
+ const role = tokens[0];
107
+ const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
81
108
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
82
109
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
83
110
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -13,25 +15,26 @@
13
15
  * @expectation
14
16
  * Each attribute's value conforms to its WAI-ARIA-declared value type:
15
17
  * boolean ("true"/"false"), tristate ("true"/"false"/"mixed"), a token
16
- * from a fixed enumerated set, an integer, a real number, or a non-empty
17
- * ID reference (list) that resolves to an existing element in the
18
- * document.
18
+ * from a fixed enumerated set, an integer, a real number, or an empty
19
+ * value or ID reference (list) that resolves to an existing element in
20
+ * the document.
19
21
  * @implementation-notes
20
22
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
21
23
  * property, while engine-level hidden-subtree filtering still applies
22
24
  * unless engineOptions.includeHiddenElements is true.
23
- * - ID-reference resolution (added 2026-07-20, see aria-helpers.js's
24
- * idExists) only flags idref-list attributes (aria-labelledby,
25
- * aria-describedby, aria-controls, aria-owns, etc.) when NONE of the
26
- * space-separated ids resolve — a partially-dangling list (some ids
27
- * exist, some don't) is left unflagged. Verified 2026-07-21 directly
28
- * against a widely-used reference engine's own `validateAttrValue` source:
29
- * this is not a conservative guess, it's an exact match for that engine's
30
- * own behavior (`idrefs(vNode, attr).some(node => !!node)` — that engine
31
- * itself only invalidates when every token fails to resolve). Single-idref
32
- * attributes (aria-activedescendant, aria-errormessage) are flagged
33
- * whenever their one id doesn't resolve, also matching that engine's `idref`
34
- * case exactly.
25
+ * - ID-reference resolution (see aria-helpers.js's idExists) only flags
26
+ * idref-list attributes (aria-labelledby, aria-describedby,
27
+ * aria-controls, aria-owns, etc.) when NONE of the space-separated ids
28
+ * resolve — a partially-dangling list (some ids exist, some don't) is
29
+ * left unflagged (only invalidate when every token fails to resolve).
30
+ * Single-idref attributes (aria-activedescendant, aria-errormessage) are
31
+ * flagged whenever their one id doesn't resolve.
32
+ * - An explicitly-EMPTY idref/idref-list value (e.g.
33
+ * `aria-describedby=""`) is valid, not a violation — `allowEmpty` holds
34
+ * for every idref/idref-list ARIA attribute. A common, deliberate pattern
35
+ * in templated markup (e.g. React conditionally rendering
36
+ * `aria-describedby={hasError ? errorId : ''}`); flagging it would be a
37
+ * false positive.
35
38
  */
36
39
 
37
40
  const id = 'aria-valid-attr-value';
@@ -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
  /**
@@ -128,7 +130,15 @@ function runInPage(ctx) {
128
130
  if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
129
131
  i += 1;
130
132
  if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
131
- if (CONTACT_MODALITY.has(tokens[i])) i += 1;
133
+ // A contact modality token is only allowed when the field that follows is
134
+ // a contact field, so "work photo" is invalid while "work email" is not.
135
+ if (CONTACT_MODALITY.has(tokens[i])) {
136
+ const next = tokens[i + 1];
137
+ const isContactField =
138
+ next === 'email' || next === 'impp' || next === 'tel' || (next || '').startsWith('tel-');
139
+ if (!isContactField) return false;
140
+ i += 1;
141
+ }
132
142
 
133
143
  let end = tokens.length;
134
144
  if (tokens[end - 1] === 'webauthn') end -= 1;
@@ -145,11 +155,39 @@ function runInPage(ctx) {
145
155
  const occurrences = [];
146
156
  let applicableCount = 0;
147
157
 
158
+ // ACT 73f2c2 exempts controls where the attribute cannot describe an input
159
+ // purpose: the on/off toggle, disabled controls, input types with a fixed
160
+ // value, and controls that take no input.
161
+ const FIXED_VALUE_TYPES = new Set([
162
+ 'button',
163
+ 'checkbox',
164
+ 'file',
165
+ 'image',
166
+ 'radio',
167
+ 'reset',
168
+ 'submit'
169
+ ]);
170
+
171
+ function isExempt(el) {
172
+ const tag = String(el.tagName || '').toLowerCase();
173
+ if (tag === 'input') {
174
+ const type = String(el.getAttribute('type') || 'text').toLowerCase();
175
+ if (FIXED_VALUE_TYPES.has(type)) return true;
176
+ }
177
+ if (el.hasAttribute && el.hasAttribute('disabled')) return true;
178
+ if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
179
+ return false;
180
+ }
181
+
148
182
  for (const el of nodes) {
149
183
  if (!el || !el.getAttribute) continue;
150
184
  const raw = String(el.getAttribute('autocomplete') || '').trim();
151
185
  if (!raw) continue;
152
186
 
187
+ const tokens = raw.toLowerCase().split(/\s+/).filter(Boolean);
188
+ if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) continue;
189
+ if (isExempt(el)) continue;
190
+
153
191
  applicableCount += 1;
154
192
 
155
193
  if (isValidAutocomplete(raw)) continue;