@surea11y/core 1.5.0 → 1.7.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -7,28 +7,43 @@
7
7
  * @atomic true
8
8
  * @summary Container roles must not own an accessible-tree child with a disallowed role
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid role that is one of the
13
13
  * container roles with a documented "required owned elements" entry
14
14
  * (the same REQUIRED_OWNED_ROLES table aria-required-children
15
- * uses — see src/core/aria-helpers.js).
15
+ * uses, see src/core/aria-helpers.js).
16
16
  * @expectation
17
17
  * Every accessible-tree-owned descendant of the container (after
18
18
  * pruning role="none"/"presentation" elements and any "group"/
19
- * "rowgroup" wrapper whose role is itself one of the required roles —
20
- * both are structurally transparent, same as WAI-ARIA's own
21
- * accessibility-tree construction) has a role from that same required-
22
- * owned set. Nothing else is a structurally valid direct child of a
23
- * composite/container role.
19
+ * "rowgroup" wrapper, both always transparent for owned-element
20
+ * matching per WAI-ARIA, regardless of whether "group"/"rowgroup" is
21
+ * itself in the container's own required-owned-roles set) has a role
22
+ * from that same required-owned set. Nothing else is a structurally
23
+ * valid direct child of a composite/container role, where "allowed" is
24
+ * the container's required-owned roles plus the small
25
+ * ALLOWED_EXTRA_OWNED_ROLES set of roles it may own without being
26
+ * required to (a separator between menu items, a caption on a grid). A
27
+ * roleless wrapper
28
+ * is descended into to reach the items a component library buries
29
+ * inside it, but once one is found there the rest of that wrapper's
30
+ * subtree is the item's own content and is not judged against the
31
+ * container.
24
32
  * @implementation-notes
25
33
  * - A distinct atomic decision from aria-required-children (see that
26
34
  * 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 =
35
+ * child one of the allowed roles", split per this repo's "one rule =
28
36
  * 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.
37
+ * - The "allowed owned roles" set is REQUIRED_OWNED_ROLES plus
38
+ * ALLOWED_EXTRA_OWNED_ROLES, because WAI-ARIA's "Required Owned
39
+ * Elements" says what a container MUST contain, not the exhaustive list
40
+ * of what it MAY contain. The extra table is kept small, and each entry
41
+ * needs a source: ARIA giving the child a Required Context Role that
42
+ * names this container (caption in table/grid), or the child role's own
43
+ * definition placing it there (separator in menu/menubar). Recorded and
44
+ * validated in scripts/generate-aria-tables.js. The two sets are
45
+ * used for different questions: only a REQUIRED role makes a roleless
46
+ * wrapper an item wrapper, while the allowed set decides the verdict.
32
47
  * - A ROLELESS descendant that has any global WAI-ARIA attribute or is
33
48
  * focusable is also flagged: it's treated as an owned entry with
34
49
  * `role: null`, which can never match a container's required-owned-roles
@@ -42,16 +57,39 @@
42
57
  * focusability (e.g. an `<a href>`), so the reported `data.details.attr`
43
58
  * and message correctly say `nativeFocusable` rather than claiming a
44
59
  * tabindex attribute that isn't actually present in the markup.
60
+ * - A roleless wrapper is transparent only as a route to the items inside
61
+ * it, never as a way to attribute the item's own content to the
62
+ * container. Unlike role="none"/"presentation", a roleless element is
63
+ * NOT removed from the accessibility tree: it is exposed as a generic
64
+ * node, so strictly nothing inside it is the container's child at all.
65
+ * The walk descends anyway, because component markup routinely buries
66
+ * the real item several roleless levels down (an Angular Material card
67
+ * whose radio sits at card > header > mat-radio-button > div > div >
68
+ * input[type=radio]) and refusing to descend would report every such
69
+ * container as owning nothing. Applied in both directions, that
70
+ * leniency turned every role-bearing element anywhere in an item's
71
+ * subtree into an owned child of the container: a `role="separator"`
72
+ * dividing two columns inside a radio card was reported as a
73
+ * prohibited child of the radiogroup. So when a roleless wrapper turns
74
+ * out to hold a required item, only the items it holds are collected;
75
+ * a wrapper holding no item at all is interposed content, and
76
+ * everything found inside it is still reported.
45
77
  * - Recursion stops at the first non-transparent role boundary: a nested
46
78
  * container with its own real role
47
79
  * (e.g. a `<div role="listbox">` inside a menubar) is evaluated as its
48
80
  * 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.
81
+ * gets its own applicability pass as a container in the same rule run),
82
+ * so its descendants are never misattributed to the outer container.
83
+ * "group"/"rowgroup" are always transparent instead, for any container
84
+ * role, not only the few (menu, menubar, tree) whose own required-owned
85
+ * set names "group" as an acceptable leaf role. Confirmed by ACT
86
+ * bc4a75's own examples, e.g. a `role="list"` (no "group" in its
87
+ * required-owned set) still treating a `role="group"` wrapper as
88
+ * transparent.
51
89
  * - Child-role resolution uses `ariaHelpers.getContainmentRole` (explicit
52
- * role, falling back to the native-tag map — li/tr/td/th/tbody/ul/ol/
90
+ * role, falling back to the native-tag map: li/tr/td/th/tbody/ul/ol/
53
91
  * table/select/input[type=radio]), the same resolution
54
- * aria-required-children's descendant matching uses — not
92
+ * aria-required-children's descendant matching uses, not
55
93
  * `getExplicitRole`, which only sees an explicit role="" attribute. A
56
94
  * bare `<li>` with no role="" (the common CSS-reset workaround
57
95
  * `<ul role="list"><li>...</li></ul>`) must still resolve to the
@@ -67,14 +105,14 @@
67
105
  * for correctness (an ineligible container has no eligible descendants
68
106
  * either, so `owned` ends up empty and nothing fails), but skipping the
69
107
  * 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
108
+ * `pass`, the more accurate outcome for a container that isn't
109
+ * currently exposed at all, and avoids walking a subtree whose result
72
110
  * is already known.
73
111
  * - No aria-busy exemption here (unlike aria-required-children): the
74
112
  * WAI-ARIA spec's aria-busy escape hatch is specifically about a
75
113
  * container missing its required owned elements while loading, not
76
- * about a container that already has extra/disallowed owned elements —
77
- * that scenario isn't this rule's concern.
114
+ * about a container that already has extra/disallowed owned elements.
115
+ * That scenario isn't this rule's concern.
78
116
  */
79
117
 
80
118
  const id = 'aria-prohibited-children';
@@ -82,20 +120,20 @@ const id = 'aria-prohibited-children';
82
120
  const meta = {
83
121
  title: 'Container roles must not own a child with a disallowed role',
84
122
  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.",
123
+ "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.",
86
124
  i18n: {
87
125
  titleKey: 'ariaProhibitedChildren_title',
88
126
  descriptionKey: 'ariaProhibitedChildren_description'
89
127
  },
90
128
  helpUrl: null,
91
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
92
- wcagSc: ['4.1.2'],
129
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
130
+ wcagSc: ['1.3.1'],
93
131
  normativeMappings: [
94
132
  {
95
133
  standard: 'WCAG',
96
134
  version: '2.2',
97
- requirement: '4.1.2',
98
- title: 'Name, Role, Value',
135
+ requirement: '1.3.1',
136
+ title: 'Info and Relationships',
99
137
  conformanceLevel: 'A'
100
138
  }
101
139
  ],
@@ -103,7 +141,7 @@ const meta = {
103
141
  category: 'robust',
104
142
  type: 'automatic',
105
143
  defaultConfidence: 'medium',
106
- coverage: { facetsBySc: { '4.1.2': ['aria-role-owned-children-allowed'] } }
144
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-owned-children-allowed'] } }
107
145
  };
108
146
 
109
147
  function runInPage(ctx) {
@@ -128,7 +166,7 @@ function runInPage(ctx) {
128
166
  }
129
167
 
130
168
  // The WAI-ARIA "Global States and Properties" set (same list as
131
- // aria-allowed-attr.js's GLOBAL_ATTRS — duplicated, not imported, since
169
+ // aria-allowed-attr.js's GLOBAL_ATTRS, duplicated rather than imported, since
132
170
  // runInPage must be self-contained per scripts/build-core.js). A
133
171
  // roleless descendant carrying any of these is a real accessible-tree
134
172
  // node, not a transparent wrapper.
@@ -167,21 +205,41 @@ function runInPage(ctx) {
167
205
  return null;
168
206
  }
169
207
 
208
+ // Roles a container may own beyond its REQUIRED owned elements. WAI-ARIA's
209
+ // "Required Owned Elements" says what a container must contain, not the
210
+ // exhaustive list of what it may contain; using the required set as both
211
+ // reported a separator between menu items, and a caption on a grid, as
212
+ // prohibited children. Generated from scripts/generate-aria-tables.js, which
213
+ // documents the source for every entry and validates each against
214
+ // aria-query's Required Context Role data.
215
+ // <generated:aria-allowed-extra-owned-roles>
216
+ const ALLOWED_EXTRA_OWNED_ROLES = {
217
+ grid: ['caption'],
218
+ menu: ['separator'],
219
+ menubar: ['separator'],
220
+ table: ['caption']
221
+ };
222
+ // </generated:aria-allowed-extra-owned-roles>
223
+
170
224
  const MAX_DEPTH = 40;
171
225
 
172
226
  // Collects this container's owned-role entries, pruning role="none"/
173
- // "presentation" and required-matching "group"/"rowgroup" wrappers as
174
- // transparent (recursing through them), and stopping at the first
175
- // non-transparent role boundary otherwise — see header comment. A
176
- // roleless descendant is ALSO a non-transparent boundary (an owned
177
- // entry with role: null, which can never satisfy a required-role set)
178
- // when it carries a global aria-* attribute or is focusable.
227
+ // "presentation" and "group"/"rowgroup" wrappers as transparent
228
+ // (recursing through them unconditionally, see header comment), and
229
+ // stopping at the first non-transparent role boundary otherwise. A
230
+ // roleless wrapper is transparent too, but only as a way to reach the
231
+ // items buried inside it: once one is found there, the rest of that
232
+ // wrapper's subtree belongs to the item, not to this container (see the
233
+ // roleless branch below). A roleless descendant is a non-transparent
234
+ // boundary (an owned entry with role: null, which can never satisfy a
235
+ // required-role set) when it carries a global aria-* attribute or is
236
+ // focusable.
179
237
  // kidRole comes from getContainmentRole, not getExplicitRole: "roleless"
180
238
  // here means neither an explicit role="" NOR one of the native
181
239
  // containment tags (li, tr, td, ...), so a bare <li>/<tr>/... is a real
182
240
  // listitem/row boundary, not a transparent wrapper the walk should pass
183
241
  // through.
184
- function collectOwnedRoles(el, requiredSet, out, depth) {
242
+ function collectOwnedRoles(el, out, depth, requiredSet) {
185
243
  if (depth > MAX_DEPTH) return;
186
244
  const kids = el.children ? Array.prototype.slice.call(el.children) : [];
187
245
  for (const kid of kids) {
@@ -190,8 +248,7 @@ function runInPage(ctx) {
190
248
 
191
249
  const kidRole = ariaHelpers.getContainmentRole(kid);
192
250
  const isPresentational = kidRole === 'presentation' || kidRole === 'none';
193
- const isTransparentGroup =
194
- (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
251
+ const isTransparentGroup = kidRole === 'group' || kidRole === 'rowgroup';
195
252
 
196
253
  if (!kidRole && !isPresentational) {
197
254
  const globalAttr = getGlobalAriaAttr(kid);
@@ -207,7 +264,7 @@ function runInPage(ctx) {
207
264
  }
208
265
  if (globalAttr || mechanism !== 'none') {
209
266
  // `mechanism` distinguishes an actual tabindex="" attribute from
210
- // native focusability (e.g. <a href>, <button>, <input>) — these
267
+ // native focusability (e.g. <a href>, <button>, <input>): these
211
268
  // are different facts and must not be reported as the same
212
269
  // "carries tabindex" claim (a native anchor with no tabindex
213
270
  // attribute at all is not "carrying tabindex").
@@ -217,8 +274,37 @@ function runInPage(ctx) {
217
274
  }
218
275
  }
219
276
 
220
- if (!kidRole || isPresentational || isTransparentGroup) {
221
- collectOwnedRoles(kid, requiredSet, out, depth + 1);
277
+ if (isPresentational || isTransparentGroup) {
278
+ // role="none"/"presentation" really is removed from the accessibility
279
+ // tree, and its children are promoted to this container, so whatever
280
+ // is inside becomes an owned child in its own right. group/rowgroup stay
281
+ // unconditionally transparent for the reason in the header comment.
282
+ collectOwnedRoles(kid, out, depth + 1, requiredSet);
283
+ continue;
284
+ }
285
+
286
+ if (!kidRole) {
287
+ // A roleless wrapper is NOT removed from the accessibility tree: it is
288
+ // exposed as a generic node, so strictly speaking nothing inside it is
289
+ // this container's child at all. The walk descends anyway, because a
290
+ // component library routinely buries the real item several roleless
291
+ // levels down (an Angular Material card whose radio sits at
292
+ // card > header > mat-radio-button > div > div > input[type=radio]),
293
+ // and refusing to descend would report every such container as
294
+ // missing its items.
295
+ //
296
+ // That leniency has to run one way only. If the wrapper turns out to
297
+ // hold a required item, the wrapper is an item wrapper and everything
298
+ // else inside it is the ITEM's content, not the container's children:
299
+ // a mat-divider sitting in the card body beside the radio is not an
300
+ // owned child of the radiogroup, and reporting it as one is a false
301
+ // positive on ordinary component markup. Only the items are collected
302
+ // in that case. A wrapper holding no item at all is pure interposed
303
+ // content, so everything found in it is still reported.
304
+ const nested = [];
305
+ collectOwnedRoles(kid, nested, depth + 1, requiredSet);
306
+ const items = nested.filter((entry) => entry.role && requiredSet.has(entry.role));
307
+ for (const entry of items.length ? items : nested) out.push(entry);
222
308
  continue;
223
309
  }
224
310
 
@@ -249,12 +335,19 @@ function runInPage(ctx) {
249
335
 
250
336
  applicableCount += 1;
251
337
 
338
+ // Two different sets on purpose. requiredSet drives the item-wrapper
339
+ // detection in collectOwnedRoles: only a REQUIRED role makes a roleless
340
+ // wrapper an item wrapper, so a wrapper holding nothing but a separator is
341
+ // still interposed content. allowedRoles decides the verdict, and includes
342
+ // the roles a container may own without being required to.
252
343
  const requiredSet = new Set(requiredOwned);
344
+ const allowedRoles = requiredOwned.concat(ALLOWED_EXTRA_OWNED_ROLES[role] || []);
345
+ const allowedSet = new Set(allowedRoles);
253
346
  const owned = [];
254
- collectOwnedRoles(el, requiredSet, owned, 0);
347
+ collectOwnedRoles(el, owned, 0, requiredSet);
255
348
 
256
349
  for (const entry of owned) {
257
- if (entry.role && requiredSet.has(entry.role)) continue;
350
+ if (entry.role && allowedSet.has(entry.role)) continue;
258
351
 
259
352
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
260
353
 
@@ -277,7 +370,7 @@ function runInPage(ctx) {
277
370
  hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
278
371
  } else {
279
372
  summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
280
- hint = `Remove or change this role so it matches one of the container's allowed owned roles (${requiredOwned.join(', ')}), or move this element outside the ${role} container.`;
373
+ hint = `Remove or change this role so it matches one of the container's allowed owned roles (${allowedRoles.join(', ')}), or move this element outside the ${role} container.`;
281
374
  summaryKey = 'ariaProhibitedChildren_summary_fail';
282
375
  hintKey = 'ariaProhibitedChildren_hint_fail';
283
376
  }
@@ -294,7 +387,7 @@ function runInPage(ctx) {
294
387
  : {
295
388
  childRole: entry.role,
296
389
  containerRole: role,
297
- allowedRoles: requiredOwned.join(', ')
390
+ allowedRoles: allowedRoles.join(', ')
298
391
  }
299
392
  },
300
393
  data: {
@@ -304,7 +397,7 @@ function runInPage(ctx) {
304
397
  attr: entry.attr,
305
398
  containerRole: role,
306
399
  containerSelector,
307
- allowedOwnedRoles: requiredOwned
400
+ allowedOwnedRoles: allowedRoles
308
401
  }
309
402
  }
310
403
  })
@@ -12,24 +12,50 @@
12
12
  * Applies to elements with an explicit, valid, non-abstract role that is
13
13
  * also one of the small set of roles with a documented, context-
14
14
  * independent required state/property (checkbox, combobox, heading,
15
- * menuitemcheckbox, menuitemradio, meter, radio, scrollbar, slider,
16
- * switch).
15
+ * menuitemcheckbox, menuitemradio, meter, radio, scrollbar, separator,
16
+ * slider, switch) -- except when that explicit role is identical to the
17
+ * element's own native/implicit role (ACT 4e8ab6: e.g.
18
+ * <input type="checkbox" role="checkbox">, which is exempt because the
19
+ * native control's own state exposure already covers it; no aria-checked
20
+ * is required. helpers.aria.getNativeRoleForElement resolves this).
17
21
  * @expectation
18
- * Every required aria-* attribute for that role is present (and non-empty).
22
+ * Every required state/property for that role is present and non-empty.
23
+ * Graded by whether ARIA supplies a stand-in for the missing attribute:
24
+ * - FAIL where it does not, so the state is simply not exposed
25
+ * (aria-checked on checkbox/radio/switch/menuitemcheckbox/menuitemradio,
26
+ * aria-valuenow on slider/scrollbar/meter and on a focusable separator).
27
+ * - CANTTELL where ARIA defines an implicit value the role falls back to
28
+ * (aria-expanded on combobox, aria-level on heading), so the role still
29
+ * exposes a value and only the author knows whether it is the right one.
19
30
  * @implementation-notes
20
- * - Deliberately scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
31
+ * - The implicit-value table is generated from aria-query's requiredProps by
32
+ * scripts/generate-aria-tables.js (REQUIRED_PROP_IMPLICIT_VALUES in
33
+ * src/core/aria-helpers.js), so the two tiers cannot drift apart from the
34
+ * spec by hand. ACT 4e8ab6 maps this rule's requirement to WAI-ARIA rather
35
+ * than to WCAG, and names 1.3.1/4.1.2 as "less strict" precisely because
36
+ * they "allow for fallback default values"; the cantTell tier is that
37
+ * carve-out, not a softening of the fail tier.
38
+ * - Scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
21
39
  * which only lists a required property when the spec is unambiguous and
22
- * context-independent — see that file's header for the rationale.
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
27
- * displayed, not unconditionally). See src/core/aria-helpers.js's
28
- * REQUIRED_PROPS_BY_ROLE comment for the full reasoning.
40
+ * context-independent, see that file's header for the rationale.
41
+ * - `meter`'s `aria-valuenow` is required. NOT required
42
+ * unconditionally: `progressbar`'s `aria-valuenow` (a legitimately
43
+ * indeterminate progressbar omits it) and `combobox`'s `aria-controls`
44
+ * (only required once the popup is actually displayed). ACT 4e8ab6's own
45
+ * test corpus confirms the conditional trigger: a role="combobox" with
46
+ * aria-expanded="true" and no (or empty) aria-controls fails, so that
47
+ * specific combination is checked directly below rather than through
48
+ * REQUIRED_PROPS_BY_ROLE's unconditional table. `separator`'s
49
+ * `aria-valuenow` is conditional in the same way: a plain separator is a
50
+ * structural divider that needs no value, but a focusable one is a
51
+ * splitter the user can move, and WAI-ARIA requires the value then. ACT
52
+ * 4e8ab6 fails exactly that shape (`<div role="separator" tabindex="0">`
53
+ * with no aria-valuenow), so focusability is read from
54
+ * helpers.getFocusableInfo at the same point.
29
55
  * - Gated on isAccTreeEligible for the element itself: unlike a syntax-
30
56
  * level check (attribute name/value validity), "does this element
31
57
  * currently carry its required state attribute" is not fixed once
32
- * written — checkbox/switch/radio's aria-checked and slider/scrollbar's
58
+ * written, checkbox/switch/radio's aria-checked and slider/scrollbar's
33
59
  * aria-valuenow are exactly the kind of live-widget-state attribute
34
60
  * component libraries set during hydration/mount, at the same moment
35
61
  * the element becomes exposed. Same false-positive shape as
@@ -95,6 +121,21 @@ function runInPage(ctx) {
95
121
  }
96
122
  }
97
123
 
124
+ // Focusability decides whether a separator is a widget; the same helper
125
+ // aria-hidden-focus and nested-interactive-controls-absent rely on, so
126
+ // :disabled, inert and invalid tabindex values are already accounted for.
127
+ function isFocusable(el) {
128
+ const fn =
129
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
130
+ if (!fn) return false;
131
+ try {
132
+ const info = fn(el, ctx);
133
+ return !!(info && info.focusable);
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
98
139
  function isMarkedBusy(el) {
99
140
  const v = el.getAttribute('aria-busy');
100
141
  return v != null && String(v).trim().toLowerCase() === 'true';
@@ -104,7 +145,8 @@ function runInPage(ctx) {
104
145
  ? helpers.queryAllSmart('[role]')
105
146
  : helpers.queryAll('[role]');
106
147
 
107
- const occurrences = [];
148
+ const failOccurrences = [];
149
+ const cantTellOccurrences = [];
108
150
  let applicableCount = 0;
109
151
 
110
152
  for (const el of nodes) {
@@ -113,7 +155,28 @@ function runInPage(ctx) {
113
155
  const role = ariaHelpers.getExplicitRole(el);
114
156
  if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
115
157
 
116
- const required = ariaHelpers.getRequiredAttrsForRole(role);
158
+ // ACT 4e8ab6: an explicit role identical to the element's own native
159
+ // role is exempt -- the native control's own state exposure already
160
+ // covers it (e.g. <input type="checkbox" role="checkbox"> needs no
161
+ // aria-checked; the browser exposes .checked natively).
162
+ if (ariaHelpers.getNativeRoleForElement && ariaHelpers.getNativeRoleForElement(el) === role) {
163
+ continue;
164
+ }
165
+
166
+ const required = ariaHelpers.getRequiredAttrsForRole(role).slice();
167
+
168
+ // combobox's aria-controls is required only once the popup is actually
169
+ // displayed (aria-expanded="true") -- see this file's header comment.
170
+ if (role === 'combobox' && String(el.getAttribute('aria-expanded') || '').trim() === 'true') {
171
+ required.push('aria-controls');
172
+ }
173
+
174
+ // A separator only carries a value when it is focusable, i.e. a
175
+ // splitter the user can move -- see this file's header comment.
176
+ if (role === 'separator' && isFocusable(el)) {
177
+ required.push('aria-valuenow');
178
+ }
179
+
117
180
  if (!required.length) continue;
118
181
 
119
182
  if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
@@ -130,8 +193,43 @@ function runInPage(ctx) {
130
193
  if (!missing.length) continue;
131
194
 
132
195
  for (const attr of missing) {
133
- occurrences.push(
196
+ const implicit =
197
+ typeof ariaHelpers.getRequiredAttrImplicitValue === 'function'
198
+ ? ariaHelpers.getRequiredAttrImplicitValue(role, attr)
199
+ : null;
200
+
201
+ if (implicit) {
202
+ cantTellOccurrences.push(
203
+ helpers.reportOccurrence(el, {
204
+ occurrenceOutcome: 'cantTell',
205
+ summary: `This attribute is required for this element’s role and is missing, but ARIA falls back to "${implicit}".`,
206
+ hint: 'Set the attribute explicitly if the implicit value is not the state you mean.',
207
+ i18n: {
208
+ summaryKey: 'ariaRequiredAttr_summary_cantTell',
209
+ hintKey: 'ariaRequiredAttr_hint_cantTell',
210
+ params: { attr, role, implicit }
211
+ },
212
+ uncertainty: {
213
+ code: 'spec-only',
214
+ needed: 'Whether the implicit fallback is the state the author meant.',
215
+ evidence: { attribute: attr, role, implicitValue: implicit }
216
+ },
217
+ data: {
218
+ details: {
219
+ reasonCode: 'ARIA_ATTR_REQUIRED_MISSING_IMPLICIT',
220
+ attr,
221
+ role,
222
+ implicitValue: implicit
223
+ }
224
+ }
225
+ })
226
+ );
227
+ continue;
228
+ }
229
+
230
+ failOccurrences.push(
134
231
  helpers.reportOccurrence(el, {
232
+ occurrenceOutcome: 'fail',
135
233
  summary: 'This attribute is required for this element’s role, but is missing.',
136
234
  hint: 'Add this attribute with a valid value for this role.',
137
235
  i18n: {
@@ -150,15 +248,12 @@ function runInPage(ctx) {
150
248
  if (applicableCount === 0) {
151
249
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
152
250
  }
153
- if (occurrences.length) {
154
- return {
155
- ruleId: rule.ruleId,
156
- outcome: 'fail',
157
- severity: rule.defaultSeverity || 'serious',
158
- occurrences
159
- };
160
- }
161
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
251
+ const resolved = helpers.resolveTieredOutcome(
252
+ failOccurrences,
253
+ cantTellOccurrences,
254
+ rule.defaultSeverity || 'serious'
255
+ );
256
+ return { ruleId: rule.ruleId, ...resolved };
162
257
  }
163
258
 
164
259
  module.exports = { id, meta, runInPage };