@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,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Container roles that require specific owned elements must contain at least one
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, non-abstract role that is
13
13
  * also one of the container roles with a documented "required owned
@@ -15,9 +15,19 @@
15
15
  * table, grid, treegrid, tablist, tree, row).
16
16
  * @expectation
17
17
  * At least one descendant, or one aria-owns-referenced element, has one
18
- * of the acceptable owned roles for that container role.
18
+ * of the acceptable owned roles for that container role. Reported at
19
+ * CANTTELL, never FAIL: this rule asks only whether the required content
20
+ * is PRESENT, and a container that owns nothing conveys nothing false --
21
+ * an empty role="list" is announced as a list with no items, which is what
22
+ * it is. Whether the content a container does own is VALID is
23
+ * aria-prohibited-children's decision, and that rule still fails, so a
24
+ * genuinely misdescribed structure (a role="button" among list items, a
25
+ * tablist of plain buttons) is caught with the same strength as before.
26
+ * The native-HTML equivalents already work this way: nothing in this
27
+ * ruleset fails an empty <ul>, and list-children-valid judges only the
28
+ * children that exist.
19
29
  * @implementation-notes
20
- * - Deliberately scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
30
+ * - Scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
21
31
  * (see that file's header for the conservative-scope rationale).
22
32
  * - Owned-role matching uses ariaHelpers.getContainmentRole, which combines
23
33
  * explicit role="" attributes with a small, curated native-HTML-tag
@@ -28,12 +38,20 @@
28
38
  * - Only one qualifying descendant/owned element is required (per
29
39
  * WAI-ARIA "required owned elements": any one acceptable role satisfies
30
40
  * the requirement); the full subtree is scanned without excluding nested
31
- * containers with their own differing role, favoring simplicity — this
41
+ * containers with their own differing role, favoring simplicity, this
32
42
  * can only under-report (recall), never over-report (fail integrity).
43
+ * - "At least one required child exists" is the whole of this rule's
44
+ * decision. Whether every owned child is ALLOWED is
45
+ * aria-prohibited-children's, and that rule does walk the owned graph
46
+ * exclusively, with group/rowgroup transparency. ACT bc4a75 asks both
47
+ * questions at once, which is why the mapping lists the two rules as a
48
+ * family: read on its own, this rule looks like it under-reports a
49
+ * container mixing valid and invalid children, and the sibling is what
50
+ * catches it.
33
51
  * - Gated on isAccTreeEligible for the container itself: unlike this
34
52
  * file's sibling attribute/role-validity checks (e.g. aria-roles-valid),
35
53
  * "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
54
+ * that stays fixed once written, it is routinely filled in by the same
37
55
  * script/interaction that reveals the container (a closed flyout menu
38
56
  * or <dialog> populated on open). Flagging it while the container isn't
39
57
  * currently exposed to the accessibility tree is a false positive; such
@@ -44,16 +62,16 @@
44
62
  * widget is missing required owned elements due to script execution or
45
63
  * loading, authors MUST mark a containing element with aria-busy equal
46
64
  * to true." A container carrying aria-busy="true" is skipped the same
47
- * way — only the exact string "true" counts (absent/"false" do not).
65
+ * way, only the exact string "true" counts (absent/"false" do not).
48
66
  * - Descendant search tries a fast native querySelectorAll(CANDIDATE_
49
67
  * SELECTOR) first (covers the light-DOM-only common case with no added
50
68
  * cost); only when that finds nothing AND the container has a <slot>
51
69
  * anywhere in its subtree does it fall back to a composed-tree walk that
52
- * expands <slot> elements via assignedElements({flatten:true}) — plain
70
+ * expands <slot> elements via assignedElements({flatten:true}). Plain
53
71
  * querySelectorAll only sees a <slot>'s unrendered fallback content, never
54
- * what's actually distributed into it. Deliberately scoped to slot
72
+ * what's actually distributed into it. Scoped to slot
55
73
  * expansion only, not a general "also descend into any nested custom
56
- * element's own shadow root" walk — no known case needs that yet.
74
+ * element's own shadow root" walk, no known case needs that yet.
57
75
  */
58
76
 
59
77
  const id = 'aria-required-children';
@@ -67,14 +85,14 @@ const meta = {
67
85
  descriptionKey: 'ariaRequiredChildren_description'
68
86
  },
69
87
  helpUrl: null,
70
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
71
- wcagSc: ['4.1.2'],
88
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
89
+ wcagSc: ['1.3.1'],
72
90
  normativeMappings: [
73
91
  {
74
92
  standard: 'WCAG',
75
93
  version: '2.2',
76
- requirement: '4.1.2',
77
- title: 'Name, Role, Value',
94
+ requirement: '1.3.1',
95
+ title: 'Info and Relationships',
78
96
  conformanceLevel: 'A'
79
97
  }
80
98
  ],
@@ -82,7 +100,7 @@ const meta = {
82
100
  category: 'robust',
83
101
  type: 'automatic',
84
102
  defaultConfidence: 'medium',
85
- coverage: { facetsBySc: { '4.1.2': ['aria-role-required-owned-children'] } }
103
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-required-owned-children'] } }
86
104
  };
87
105
 
88
106
  function runInPage(ctx) {
@@ -114,7 +132,7 @@ function runInPage(ctx) {
114
132
  // Candidate selector for descendant scanning: explicit role attributes,
115
133
  // plus every native tag ariaHelpers.getContainmentRole() recognizes
116
134
  // (kept in sync with aria-helpers.js NATIVE_CONTAINMENT_ROLE_BY_ELEMENT).
117
- // Declared inside runInPage — see scripts/build-core.js header
135
+ // Declared inside runInPage, see scripts/build-core.js header
118
136
  // ("runInPage MUST be self-contained").
119
137
  const CANDIDATE_SELECTOR =
120
138
  '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
@@ -130,11 +148,11 @@ function runInPage(ctx) {
130
148
  // light-DOM subtree, so a container whose real owned children are
131
149
  // distributed via <slot> (e.g. a shadow-DOM role="list" wrapping
132
150
  // <slot></slot>, with the actual role="listitem" elements living in the
133
- // light DOM and projected in) would never find them there — same class
151
+ // light DOM and projected in) would never find them there, same class
134
152
  // of bug as aria-required-parent's ancestor search, just in the opposite
135
153
  // (descendant) direction.
136
154
  //
137
- // Deliberately scoped to slot expansion only — does NOT separately
155
+ // Scoped to slot expansion only, does NOT separately
138
156
  // descend into an unrelated nested custom element's own shadow root
139
157
  // (e.g. a <my-widget> child with no <slot> involvement at all). That's a
140
158
  // qualitatively different question (does an arbitrary component's own
@@ -192,9 +210,8 @@ function runInPage(ctx) {
192
210
  let found = false;
193
211
 
194
212
  // 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.
213
+ // selector. Covers the overwhelming majority of containers (no shadow
214
+ // DOM involved at all) with zero added cost.
198
215
  let descendants;
199
216
  try {
200
217
  descendants = el.querySelectorAll(CANDIDATE_SELECTOR);
@@ -210,7 +227,7 @@ function runInPage(ctx) {
210
227
  }
211
228
 
212
229
  // 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
230
+ // <slot> somewhere in the subtree to expand, bounds the extra cost to
214
231
  // exactly the containers that could possibly need it.
215
232
  if (!found) {
216
233
  let hasSlot;
@@ -262,6 +279,16 @@ function runInPage(ctx) {
262
279
  hintKey: 'ariaRequiredChildren_hint_fail',
263
280
  params: { role, requiredRoles: requiredOwned.join(', ') }
264
281
  },
282
+ uncertainty: {
283
+ code: 'spec-only',
284
+ needed:
285
+ 'Whether this container is legitimately empty, or holds items that never got their role.',
286
+ evidence: {
287
+ role,
288
+ requiredOwnedRoles: requiredOwned,
289
+ childElementCount: el.children ? el.children.length : null
290
+ }
291
+ },
265
292
  data: {
266
293
  details: {
267
294
  reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
@@ -276,15 +303,12 @@ function runInPage(ctx) {
276
303
  if (applicableCount === 0) {
277
304
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
278
305
  }
279
- if (occurrences.length) {
280
- return {
281
- ruleId: rule.ruleId,
282
- outcome: 'fail',
283
- severity: rule.defaultSeverity || 'moderate',
284
- occurrences
285
- };
286
- }
287
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
306
+ const resolved = helpers.resolveTieredOutcome(
307
+ [],
308
+ occurrences,
309
+ rule.defaultSeverity || 'moderate'
310
+ );
311
+ return { ruleId: rule.ruleId, ...resolved };
288
312
  }
289
313
 
290
314
  module.exports = { id, meta, runInPage };
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Roles that require a specific ancestor/owner context role must have one
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, non-abstract role that is
13
13
  * also one of the roles with a documented, non-empty "required context
@@ -19,7 +19,7 @@
19
19
  * ancestor/owner's aria-owns) whose effective role is one of the
20
20
  * acceptable context roles for this element's role.
21
21
  * @implementation-notes
22
- * - Deliberately scoped to REQUIRED_CONTEXT_ROLE in src/core/aria-helpers.js
22
+ * - Scoped to REQUIRED_CONTEXT_ROLE in src/core/aria-helpers.js
23
23
  * (see that file's header for the conservative-scope rationale); roles
24
24
  * with an explicitly empty entry (e.g. tabpanel) are left unconstrained.
25
25
  * - Context-role matching uses ariaHelpers.getContainmentRole, which
@@ -34,8 +34,8 @@
34
34
  * independent path via a reverse lookup over the search root.
35
35
  * - Gated on isAccTreeEligible for the element itself. The ancestor-role
36
36
  * walk itself doesn't care about visibility (a hidden ancestor's role is
37
- * still found by plain DOM/composed-tree containment, so a genuinely
38
- * correctly-nested-but-hidden widget was never at risk here) — the
37
+ * still found by plain DOM/composed-tree containment, so a
38
+ * correctly-nested-but-hidden widget was never at risk here), the
39
39
  * remaining false-positive shape is an element whose required ancestor
40
40
  * context doesn't exist YET because it (and its wrapping context) are
41
41
  * assembled together at reveal time (e.g. a portal-rendered item staged
@@ -43,6 +43,16 @@
43
43
  * aria-required-children/aria-prohibited-children, applied for
44
44
  * consistency; an element that isn't currently exposed to the
45
45
  * accessibility tree is skipped (notApplicable), not failed.
46
+ * - Also honors WAI-ARIA's escape hatch for a widget script has not
47
+ * finished assembling: "When a widget is missing required owned elements due to
48
+ * script execution or loading, authors MUST mark a containing element
49
+ * with aria-busy equal to true." aria-required-children reads that from
50
+ * the container it is checking; read from the item's side it is an
51
+ * ancestor, so the walk looks up rather than at the element itself, and
52
+ * only the exact string "true" counts. It also outranks the
53
+ * roleless-generic-parent rule below, since aria-busy is itself a global
54
+ * ARIA attribute and would otherwise block the context search and fail
55
+ * the very markup the spec says to mark.
46
56
  */
47
57
 
48
58
  const id = 'aria-required-parent';
@@ -56,14 +66,14 @@ const meta = {
56
66
  descriptionKey: 'ariaRequiredParent_description'
57
67
  },
58
68
  helpUrl: null,
59
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
60
- wcagSc: ['4.1.2'],
69
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
70
+ wcagSc: ['1.3.1'],
61
71
  normativeMappings: [
62
72
  {
63
73
  standard: 'WCAG',
64
74
  version: '2.2',
65
- requirement: '4.1.2',
66
- title: 'Name, Role, Value',
75
+ requirement: '1.3.1',
76
+ title: 'Info and Relationships',
67
77
  conformanceLevel: 'A'
68
78
  }
69
79
  ],
@@ -71,7 +81,7 @@ const meta = {
71
81
  category: 'robust',
72
82
  type: 'automatic',
73
83
  defaultConfidence: 'medium',
74
- coverage: { facetsBySc: { '4.1.2': ['aria-role-required-context-parent'] } }
84
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-required-context-parent'] } }
75
85
  };
76
86
 
77
87
  function runInPage(ctx) {
@@ -96,13 +106,57 @@ function runInPage(ctx) {
96
106
  }
97
107
 
98
108
  // Roles that may host a nested listitem/treeitem group without breaking
99
- // the required-context chain — the group role is transparent for exactly
109
+ // the required-context chain, the group role is transparent for exactly
100
110
  // these two roles.
101
111
  const GROUP_TRANSPARENT_FOR_ROLES = new Set(['listitem', 'treeitem']);
102
112
 
103
- // A real ancestor role — not "no role at all" and not the two roles that
113
+ // The WAI-ARIA "Global States and Properties" set (same list as
114
+ // aria-prohibited-children.js's GLOBAL_ARIA_ATTRS, duplicated, not
115
+ // imported, since runInPage must be self-contained per
116
+ // scripts/build-core.js). A roleless ancestor carrying any of these is
117
+ // still "included in the accessibility tree" and is therefore a real
118
+ // (generic) parent, not a transparent one: ACT's ff89c9 test corpus
119
+ // covers exactly this with role="listitem" whose actual DOM parent is a
120
+ // roleless <div aria-live="polite">, itself inside a role="list", the
121
+ // required-context chain is broken by that included-but-roleless div,
122
+ // even though a role="list" ancestor does exist further up.
123
+ const GLOBAL_ARIA_ATTRS = [
124
+ 'aria-atomic',
125
+ 'aria-braillelabel',
126
+ 'aria-brailleroledescription',
127
+ 'aria-busy',
128
+ 'aria-controls',
129
+ 'aria-current',
130
+ 'aria-describedby',
131
+ 'aria-description',
132
+ 'aria-details',
133
+ 'aria-disabled',
134
+ 'aria-dropeffect',
135
+ 'aria-errormessage',
136
+ 'aria-flowto',
137
+ 'aria-grabbed',
138
+ 'aria-haspopup',
139
+ 'aria-hidden',
140
+ 'aria-invalid',
141
+ 'aria-keyshortcuts',
142
+ 'aria-label',
143
+ 'aria-labelledby',
144
+ 'aria-live',
145
+ 'aria-owns',
146
+ 'aria-relevant',
147
+ 'aria-roledescription'
148
+ ];
149
+
150
+ function hasGlobalAriaAttr(el) {
151
+ for (const attr of GLOBAL_ARIA_ATTRS) {
152
+ if (el.getAttribute && el.getAttribute(attr) != null) return true;
153
+ }
154
+ return false;
155
+ }
156
+
157
+ // A real ancestor role, not "no role at all" and not the two roles that
104
158
  // strip an element from the accessibility tree's parent/child chain
105
- // entirely (presentation/none) — stops the search. This is stricter than
159
+ // entirely (presentation/none), stops the search. This is stricter than
106
160
  // "any ancestor with the right role anywhere up the tree": the
107
161
  // required-context relationship is about the accessibility tree's actual
108
162
  // PARENT, so an intervening ancestor with its OWN distinct real role
@@ -110,19 +164,27 @@ function runInPage(ctx) {
110
164
  // a further-up ancestor has the correct role. E.g. a <button role="tab">
111
165
  // inside a plain <li> (native listitem) inside <ul role="tablist"> fails:
112
166
  // the tablist is never the tab's accessible-tree parent, the listitem is.
167
+ //
168
+ // A roleless ancestor is normally transparent (it isn't a node in the
169
+ // accessibility tree at all), UNLESS it carries a global ARIA attribute
170
+ // -- that alone includes it in the tree as a real, roleless (generic)
171
+ // parent, which still blocks the search the same way a distinct real
172
+ // role would.
113
173
  function getRealContextRole(el) {
114
174
  const role = ariaHelpers.getContainmentRole(el);
115
- if (!role || role === 'presentation' || role === 'none') return '';
175
+ if (!role || role === 'presentation' || role === 'none') {
176
+ return hasGlobalAriaAttr(el) ? 'generic' : '';
177
+ }
116
178
  return role;
117
179
  }
118
180
 
119
- // Flat-tree ancestor walk (ctx.helpers.composedParent — assignedSlot wins
181
+ // Flat-tree ancestor walk (ctx.helpers.composedParent, assignedSlot wins
120
182
  // over parentNode, then shadow host). A slotted light-DOM element's real
121
183
  // rendered ancestor is whatever the shadow tree wraps its <slot> in (e.g.
122
184
  // a role="list" container), not its own light-DOM parentElement.
123
185
  // composedParent can return a non-Element node (a ShadowRoot, nodeType
124
186
  // 11) when climbing out of a shadow tree that has no further light-DOM
125
- // parent — skip those and keep climbing rather than treating them as a
187
+ // parent, skip those and keep climbing rather than treating them as a
126
188
  // (roleless) context.
127
189
  const getComposedParent =
128
190
  helpers && typeof helpers.composedParent === 'function'
@@ -131,6 +193,21 @@ function runInPage(ctx) {
131
193
  return n && n.parentElement ? n.parentElement : null;
132
194
  };
133
195
 
196
+ // The escape hatch marks the container being assembled, not the item inside
197
+ // it, so this walks up instead of reading the element's own attribute.
198
+ function hasBusyAncestor(el) {
199
+ let cur = getComposedParent(el);
200
+ let guard = 0;
201
+ while (cur && guard++ < 200) {
202
+ if (cur.nodeType === 1 && cur.getAttribute) {
203
+ const v = cur.getAttribute('aria-busy');
204
+ if (v != null && String(v).trim().toLowerCase() === 'true') return true;
205
+ }
206
+ cur = getComposedParent(cur);
207
+ }
208
+ return false;
209
+ }
210
+
134
211
  function hasAcceptableAncestorContext(el, acceptableRoles, ownRole) {
135
212
  const allowsGroup = acceptableRoles.has('group');
136
213
  let cur = getComposedParent(el);
@@ -200,6 +277,7 @@ function runInPage(ctx) {
200
277
  if (!requiredContext || !requiredContext.length) continue; // no entry, or explicitly unconstrained
201
278
 
202
279
  if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
280
+ if (hasBusyAncestor(el)) continue; // author has signaled transient incompleteness per WAI-ARIA
203
281
 
204
282
  applicableCount += 1;
205
283
 
@@ -3,7 +3,8 @@
3
3
  'use strict';
4
4
 
5
5
  /**
6
- * Generic name-presence rule for selected ARIA composite/widget roles that should expose an accessible name.
6
+ * Generic name-presence rule for the ARIA roles WAI-ARIA *requires* an
7
+ * accessible name for.
7
8
  *
8
9
  * Notes:
9
10
  * - This rule intentionally focuses on author-provided naming mechanisms:
@@ -11,20 +12,40 @@
11
12
  * - It does NOT treat descendant text content as a valid name source for these roles
12
13
  * (to avoid false passes from labelled children inside composite widgets).
13
14
  * - Eligibility is based on helpers.isAccTreeEligible(node, ctx) per engine checks.
15
+ * - The role set is generated from aria-query by
16
+ * scripts/generate-aria-tables.js; see that script for why each role is in
17
+ * or out, and for the name-required roles no rule covers yet.
18
+ *
19
+ * @applicability
20
+ * Applies to elements whose role attribute is exactly one of grid, meter,
21
+ * progressbar, radiogroup or tree, and that are included in the
22
+ * accessibility tree. Membership is decided by WAI-ARIA's own "Accessible
23
+ * Name Required: True" characteristic, not by whether a role merely permits
24
+ * a name: tablist, toolbar, menu, menubar and scrollbar are name-from-author
25
+ * roles the spec does not require a name for, and are out of
26
+ * scope. meter and progressbar are also covered by meter-name-present and
27
+ * progressbar-name-present, which map to SC 1.1.1; this rule is what gives
28
+ * those two roles their 4.1.2 coverage.
29
+ * @expectation
30
+ * The element has a non-empty aria-label, an aria-labelledby that resolves
31
+ * to non-empty text, or a non-empty title. Every role in the set is
32
+ * name-from-author-only, so descendant text is not accepted:
33
+ * a labelled child inside a composite widget would otherwise pass the
34
+ * container that has no name of its own.
14
35
  */
15
36
 
16
37
  const id = 'aria-role-name-present';
17
38
 
18
39
  const meta = {
19
- title: 'ARIA widget/container roles have an accessible name',
40
+ title: 'ARIA roles that require an accessible name have one',
20
41
  description:
21
- 'Checks that selected ARIA widget/container roles expose a non-empty accessible name.',
42
+ 'Checks that the ARIA roles WAI-ARIA requires an accessible name for expose a non-empty one.',
22
43
  i18n: {
23
44
  titleKey: 'ariaRoleNamePresent_title',
24
45
  descriptionKey: 'ariaRoleNamePresent_description'
25
46
  },
26
47
  helpUrl: null,
27
- tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
48
+ tags: ['wcag2a', 'wcag412', 'aria', 'navigation', 'atomic', 'automatic', 'name'],
28
49
  wcagSc: ['4.1.2'],
29
50
  normativeMappings: [
30
51
  {
@@ -93,7 +114,7 @@ function runInPage(ctx) {
93
114
  // button-name-present et al.), which correctly includes hidden/aria-hidden
94
115
  // referenced nodes per the Accessible Name and Description Computation
95
116
  // spec (a hidden node directly referenced by aria-labelledby still
96
- // supplies its text — this is a standard visually-hidden-label pattern).
117
+ // supplies its text, this is a standard visually-hidden-label pattern).
97
118
  const resolveLabelledby = (el, maxRefs) => {
98
119
  if (!getAriaLabelledByInfo) return '';
99
120
  try {
@@ -115,23 +136,16 @@ function runInPage(ctx) {
115
136
  }
116
137
  };
117
138
 
118
- // Frozen allowlist of roles to check in this generic rule.
119
- // (Keep this small, explicit, and standards-based for determinism.)
120
- const roleSet = new Set([
121
- 'scrollbar',
122
- 'toolbar',
123
- 'tablist',
124
- 'radiogroup',
125
- 'tree',
126
- 'grid',
127
- 'menu',
128
- 'menubar',
129
- 'meter',
130
- 'progressbar'
131
- ]);
132
-
133
- const selector =
134
- '[role="scrollbar"],[role="toolbar"],[role="tablist"],[role="radiogroup"],[role="tree"],[role="grid"],[role="menu"],[role="menubar"],[role="meter"],[role="progressbar"]';
139
+ // Roles WAI-ARIA marks "Accessible Name Required: True" and names from the
140
+ // author only. Generated from aria-query by scripts/generate-aria-tables.js:
141
+ // a role that merely *allows* an author name (tablist, toolbar, menu,
142
+ // menubar, scrollbar) is not a 4.1.2 failure when unnamed and is not listed.
143
+ // <generated:aria-name-required-roles>
144
+ const NAME_REQUIRED_ROLES = new Set(['grid', 'meter', 'progressbar', 'radiogroup', 'tree']);
145
+ // </generated:aria-name-required-roles>
146
+
147
+ // Derived from the set above so the two cannot drift apart.
148
+ const selector = [...NAME_REQUIRED_ROLES].map((r) => `[role="${r}"]`).join(',');
135
149
 
136
150
  const nodes = (() => {
137
151
  try {
@@ -161,7 +175,7 @@ function runInPage(ctx) {
161
175
  return '';
162
176
  }
163
177
  })();
164
- if (!roleSet.has(role)) continue;
178
+ if (!NAME_REQUIRED_ROLES.has(role)) continue;
165
179
 
166
180
  if (!isEligible(el)) continue;
167
181
 
@@ -11,16 +11,15 @@
11
11
  * @applicability
12
12
  * Applies to any element with a non-empty role="" attribute in the composed DOM.
13
13
  * @expectation
14
- * The role attribute's first token (the role actually used by assistive technology;
15
- * later space-separated tokens are author-supplied fallbacks and are not evaluated
16
- * here) must be a real WAI-ARIA role name, and must not be an abstract role
17
- * (abstract roles exist only for the specification's own role taxonomy and must
18
- * never be used directly in markup).
19
- * @implementation-notes
20
- * - Unlike this engine's accessible-name rules, ARIA validity is a static markup
21
- * property independent of current visibility/eligibility, so this rule does not
22
- * gate on isAccTreeEligible: an invalid role is a defect whether or not the
23
- * element happens to be hidden right now.
14
+ * At least one role token names a concrete, non-abstract ARIA role.
15
+ * Graded by what the element falls back to when none does:
16
+ * - FAIL on a roleless host (div, span, custom element), which is left
17
+ * exposed as generic, so the role the author meant reaches no one.
18
+ * - CANTTELL where the element has a native role (a <button>, <nav>,
19
+ * <a href>), which the accessibility tree keeps using. ACT 674b10 lists
20
+ * 4.1.2 as a secondary requirement only, "satisfied through the implicit
21
+ * role," so the bad token is worth reporting but is not itself the
22
+ * criterion failing.
24
23
  */
25
24
 
26
25
  const id = 'aria-roles-valid';
@@ -64,7 +63,8 @@ function runInPage(ctx) {
64
63
  ? helpers.queryAllSmart('[role]')
65
64
  : helpers.queryAll('[role]');
66
65
 
67
- const occurrences = [];
66
+ const failOccurrences = [];
67
+ const cantTellOccurrences = [];
68
68
  let applicableCount = 0;
69
69
 
70
70
  // Programmatically hidden per the ACT glossary: display:none, visibility not
@@ -121,8 +121,42 @@ function runInPage(ctx) {
121
121
  const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
122
122
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
123
123
 
124
- occurrences.push(
124
+ // An unusable role token leaves the element on its native role, when it
125
+ // has one: ACT 674b10 lists 4.1.2 as only a secondary requirement for
126
+ // exactly that reason. A roleless host (div, span, custom element) has
127
+ // nothing to fall back to and is exposed as generic instead.
128
+ const nativeRole =
129
+ typeof ariaHelpers.getNativeRoleForElement === 'function'
130
+ ? ariaHelpers.getNativeRoleForElement(el) || ''
131
+ : '';
132
+
133
+ if (nativeRole) {
134
+ cantTellOccurrences.push(
135
+ helpers.reportOccurrence(el, {
136
+ occurrenceOutcome: 'cantTell',
137
+ summary: `The role attribute value is not usable, so this element is still exposed as its native role="${nativeRole}".`,
138
+ hint: 'Fix or remove the role token; assistive technology is using the native role in the meantime.',
139
+ i18n: {
140
+ summaryKey: 'ariaRolesValid_summary_cantTell',
141
+ hintKey: 'ariaRolesValid_hint_cantTell',
142
+ params: { role, nativeRole }
143
+ },
144
+ uncertainty: {
145
+ code: 'spec-only',
146
+ needed: 'Whether the native role this element falls back to is the one intended.',
147
+ evidence: { role, nativeRole, fallbackExposed: true }
148
+ },
149
+ data: {
150
+ details: { reasonCode, role, nativeRole }
151
+ }
152
+ })
153
+ );
154
+ continue;
155
+ }
156
+
157
+ failOccurrences.push(
125
158
  helpers.reportOccurrence(el, {
159
+ occurrenceOutcome: 'fail',
126
160
  summary: !isKnown
127
161
  ? 'The role attribute value is not a recognized ARIA role.'
128
162
  : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
@@ -146,15 +180,12 @@ function runInPage(ctx) {
146
180
  if (applicableCount === 0) {
147
181
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
182
  }
149
- if (occurrences.length) {
150
- return {
151
- ruleId: rule.ruleId,
152
- outcome: 'fail',
153
- severity: rule.defaultSeverity || 'serious',
154
- occurrences
155
- };
156
- }
157
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
183
+ const resolved = helpers.resolveTieredOutcome(
184
+ failOccurrences,
185
+ cantTellOccurrences,
186
+ rule.defaultSeverity || 'serious'
187
+ );
188
+ return { ruleId: rule.ruleId, ...resolved };
158
189
  }
159
190
 
160
191
  module.exports = { id, meta, runInPage };