@surea11y/core 1.2.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. 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
@@ -111,7 +81,8 @@ const id = 'aria-prohibited-children';
111
81
 
112
82
  const meta = {
113
83
  title: 'Container roles must not own a child with a disallowed role',
114
- description: 'Checks that every accessible-tree-owned child of a container role (list, listbox, menu, menubar, radiogroup, rowgroup, table, grid, treegrid, tablist, tree, row) has one of that role\'s allowed owned roles — the same set as its required owned roles.',
84
+ description:
85
+ "Checks that every accessible-tree-owned child of a container role (list, listbox, menu, menubar, radiogroup, rowgroup, table, grid, treegrid, tablist, tree, row) has one of that role's allowed owned roles — the same set as its required owned roles.",
115
86
  i18n: {
116
87
  titleKey: 'ariaProhibitedChildren_title',
117
88
  descriptionKey: 'ariaProhibitedChildren_description'
@@ -120,7 +91,13 @@ const meta = {
120
91
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
121
92
  wcagSc: ['4.1.2'],
122
93
  normativeMappings: [
123
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
94
+ {
95
+ standard: 'WCAG',
96
+ version: '2.2',
97
+ requirement: '4.1.2',
98
+ title: 'Name, Role, Value',
99
+ conformanceLevel: 'A'
100
+ }
124
101
  ],
125
102
  defaultSeverity: 'moderate',
126
103
  category: 'robust',
@@ -130,7 +107,7 @@ const meta = {
130
107
  };
131
108
 
132
109
  function runInPage(ctx) {
133
- const { document, helpers, rule } = ctx;
110
+ const { helpers, rule } = ctx;
134
111
 
135
112
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
136
113
  if (!ariaHelpers) {
@@ -138,7 +115,8 @@ function runInPage(ctx) {
138
115
  }
139
116
 
140
117
  function isEligibleAcc(el) {
141
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
118
+ const fn =
119
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
142
120
  if (!fn) return true;
143
121
  try {
144
122
  const r = fn(el, ctx);
@@ -153,14 +131,32 @@ function runInPage(ctx) {
153
131
  // aria-allowed-attr.js's GLOBAL_ATTRS — duplicated, not imported, since
154
132
  // runInPage must be self-contained per scripts/build-core.js). A
155
133
  // roleless descendant carrying any of these is a real accessible-tree
156
- // node a widely-used reference engine's getOwnedRoles also flags, not a transparent wrapper.
134
+ // node, not a transparent wrapper.
157
135
  const GLOBAL_ARIA_ATTRS = [
158
- 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
159
- 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description',
160
- 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
161
- 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
162
- 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
163
- 'aria-relevant', 'aria-roledescription'
136
+ 'aria-atomic',
137
+ 'aria-braillelabel',
138
+ 'aria-brailleroledescription',
139
+ 'aria-busy',
140
+ 'aria-controls',
141
+ 'aria-current',
142
+ 'aria-describedby',
143
+ 'aria-description',
144
+ 'aria-details',
145
+ 'aria-disabled',
146
+ 'aria-dropeffect',
147
+ 'aria-errormessage',
148
+ 'aria-flowto',
149
+ 'aria-grabbed',
150
+ 'aria-haspopup',
151
+ 'aria-hidden',
152
+ 'aria-invalid',
153
+ 'aria-keyshortcuts',
154
+ 'aria-label',
155
+ 'aria-labelledby',
156
+ 'aria-live',
157
+ 'aria-owns',
158
+ 'aria-relevant',
159
+ 'aria-roledescription'
164
160
  ];
165
161
 
166
162
  function getGlobalAriaAttr(el) {
@@ -179,13 +175,12 @@ function runInPage(ctx) {
179
175
  // non-transparent role boundary otherwise — see header comment. A
180
176
  // roleless descendant is ALSO a non-transparent boundary (an owned
181
177
  // entry with role: null, which can never satisfy a required-role set)
182
- // when it carries a global aria-* attribute or is focusable — matches
183
- // a widely-used reference engine's own getOwnedRoles exactly (see header comment).
184
- // kidRole comes from getContainmentRole, not getExplicitRole (see header
185
- // comment's 2026-07-31 fix): "roleless" here means neither an explicit
186
- // role="" NOR one of the native containment tags (li, tr, td, ...), so a
187
- // bare <li>/<tr>/... is a real listitem/row boundary, not a transparent
188
- // 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.
189
184
  function collectOwnedRoles(el, requiredSet, out, depth) {
190
185
  if (depth > MAX_DEPTH) return;
191
186
  const kids = el.children ? Array.prototype.slice.call(el.children) : [];
@@ -195,13 +190,17 @@ function runInPage(ctx) {
195
190
 
196
191
  const kidRole = ariaHelpers.getContainmentRole(kid);
197
192
  const isPresentational = kidRole === 'presentation' || kidRole === 'none';
198
- const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
193
+ const isTransparentGroup =
194
+ (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
199
195
 
200
196
  if (!kidRole && !isPresentational) {
201
197
  const globalAttr = getGlobalAriaAttr(kid);
202
- let mechanism = 'none';
198
+ let mechanism;
203
199
  try {
204
- const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
200
+ const fi =
201
+ helpers && typeof helpers.getFocusableInfo === 'function'
202
+ ? helpers.getFocusableInfo(kid, ctx)
203
+ : null;
205
204
  mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
206
205
  } catch {
207
206
  mechanism = 'none';
@@ -230,7 +229,9 @@ function runInPage(ctx) {
230
229
  }
231
230
  }
232
231
 
233
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
232
+ const nodes = helpers.queryAllSmart
233
+ ? helpers.queryAllSmart('[role]')
234
+ : helpers.queryAll('[role]');
234
235
 
235
236
  const occurrences = [];
236
237
  let applicableCount = 0;
@@ -256,7 +257,9 @@ function runInPage(ctx) {
256
257
  if (entry.role && requiredSet.has(entry.role)) continue;
257
258
 
258
259
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
259
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(entry.el) : (entry.el.outerHTML || '');
260
+ const html = helpers.getOuterHtmlSnippet
261
+ ? helpers.getOuterHtmlSnippet(entry.el)
262
+ : entry.el.outerHTML || '';
260
263
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
261
264
 
262
265
  const isRoleless = !entry.role;
@@ -313,7 +316,12 @@ function runInPage(ctx) {
313
316
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
314
317
  }
315
318
  if (occurrences.length) {
316
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
319
+ return {
320
+ ruleId: rule.ruleId,
321
+ outcome: 'fail',
322
+ severity: rule.defaultSeverity || 'moderate',
323
+ occurrences
324
+ };
317
325
  }
318
326
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
319
327
  }
@@ -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-
@@ -50,7 +49,8 @@ const id = 'aria-required-attr';
50
49
 
51
50
  const meta = {
52
51
  title: 'Roles with a required ARIA state/property must carry it',
53
- description: 'Checks that elements with an explicit role carry every unambiguous, context-independent required aria-* state/property for that role (e.g. role="checkbox" must have aria-checked).',
52
+ description:
53
+ 'Checks that elements with an explicit role carry every unambiguous, context-independent required aria-* state/property for that role (e.g. role="checkbox" must have aria-checked).',
54
54
  i18n: {
55
55
  titleKey: 'ariaRequiredAttr_title',
56
56
  descriptionKey: 'ariaRequiredAttr_description'
@@ -59,7 +59,13 @@ const meta = {
59
59
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
60
60
  wcagSc: ['4.1.2'],
61
61
  normativeMappings: [
62
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
62
+ {
63
+ standard: 'WCAG',
64
+ version: '2.2',
65
+ requirement: '4.1.2',
66
+ title: 'Name, Role, Value',
67
+ conformanceLevel: 'A'
68
+ }
63
69
  ],
64
70
  defaultSeverity: 'serious',
65
71
  category: 'robust',
@@ -69,7 +75,7 @@ const meta = {
69
75
  };
70
76
 
71
77
  function runInPage(ctx) {
72
- const { document, helpers, rule } = ctx;
78
+ const { helpers, rule } = ctx;
73
79
 
74
80
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
75
81
  if (!ariaHelpers) {
@@ -77,7 +83,8 @@ function runInPage(ctx) {
77
83
  }
78
84
 
79
85
  function isEligibleAcc(el) {
80
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
86
+ const fn =
87
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
81
88
  if (!fn) return true;
82
89
  try {
83
90
  const r = fn(el, ctx);
@@ -93,7 +100,9 @@ function runInPage(ctx) {
93
100
  return v != null && String(v).trim().toLowerCase() === 'true';
94
101
  }
95
102
 
96
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
103
+ const nodes = helpers.queryAllSmart
104
+ ? helpers.queryAllSmart('[role]')
105
+ : helpers.queryAll('[role]');
97
106
 
98
107
  const occurrences = [];
99
108
  let applicableCount = 0;
@@ -121,7 +130,7 @@ function runInPage(ctx) {
121
130
  if (!missing.length) continue;
122
131
 
123
132
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
124
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
133
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
125
134
 
126
135
  for (const attr of missing) {
127
136
  occurrences.push({
@@ -145,9 +154,14 @@ function runInPage(ctx) {
145
154
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
146
155
  }
147
156
  if (occurrences.length) {
148
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
157
+ return {
158
+ ruleId: rule.ruleId,
159
+ outcome: 'fail',
160
+ severity: rule.defaultSeverity || 'serious',
161
+ occurrences
162
+ };
149
163
  }
150
164
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
151
165
  }
152
166
 
153
- module.exports = { id, meta, runInPage };
167
+ 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
  /**
@@ -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,15 +53,15 @@
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';
61
60
 
62
61
  const meta = {
63
62
  title: 'Container roles must own at least one required child role',
64
- description: 'Checks that container roles with a documented "required owned elements" entry (list, listbox, menu, radiogroup, table, grid, tablist, tree, row, ...) contain at least one descendant or aria-owns-referenced element with an acceptable owned role.',
63
+ description:
64
+ 'Checks that container roles with a documented "required owned elements" entry (list, listbox, menu, radiogroup, table, grid, tablist, tree, row, ...) contain at least one descendant or aria-owns-referenced element with an acceptable owned role.',
65
65
  i18n: {
66
66
  titleKey: 'ariaRequiredChildren_title',
67
67
  descriptionKey: 'ariaRequiredChildren_description'
@@ -70,7 +70,13 @@ const meta = {
70
70
  tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
71
71
  wcagSc: ['4.1.2'],
72
72
  normativeMappings: [
73
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
73
+ {
74
+ standard: 'WCAG',
75
+ version: '2.2',
76
+ requirement: '4.1.2',
77
+ title: 'Name, Role, Value',
78
+ conformanceLevel: 'A'
79
+ }
74
80
  ],
75
81
  defaultSeverity: 'moderate',
76
82
  category: 'robust',
@@ -80,7 +86,7 @@ const meta = {
80
86
  };
81
87
 
82
88
  function runInPage(ctx) {
83
- const { document, helpers, rule } = ctx;
89
+ const { helpers, rule } = ctx;
84
90
 
85
91
  const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
86
92
  if (!ariaHelpers) {
@@ -88,7 +94,8 @@ function runInPage(ctx) {
88
94
  }
89
95
 
90
96
  function isEligibleAcc(el) {
91
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
97
+ const fn =
98
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
92
99
  if (!fn) return true;
93
100
  try {
94
101
  const r = fn(el, ctx);
@@ -109,9 +116,12 @@ function runInPage(ctx) {
109
116
  // (kept in sync with aria-helpers.js NATIVE_CONTAINMENT_ROLE_BY_ELEMENT).
110
117
  // Declared inside runInPage — see scripts/build-core.js header
111
118
  // ("runInPage MUST be self-contained").
112
- const CANDIDATE_SELECTOR = '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
119
+ const CANDIDATE_SELECTOR =
120
+ '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
113
121
 
114
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]') : helpers.queryAll('[role]');
122
+ const nodes = helpers.queryAllSmart
123
+ ? helpers.queryAllSmart('[role]')
124
+ : helpers.queryAll('[role]');
115
125
 
116
126
  const occurrences = [];
117
127
  let applicableCount = 0;
@@ -122,25 +132,26 @@ function runInPage(ctx) {
122
132
  // <slot></slot>, with the actual role="listitem" elements living in the
123
133
  // light DOM and projected in) would never find them there — same class
124
134
  // of bug as aria-required-parent's ancestor search, just in the opposite
125
- // (descendant) direction. Found via Adobe Spectrum Web Components'
126
- // sp-sidenav-item: its shadow root's role="list" div owns its listitems
127
- // only through slot projection.
135
+ // (descendant) direction.
128
136
  //
129
137
  // Deliberately scoped to slot expansion only — does NOT separately
130
138
  // descend into an unrelated nested custom element's own shadow root
131
139
  // (e.g. a <my-widget> child with no <slot> involvement at all). That's a
132
140
  // qualitatively different question (does an arbitrary component's own
133
141
  // internal structure count as this container's "owned children"?) with
134
- // no confirmed real-world case driving it yet; slot projection is the
135
- // shape actually observed.
142
+ // no known case driving it yet; slot projection is the shape that
143
+ // actually comes up.
136
144
  function collectComposedDescendants(node, out, seen, limit) {
137
145
  if (!node || !node.children) return;
138
146
  for (const child of Array.from(node.children)) {
139
147
  if (out.length >= limit) return;
140
148
  if (seen.has(child)) continue;
141
149
 
142
- if ((child.tagName || '').toLowerCase() === 'slot' && typeof child.assignedElements === 'function') {
143
- let assigned = [];
150
+ if (
151
+ (child.tagName || '').toLowerCase() === 'slot' &&
152
+ typeof child.assignedElements === 'function'
153
+ ) {
154
+ let assigned;
144
155
  try {
145
156
  assigned = child.assignedElements({ flatten: true }) || [];
146
157
  } catch {
@@ -184,7 +195,7 @@ function runInPage(ctx) {
184
195
  // selector, exactly as before this fix — covers the overwhelming
185
196
  // majority of containers (no shadow DOM involved at all) with zero
186
197
  // added cost.
187
- let descendants = [];
198
+ let descendants;
188
199
  try {
189
200
  descendants = el.querySelectorAll(CANDIDATE_SELECTOR);
190
201
  } catch {
@@ -202,7 +213,7 @@ function runInPage(ctx) {
202
213
  // <slot> somewhere in the subtree to expand — bounds the extra cost to
203
214
  // exactly the containers that could possibly need it.
204
215
  if (!found) {
205
- let hasSlot = false;
216
+ let hasSlot;
206
217
  try {
207
218
  hasSlot = !!el.querySelector('slot');
208
219
  } catch {
@@ -243,7 +254,7 @@ function runInPage(ctx) {
243
254
  if (found) continue;
244
255
 
245
256
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
246
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
257
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
247
258
 
248
259
  occurrences.push({
249
260
  selector: stableSelector,
@@ -256,7 +267,11 @@ function runInPage(ctx) {
256
267
  params: { role, requiredRoles: requiredOwned.join(', ') }
257
268
  },
258
269
  data: {
259
- details: { reasonCode: 'ARIA_REQUIRED_CHILD_MISSING', role, requiredOwnedRoles: requiredOwned }
270
+ details: {
271
+ reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
272
+ role,
273
+ requiredOwnedRoles: requiredOwned
274
+ }
260
275
  }
261
276
  });
262
277
  }
@@ -265,9 +280,14 @@ function runInPage(ctx) {
265
280
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
266
281
  }
267
282
  if (occurrences.length) {
268
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
283
+ return {
284
+ ruleId: rule.ruleId,
285
+ outcome: 'fail',
286
+ severity: rule.defaultSeverity || 'moderate',
287
+ occurrences
288
+ };
269
289
  }
270
290
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
271
291
  }
272
292
 
273
- module.exports = { id, meta, runInPage };
293
+ module.exports = { id, meta, runInPage };