@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,265 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-prohibited-children
5
+ * @atomic true
6
+ * @summary Container roles must not own an accessible-tree child with a disallowed role
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements with an explicit, valid role that is one of the
11
+ * container roles with a documented "required owned elements" entry
12
+ * (the same REQUIRED_OWNED_ROLES table aria-required-children
13
+ * uses — see src/core/aria-helpers.js).
14
+ * @expectation
15
+ * Every accessible-tree-owned descendant of the container (after
16
+ * pruning role="none"/"presentation" elements and any "group"/
17
+ * "rowgroup" wrapper whose role is itself one of the required roles —
18
+ * both are structurally transparent, same as WAI-ARIA's own
19
+ * accessibility-tree construction) has a role from that same required-
20
+ * owned set. Nothing else is a structurally valid direct child of a
21
+ * composite/container role.
22
+ * @implementation-notes
23
+ * - A distinct atomic decision from aria-required-children (see
24
+ * that rule): "does at least one required child exist" vs "is every
25
+ * owned child one of the allowed roles." A widely-used reference engine
26
+ * bundles both under one check (`aria-required-children`); this repo's
27
+ * "one rule = one normative decision" principle splits them, matching
28
+ * the established pattern elsewhere of surea11y rules mapping
29
+ * many-to-one against a single check in that reference engine.
30
+ * - The "allowed owned roles" set is exactly REQUIRED_OWNED_ROLES — not
31
+ * a separately authored, broader list. Verified directly against a
32
+ * widely-used reference engine's own ariaRequiredChildren/getOwnedRoles
33
+ * algorithm: an owned element is only considered allowed if its role is
34
+ * literally in the container's required set; that engine does not
35
+ * define a superset "allowed but not required" list for this purpose.
36
+ * Found and verified via a real page (Red Cross's homepage: a
37
+ * <nav role="region"> nested inside a <ul role="menubar"> through a
38
+ * role="none" <li> wrapper — a real violation that reference engine
39
+ * caught that aria-required-children's own scope (documented
40
+ * there as "can only under-report, never over-report") does not).
41
+ * - Widened 2026-07-21 to also flag a ROLELESS descendant that has any
42
+ * global WAI-ARIA attribute or is focusable, matching a widely-used
43
+ * reference engine's own `getOwnedRoles` exactly (verified directly
44
+ * against its source: `hasGlobalAriaOrFocusable =
45
+ * !!globalAriaAttr || _isFocusable(vNode)` — such a descendant is
46
+ * pushed as an owned entry with `role: null`, which can never match a
47
+ * container's required-owned-roles set, so it's always "unallowed").
48
+ * Previously left out as riskier to replicate — re-evaluated given
49
+ * direct access to that engine's exact algorithm (not a guess) plus this
50
+ * engine's own already-existing, shared `helpers.getFocusableInfo` for
51
+ * the focusability half. Both signals (global-attribute presence,
52
+ * focusability) are static, declarative markup facts with no live-DOM/
53
+ * hydration risk, unlike e.g. `aria-checked-state-mismatch`'s DOM-
54
+ * property comparison.
55
+ * - Recursion stops at the first non-transparent role boundary, same as
56
+ * that reference engine: a nested container with its own real role (e.g. a
57
+ * <div role="listbox"> inside a menubar) is evaluated as ITS OWN
58
+ * owned-role entry against the outer container (and, separately, gets
59
+ * its own applicability pass as a container in the same rule run) —
60
+ * its descendants are never misattributed to the outer container.
61
+ * - Gated on isAccTreeEligible for the container itself, matching the fix
62
+ * applied to aria-required-children (see that rule's header): the
63
+ * original "not gated" note here just cited that rule's reasoning
64
+ * without re-deriving it, and that reasoning turned out not to hold —
65
+ * a closed dialog/flyout menu populated on open is a real false-positive
66
+ * shape. In this rule specifically the descendant-level eligibility gate
67
+ * already made the container-level gate redundant for correctness (an
68
+ * ineligible container has no eligible descendants either, so `owned`
69
+ * ends up empty and nothing fails) — but skipping the container up front
70
+ * reports `notApplicable` instead of a vacuous `pass`, which is the more
71
+ * accurate outcome for a container that isn't currently exposed at all,
72
+ * and avoids walking a subtree whose result is already known.
73
+ * - No aria-busy exemption here (unlike aria-required-children): the
74
+ * WAI-ARIA spec's aria-busy escape hatch is specifically about a
75
+ * 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.
78
+ */
79
+
80
+ const id = 'aria-prohibited-children';
81
+
82
+ const meta = {
83
+ title: 'Container roles must not own a child with a disallowed role',
84
+ description: 'Checks that every accessible-tree-owned child of a container role (list, listbox, menu, menubar, radiogroup, rowgroup, table, grid, treegrid, tablist, tree, row) has one of that role\'s allowed owned roles — the same set as its required owned roles.',
85
+ i18n: {
86
+ titleKey: 'ariaProhibitedChildren_title',
87
+ descriptionKey: 'ariaProhibitedChildren_description'
88
+ },
89
+ helpUrl: null,
90
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
91
+ wcagSc: ['4.1.2'],
92
+ normativeMappings: [
93
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
94
+ ],
95
+ defaultSeverity: 'moderate',
96
+ category: 'robust',
97
+ type: 'automatic',
98
+ defaultConfidence: 'medium',
99
+ coverage: { facetsBySc: { '4.1.2': ['aria-role-owned-children-allowed'] } }
100
+ };
101
+
102
+ function runInPage(ctx) {
103
+ const { document, root, helpers, rule } = ctx;
104
+ const safeRoot = root || document;
105
+
106
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
107
+ if (!ariaHelpers) {
108
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
109
+ }
110
+
111
+ function isEligibleAcc(el) {
112
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
113
+ if (!fn) return true;
114
+ try {
115
+ const r = fn(el, ctx);
116
+ if (typeof r === 'boolean') return r;
117
+ return !!(r && r.eligible);
118
+ } catch {
119
+ return true;
120
+ }
121
+ }
122
+
123
+ // The WAI-ARIA "Global States and Properties" set (same list as
124
+ // aria-allowed-attr.js's GLOBAL_ATTRS — duplicated, not imported, since
125
+ // runInPage must be self-contained per scripts/build-core.js). A
126
+ // roleless descendant carrying any of these is a real accessible-tree
127
+ // node a widely-used reference engine's getOwnedRoles also flags, not a transparent wrapper.
128
+ const GLOBAL_ARIA_ATTRS = [
129
+ 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy',
130
+ 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description',
131
+ 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage',
132
+ 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
133
+ 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', 'aria-owns',
134
+ 'aria-relevant', 'aria-roledescription'
135
+ ];
136
+
137
+ function getGlobalAriaAttr(el) {
138
+ for (const attr of GLOBAL_ARIA_ATTRS) {
139
+ const v = el.getAttribute ? el.getAttribute(attr) : null;
140
+ if (v != null) return attr;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ const MAX_DEPTH = 40;
146
+
147
+ // Collects this container's owned-role entries, pruning role="none"/
148
+ // "presentation" and required-matching "group"/"rowgroup" wrappers as
149
+ // transparent (recursing through them), and stopping at the first
150
+ // non-transparent role boundary otherwise — see header comment. A
151
+ // roleless descendant is ALSO a non-transparent boundary (an owned
152
+ // entry with role: null, which can never satisfy a required-role set)
153
+ // when it carries a global aria-* attribute or is focusable — matches
154
+ // a widely-used reference engine's own getOwnedRoles exactly (see header comment).
155
+ function collectOwnedRoles(el, requiredSet, out, depth) {
156
+ if (depth > MAX_DEPTH) return;
157
+ const kids = el.children ? Array.prototype.slice.call(el.children) : [];
158
+ for (const kid of kids) {
159
+ if (!kid || kid.nodeType !== 1) continue;
160
+ if (!isEligibleAcc(kid)) continue;
161
+
162
+ const kidRole = ariaHelpers.getExplicitRole(kid);
163
+ const isPresentational = kidRole === 'presentation' || kidRole === 'none';
164
+ const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
165
+
166
+ if (!kidRole && !isPresentational) {
167
+ const globalAttr = getGlobalAriaAttr(kid);
168
+ let focusable = false;
169
+ try {
170
+ const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
171
+ focusable = !!(fi && fi.focusable);
172
+ } catch {
173
+ focusable = false;
174
+ }
175
+ if (globalAttr || focusable) {
176
+ out.push({ el: kid, role: null, attr: globalAttr || 'tabindex' });
177
+ continue; // real accessible-tree node: stop here, do not recurse further
178
+ }
179
+ }
180
+
181
+ if (!kidRole || isPresentational || isTransparentGroup) {
182
+ collectOwnedRoles(kid, requiredSet, out, depth + 1);
183
+ continue;
184
+ }
185
+
186
+ if (!ariaHelpers.isValidConcreteRole(kidRole)) continue; // aria-roles-valid's concern
187
+
188
+ out.push({ el: kid, role: kidRole });
189
+ // Stop here: a real, non-transparent role is its own semantic unit.
190
+ }
191
+ }
192
+
193
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
194
+
195
+ const occurrences = [];
196
+ let applicableCount = 0;
197
+
198
+ for (const el of nodes) {
199
+ if (!el || !el.getAttribute) continue;
200
+
201
+ const role = ariaHelpers.getExplicitRole(el);
202
+ if (!role || !ariaHelpers.isValidConcreteRole(role)) continue;
203
+
204
+ const requiredOwned = ariaHelpers.getRequiredOwnedRoles(role);
205
+ if (!requiredOwned || !requiredOwned.length) continue;
206
+
207
+ if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
208
+
209
+ applicableCount += 1;
210
+
211
+ const requiredSet = new Set(requiredOwned);
212
+ const owned = [];
213
+ collectOwnedRoles(el, requiredSet, owned, 0);
214
+
215
+ for (const entry of owned) {
216
+ if (entry.role && requiredSet.has(entry.role)) continue;
217
+
218
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
219
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(entry.el) : (entry.el.outerHTML || '');
220
+ const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
221
+
222
+ const isRoleless = !entry.role;
223
+ const summary = isRoleless
224
+ ? `This element has no explicit role but carries ${entry.attr}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`
225
+ : `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
226
+ const hint = isRoleless
227
+ ? `Remove ${entry.attr} (or the role="${role}" container ownership), or give this element role="presentation"/"none" if it isn't meant to be its own accessible-tree node.`
228
+ : `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.`;
229
+
230
+ occurrences.push({
231
+ selector: stableSelector,
232
+ html,
233
+ summary,
234
+ hint,
235
+ i18n: {
236
+ summaryKey: isRoleless ? 'ariaProhibitedChildren_summary_fail_roleless' : 'ariaProhibitedChildren_summary_fail',
237
+ hintKey: isRoleless ? 'ariaProhibitedChildren_hint_fail_roleless' : 'ariaProhibitedChildren_hint_fail',
238
+ params: isRoleless
239
+ ? { attr: entry.attr, containerRole: role }
240
+ : { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
241
+ },
242
+ data: {
243
+ details: {
244
+ reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
245
+ childRole: entry.role,
246
+ attr: entry.attr,
247
+ containerRole: role,
248
+ containerSelector,
249
+ allowedOwnedRoles: requiredOwned
250
+ }
251
+ }
252
+ });
253
+ }
254
+ }
255
+
256
+ if (applicableCount === 0) {
257
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
258
+ }
259
+ if (occurrences.length) {
260
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
261
+ }
262
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
263
+ }
264
+
265
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-required-attr
5
+ * @atomic true
6
+ * @summary Roles with an unambiguous required state/property must carry it
7
+ * @standard WCAG 2.2
8
+ * @sc 4.1.2
9
+ * @applicability
10
+ * Applies to elements with an explicit, valid, non-abstract role that is
11
+ * also one of the small set of roles with a documented, context-
12
+ * independent required state/property (checkbox, combobox, heading,
13
+ * menuitemcheckbox, menuitemradio, meter, radio, scrollbar, slider,
14
+ * switch).
15
+ * @expectation
16
+ * Every required aria-* attribute for that role is present (and non-empty).
17
+ * @implementation-notes
18
+ * - Deliberately scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
19
+ * which only lists a required property when the spec is unambiguous and
20
+ * context-independent — see that file's header for the rationale.
21
+ * - Widened 2026-07-21 to add `meter` (`aria-valuenow`), verified against
22
+ * a widely-used reference engine's own `requiredAttrs` table. Deliberately
23
+ * did NOT add two other entries from that same table: `progressbar`'s
24
+ * `aria-valuenow` (a legitimately indeterminate progressbar omits it —
25
+ * that engine itself excludes progressbar from its own table for this reason)
26
+ * and `combobox`'s `aria-controls` (confirmed via MDN's combobox role
27
+ * page to be conditional — only required once the popup is actually
28
+ * displayed, not unconditionally). See src/core/aria-helpers.js's
29
+ * REQUIRED_PROPS_BY_ROLE comment for the full reasoning.
30
+ * - Gated on isAccTreeEligible for the element itself: unlike a syntax-
31
+ * level check (attribute name/value validity), "does this element
32
+ * currently carry its required state attribute" is not fixed once
33
+ * written — checkbox/switch/radio's aria-checked and slider/scrollbar's
34
+ * aria-valuenow are exactly the kind of live-widget-state attribute
35
+ * component libraries set during hydration/mount, at the same moment
36
+ * the element becomes exposed. Same false-positive shape as
37
+ * aria-required-children; an element that isn't currently exposed to
38
+ * the accessibility tree is skipped (notApplicable), not failed.
39
+ * - Also treats aria-busy="true" as an exemption, same as
40
+ * aria-required-children. Note this is an extension by analogy, not a
41
+ * literal reading of the spec: WAI-ARIA's aria-busy carve-out text names
42
+ * "required owned elements" specifically, not required state attributes.
43
+ * The underlying rationale (a widget mid-initialization shouldn't be
44
+ * flagged for not yet reflecting state the same initialization step is
45
+ * about to set) applies equally here, so the exemption is extended by
46
+ * analogy rather than by explicit spec text.
47
+ */
48
+
49
+ const id = 'aria-required-attr';
50
+
51
+ const meta = {
52
+ title: 'Roles with a required ARIA state/property must carry it',
53
+ description: 'Checks that elements with an explicit role carry every unambiguous, context-independent required aria-* state/property for that role (e.g. role="checkbox" must have aria-checked).',
54
+ i18n: {
55
+ titleKey: 'ariaRequiredAttr_title',
56
+ descriptionKey: 'ariaRequiredAttr_description'
57
+ },
58
+ helpUrl: null,
59
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
60
+ wcagSc: ['4.1.2'],
61
+ normativeMappings: [
62
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
63
+ ],
64
+ defaultSeverity: 'serious',
65
+ category: 'robust',
66
+ type: 'automatic',
67
+ defaultConfidence: 'high',
68
+ coverage: { facetsBySc: { '4.1.2': ['aria-attr-required-for-role'] } }
69
+ };
70
+
71
+ function runInPage(ctx) {
72
+ const { document, root, helpers, rule } = ctx;
73
+ const safeRoot = root || document;
74
+
75
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
76
+ if (!ariaHelpers) {
77
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
78
+ }
79
+
80
+ function isEligibleAcc(el) {
81
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
82
+ if (!fn) return true;
83
+ try {
84
+ const r = fn(el, ctx);
85
+ if (typeof r === 'boolean') return r;
86
+ return !!(r && r.eligible);
87
+ } catch {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ function isMarkedBusy(el) {
93
+ const v = el.getAttribute('aria-busy');
94
+ return v != null && String(v).trim().toLowerCase() === 'true';
95
+ }
96
+
97
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
98
+
99
+ const occurrences = [];
100
+ let applicableCount = 0;
101
+
102
+ for (const el of nodes) {
103
+ if (!el || !el.getAttribute) continue;
104
+
105
+ const role = ariaHelpers.getExplicitRole(el);
106
+ if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
107
+
108
+ const required = ariaHelpers.getRequiredAttrsForRole(role);
109
+ if (!required.length) continue;
110
+
111
+ if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
112
+ if (isMarkedBusy(el)) continue; // author has signaled transient incompleteness per WAI-ARIA
113
+
114
+ applicableCount += 1;
115
+
116
+ const missing = [];
117
+ for (const attr of required) {
118
+ const v = el.getAttribute(attr);
119
+ if (v == null || String(v).trim() === '') missing.push(attr);
120
+ }
121
+
122
+ if (!missing.length) continue;
123
+
124
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
125
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
126
+
127
+ for (const attr of missing) {
128
+ occurrences.push({
129
+ selector: stableSelector,
130
+ html,
131
+ summary: 'This attribute is required for this element’s role, but is missing.',
132
+ hint: 'Add this attribute with a valid value for this role.',
133
+ i18n: {
134
+ summaryKey: 'ariaRequiredAttr_summary_fail',
135
+ hintKey: 'ariaRequiredAttr_hint_fail',
136
+ params: { attr, role }
137
+ },
138
+ data: {
139
+ details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
140
+ }
141
+ });
142
+ }
143
+ }
144
+
145
+ if (applicableCount === 0) {
146
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
147
+ }
148
+ if (occurrences.length) {
149
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
150
+ }
151
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
152
+ }
153
+
154
+ module.exports = { id, meta, runInPage };