@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -12,23 +12,38 @@
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,7 +120,7 @@ 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'
@@ -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,17 +335,20 @@ 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
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
260
- const html = helpers.getOuterHtmlSnippet
261
- ? helpers.getOuterHtmlSnippet(entry.el)
262
- : entry.el.outerHTML || '';
263
352
  const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
264
353
 
265
354
  const isRoleless = !entry.role;
@@ -281,34 +370,38 @@ function runInPage(ctx) {
281
370
  hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
282
371
  } else {
283
372
  summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
284
- 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.`;
285
374
  summaryKey = 'ariaProhibitedChildren_summary_fail';
286
375
  hintKey = 'ariaProhibitedChildren_hint_fail';
287
376
  }
288
377
 
289
- occurrences.push({
290
- selector: stableSelector,
291
- html,
292
- summary,
293
- hint,
294
- i18n: {
295
- summaryKey,
296
- hintKey,
297
- params: isRoleless
298
- ? { attr: entry.attr, containerRole: role }
299
- : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
300
- },
301
- data: {
302
- details: {
303
- reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
304
- childRole: entry.role,
305
- attr: entry.attr,
306
- containerRole: role,
307
- containerSelector,
308
- allowedOwnedRoles: requiredOwned
378
+ occurrences.push(
379
+ helpers.reportOccurrence(entry.el, {
380
+ summary,
381
+ hint,
382
+ i18n: {
383
+ summaryKey,
384
+ hintKey,
385
+ params: isRoleless
386
+ ? { attr: entry.attr, containerRole: role }
387
+ : {
388
+ childRole: entry.role,
389
+ containerRole: role,
390
+ allowedRoles: allowedRoles.join(', ')
391
+ }
392
+ },
393
+ data: {
394
+ details: {
395
+ reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
396
+ childRole: entry.role,
397
+ attr: entry.attr,
398
+ containerRole: role,
399
+ containerSelector,
400
+ allowedOwnedRoles: allowedRoles
401
+ }
309
402
  }
310
- }
311
- });
403
+ })
404
+ );
312
405
  }
313
406
  }
314
407
 
@@ -12,24 +12,36 @@
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
22
  * Every required aria-* attribute for that role is present (and non-empty).
19
23
  * @implementation-notes
20
- * - Deliberately scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
24
+ * - Scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
21
25
  * 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.
26
+ * context-independent, see that file's header for the rationale.
27
+ * - `meter`'s `aria-valuenow` is required. NOT required
28
+ * unconditionally: `progressbar`'s `aria-valuenow` (a legitimately
29
+ * indeterminate progressbar omits it) and `combobox`'s `aria-controls`
30
+ * (only required once the popup is actually displayed). ACT 4e8ab6's own
31
+ * test corpus confirms the conditional trigger: a role="combobox" with
32
+ * aria-expanded="true" and no (or empty) aria-controls fails, so that
33
+ * specific combination is checked directly below rather than through
34
+ * REQUIRED_PROPS_BY_ROLE's unconditional table. `separator`'s
35
+ * `aria-valuenow` is conditional in the same way: a plain separator is a
36
+ * structural divider that needs no value, but a focusable one is a
37
+ * splitter the user can move, and WAI-ARIA requires the value then. ACT
38
+ * 4e8ab6 fails exactly that shape (`<div role="separator" tabindex="0">`
39
+ * with no aria-valuenow), so focusability is read from
40
+ * helpers.getFocusableInfo at the same point.
29
41
  * - Gated on isAccTreeEligible for the element itself: unlike a syntax-
30
42
  * level check (attribute name/value validity), "does this element
31
43
  * currently carry its required state attribute" is not fixed once
32
- * written — checkbox/switch/radio's aria-checked and slider/scrollbar's
44
+ * written, checkbox/switch/radio's aria-checked and slider/scrollbar's
33
45
  * aria-valuenow are exactly the kind of live-widget-state attribute
34
46
  * component libraries set during hydration/mount, at the same moment
35
47
  * the element becomes exposed. Same false-positive shape as
@@ -95,6 +107,21 @@ function runInPage(ctx) {
95
107
  }
96
108
  }
97
109
 
110
+ // Focusability decides whether a separator is a widget; the same helper
111
+ // aria-hidden-focus and nested-interactive-controls-absent rely on, so
112
+ // :disabled, inert and invalid tabindex values are already accounted for.
113
+ function isFocusable(el) {
114
+ const fn =
115
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
116
+ if (!fn) return false;
117
+ try {
118
+ const info = fn(el, ctx);
119
+ return !!(info && info.focusable);
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
98
125
  function isMarkedBusy(el) {
99
126
  const v = el.getAttribute('aria-busy');
100
127
  return v != null && String(v).trim().toLowerCase() === 'true';
@@ -113,7 +140,28 @@ function runInPage(ctx) {
113
140
  const role = ariaHelpers.getExplicitRole(el);
114
141
  if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
115
142
 
116
- const required = ariaHelpers.getRequiredAttrsForRole(role);
143
+ // ACT 4e8ab6: an explicit role identical to the element's own native
144
+ // role is exempt -- the native control's own state exposure already
145
+ // covers it (e.g. <input type="checkbox" role="checkbox"> needs no
146
+ // aria-checked; the browser exposes .checked natively).
147
+ if (ariaHelpers.getNativeRoleForElement && ariaHelpers.getNativeRoleForElement(el) === role) {
148
+ continue;
149
+ }
150
+
151
+ const required = ariaHelpers.getRequiredAttrsForRole(role).slice();
152
+
153
+ // combobox's aria-controls is required only once the popup is actually
154
+ // displayed (aria-expanded="true") -- see this file's header comment.
155
+ if (role === 'combobox' && String(el.getAttribute('aria-expanded') || '').trim() === 'true') {
156
+ required.push('aria-controls');
157
+ }
158
+
159
+ // A separator only carries a value when it is focusable, i.e. a
160
+ // splitter the user can move -- see this file's header comment.
161
+ if (role === 'separator' && isFocusable(el)) {
162
+ required.push('aria-valuenow');
163
+ }
164
+
117
165
  if (!required.length) continue;
118
166
 
119
167
  if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
@@ -129,24 +177,21 @@ function runInPage(ctx) {
129
177
 
130
178
  if (!missing.length) continue;
131
179
 
132
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
133
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
134
-
135
180
  for (const attr of missing) {
136
- occurrences.push({
137
- selector: stableSelector,
138
- html,
139
- summary: 'This attribute is required for this element’s role, but is missing.',
140
- hint: 'Add this attribute with a valid value for this role.',
141
- i18n: {
142
- summaryKey: 'ariaRequiredAttr_summary_fail',
143
- hintKey: 'ariaRequiredAttr_hint_fail',
144
- params: { attr, role }
145
- },
146
- data: {
147
- details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
148
- }
149
- });
181
+ occurrences.push(
182
+ helpers.reportOccurrence(el, {
183
+ summary: 'This attribute is required for this element’s role, but is missing.',
184
+ hint: 'Add this attribute with a valid value for this role.',
185
+ i18n: {
186
+ summaryKey: 'ariaRequiredAttr_summary_fail',
187
+ hintKey: 'ariaRequiredAttr_hint_fail',
188
+ params: { attr, role }
189
+ },
190
+ data: {
191
+ details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
192
+ }
193
+ })
194
+ );
150
195
  }
151
196
  }
152
197
 
@@ -17,7 +17,7 @@
17
17
  * At least one descendant, or one aria-owns-referenced element, has one
18
18
  * of the acceptable owned roles for that container role.
19
19
  * @implementation-notes
20
- * - Deliberately scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
20
+ * - Scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
21
21
  * (see that file's header for the conservative-scope rationale).
22
22
  * - Owned-role matching uses ariaHelpers.getContainmentRole, which combines
23
23
  * explicit role="" attributes with a small, curated native-HTML-tag
@@ -28,12 +28,20 @@
28
28
  * - Only one qualifying descendant/owned element is required (per
29
29
  * WAI-ARIA "required owned elements": any one acceptable role satisfies
30
30
  * the requirement); the full subtree is scanned without excluding nested
31
- * containers with their own differing role, favoring simplicity — this
31
+ * containers with their own differing role, favoring simplicity, this
32
32
  * can only under-report (recall), never over-report (fail integrity).
33
+ * - "At least one required child exists" is the whole of this rule's
34
+ * decision. Whether every owned child is ALLOWED is
35
+ * aria-prohibited-children's, and that rule does walk the owned graph
36
+ * exclusively, with group/rowgroup transparency. ACT bc4a75 asks both
37
+ * questions at once, which is why the mapping lists the two rules as a
38
+ * family: read on its own, this rule looks like it under-reports a
39
+ * container mixing valid and invalid children, and the sibling is what
40
+ * catches it.
33
41
  * - Gated on isAccTreeEligible for the container itself: unlike this
34
42
  * file's sibling attribute/role-validity checks (e.g. aria-roles-valid),
35
43
  * "does this container currently have a required child" is not a fact
36
- * that stays fixed once written — it is routinely filled in by the same
44
+ * that stays fixed once written, it is routinely filled in by the same
37
45
  * script/interaction that reveals the container (a closed flyout menu
38
46
  * or <dialog> populated on open). Flagging it while the container isn't
39
47
  * currently exposed to the accessibility tree is a false positive; such
@@ -44,16 +52,16 @@
44
52
  * widget is missing required owned elements due to script execution or
45
53
  * loading, authors MUST mark a containing element with aria-busy equal
46
54
  * to true." A container carrying aria-busy="true" is skipped the same
47
- * way — only the exact string "true" counts (absent/"false" do not).
55
+ * way, only the exact string "true" counts (absent/"false" do not).
48
56
  * - Descendant search tries a fast native querySelectorAll(CANDIDATE_
49
57
  * SELECTOR) first (covers the light-DOM-only common case with no added
50
58
  * cost); only when that finds nothing AND the container has a <slot>
51
59
  * anywhere in its subtree does it fall back to a composed-tree walk that
52
- * expands <slot> elements via assignedElements({flatten:true}) — plain
60
+ * expands <slot> elements via assignedElements({flatten:true}). Plain
53
61
  * querySelectorAll only sees a <slot>'s unrendered fallback content, never
54
- * what's actually distributed into it. Deliberately scoped to slot
62
+ * what's actually distributed into it. Scoped to slot
55
63
  * expansion only, not a general "also descend into any nested custom
56
- * element's own shadow root" walk — no known case needs that yet.
64
+ * element's own shadow root" walk, no known case needs that yet.
57
65
  */
58
66
 
59
67
  const id = 'aria-required-children';
@@ -114,7 +122,7 @@ function runInPage(ctx) {
114
122
  // Candidate selector for descendant scanning: explicit role attributes,
115
123
  // plus every native tag ariaHelpers.getContainmentRole() recognizes
116
124
  // (kept in sync with aria-helpers.js NATIVE_CONTAINMENT_ROLE_BY_ELEMENT).
117
- // Declared inside runInPage — see scripts/build-core.js header
125
+ // Declared inside runInPage, see scripts/build-core.js header
118
126
  // ("runInPage MUST be self-contained").
119
127
  const CANDIDATE_SELECTOR =
120
128
  '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
@@ -130,11 +138,11 @@ function runInPage(ctx) {
130
138
  // light-DOM subtree, so a container whose real owned children are
131
139
  // distributed via <slot> (e.g. a shadow-DOM role="list" wrapping
132
140
  // <slot></slot>, with the actual role="listitem" elements living in the
133
- // light DOM and projected in) would never find them there — same class
141
+ // light DOM and projected in) would never find them there, same class
134
142
  // of bug as aria-required-parent's ancestor search, just in the opposite
135
143
  // (descendant) direction.
136
144
  //
137
- // Deliberately scoped to slot expansion only — does NOT separately
145
+ // Scoped to slot expansion only, does NOT separately
138
146
  // descend into an unrelated nested custom element's own shadow root
139
147
  // (e.g. a <my-widget> child with no <slot> involvement at all). That's a
140
148
  // qualitatively different question (does an arbitrary component's own
@@ -192,9 +200,8 @@ function runInPage(ctx) {
192
200
  let found = false;
193
201
 
194
202
  // Fast path first: native querySelectorAll over the curated candidate
195
- // selector, exactly as before this fix — covers the overwhelming
196
- // majority of containers (no shadow DOM involved at all) with zero
197
- // added cost.
203
+ // selector. Covers the overwhelming majority of containers (no shadow
204
+ // DOM involved at all) with zero added cost.
198
205
  let descendants;
199
206
  try {
200
207
  descendants = el.querySelectorAll(CANDIDATE_SELECTOR);
@@ -210,7 +217,7 @@ function runInPage(ctx) {
210
217
  }
211
218
 
212
219
  // Slow path only when the fast path found nothing AND there's an actual
213
- // <slot> somewhere in the subtree to expand — bounds the extra cost to
220
+ // <slot> somewhere in the subtree to expand, bounds the extra cost to
214
221
  // exactly the containers that could possibly need it.
215
222
  if (!found) {
216
223
  let hasSlot;
@@ -253,27 +260,24 @@ function runInPage(ctx) {
253
260
 
254
261
  if (found) continue;
255
262
 
256
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
257
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
258
-
259
- occurrences.push({
260
- selector: stableSelector,
261
- html,
262
- summary: 'This container role has no owned child with a required role.',
263
- hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
264
- i18n: {
265
- summaryKey: 'ariaRequiredChildren_summary_fail',
266
- hintKey: 'ariaRequiredChildren_hint_fail',
267
- params: { role, requiredRoles: requiredOwned.join(', ') }
268
- },
269
- data: {
270
- details: {
271
- reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
272
- role,
273
- requiredOwnedRoles: requiredOwned
263
+ occurrences.push(
264
+ helpers.reportOccurrence(el, {
265
+ summary: 'This container role has no owned child with a required role.',
266
+ hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
267
+ i18n: {
268
+ summaryKey: 'ariaRequiredChildren_summary_fail',
269
+ hintKey: 'ariaRequiredChildren_hint_fail',
270
+ params: { role, requiredRoles: requiredOwned.join(', ') }
271
+ },
272
+ data: {
273
+ details: {
274
+ reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
275
+ role,
276
+ requiredOwnedRoles: requiredOwned
277
+ }
274
278
  }
275
- }
276
- });
279
+ })
280
+ );
277
281
  }
278
282
 
279
283
  if (applicableCount === 0) {