@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,274 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-required-children
5
+ * @atomic true
6
+ * @summary Container roles that require specific owned elements must contain at least one
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 container roles with a documented "required owned
12
+ * elements" entry (list, listbox, menu, menubar, radiogroup, rowgroup,
13
+ * table, grid, treegrid, tablist, tree, row).
14
+ * @expectation
15
+ * At least one descendant, or one aria-owns-referenced element, has one
16
+ * of the acceptable owned roles for that container role.
17
+ * @implementation-notes
18
+ * - Deliberately scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
19
+ * (see that file's header for the conservative-scope rationale).
20
+ * - Owned-role matching uses ariaHelpers.getContainmentRole, which combines
21
+ * explicit role="" attributes with a small, curated native-HTML-tag
22
+ * mapping (li, option, tr, td, th, ...). This avoids false positives on
23
+ * plain native markup under an explicitly-asserted container role, e.g.
24
+ * <ul role="list"><li>...</li></ul> (a common CSS-reset workaround where
25
+ * only the container gets an explicit role).
26
+ * - Only one qualifying descendant/owned element is required (per
27
+ * WAI-ARIA "required owned elements": any one acceptable role satisfies
28
+ * the requirement); the full subtree is scanned without excluding nested
29
+ * containers with their own differing role, favoring simplicity — this
30
+ * can only under-report (recall), never over-report (fail integrity).
31
+ * - Gated on isAccTreeEligible for the container itself: unlike this
32
+ * file's sibling attribute/role-validity checks (e.g. aria-roles-valid),
33
+ * "does this container currently have a required child" is not a fact
34
+ * that stays fixed once written — it is routinely filled in by the same
35
+ * script/interaction that reveals the container (a closed flyout menu
36
+ * or <dialog> populated on open). Flagging it while the container isn't
37
+ * currently exposed to the accessibility tree is a false positive; such
38
+ * a container is skipped entirely (does not count toward applicability),
39
+ * matching the pattern already used by this engine's accessible-name
40
+ * rules (svg-text-alternative-present, iframe-name-present, ...).
41
+ * - Also honors the WAI-ARIA spec's own explicit escape hatch: "When a
42
+ * widget is missing required owned elements due to script execution or
43
+ * loading, authors MUST mark a containing element with aria-busy equal
44
+ * to true." A container carrying aria-busy="true" is skipped the same
45
+ * way — only the exact string "true" counts (absent/"false" do not),
46
+ * matching a widely-used reference engine's own aria-required-children
47
+ * behavior.
48
+ * - Descendant search tries a fast native querySelectorAll(CANDIDATE_
49
+ * SELECTOR) first (covers the light-DOM-only common case with no added
50
+ * cost); only when that finds nothing AND the container has a <slot>
51
+ * anywhere in its subtree does it fall back to a composed-tree walk that
52
+ * expands <slot> elements via assignedElements({flatten:true}) — plain
53
+ * querySelectorAll only sees a <slot>'s unrendered fallback content, never
54
+ * what's actually distributed into it. Deliberately scoped to slot
55
+ * expansion only, not a general "also descend into any nested custom
56
+ * element's own shadow root" walk — no confirmed real-world case needs
57
+ * that yet.
58
+ */
59
+
60
+ const id = 'aria-required-children';
61
+
62
+ const meta = {
63
+ title: 'Container roles must own at least one required child role',
64
+ description: 'Checks that container roles with a documented "required owned elements" entry (list, listbox, menu, radiogroup, table, grid, tablist, tree, row, ...) contain at least one descendant or aria-owns-referenced element with an acceptable owned role.',
65
+ i18n: {
66
+ titleKey: 'ariaRequiredChildren_title',
67
+ descriptionKey: 'ariaRequiredChildren_description'
68
+ },
69
+ helpUrl: null,
70
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
71
+ wcagSc: ['4.1.2'],
72
+ normativeMappings: [
73
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
74
+ ],
75
+ defaultSeverity: 'moderate',
76
+ category: 'robust',
77
+ type: 'automatic',
78
+ defaultConfidence: 'medium',
79
+ coverage: { facetsBySc: { '4.1.2': ['aria-role-required-owned-children'] } }
80
+ };
81
+
82
+ function runInPage(ctx) {
83
+ const { document, root, helpers, rule } = ctx;
84
+ const safeRoot = root || document;
85
+
86
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
87
+ if (!ariaHelpers) {
88
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
89
+ }
90
+
91
+ function isEligibleAcc(el) {
92
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
93
+ if (!fn) return true;
94
+ try {
95
+ const r = fn(el, ctx);
96
+ if (typeof r === 'boolean') return r;
97
+ return !!(r && r.eligible);
98
+ } catch {
99
+ return true;
100
+ }
101
+ }
102
+
103
+ function isMarkedBusy(el) {
104
+ const v = el.getAttribute('aria-busy');
105
+ return v != null && String(v).trim().toLowerCase() === 'true';
106
+ }
107
+
108
+ // Candidate selector for descendant scanning: explicit role attributes,
109
+ // plus every native tag ariaHelpers.getContainmentRole() recognizes
110
+ // (kept in sync with aria-helpers.js NATIVE_CONTAINMENT_ROLE_BY_ELEMENT).
111
+ // Declared inside runInPage — see scripts/build-core.js header
112
+ // ("runInPage MUST be self-contained").
113
+ const CANDIDATE_SELECTOR = '[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
114
+
115
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
116
+
117
+ const occurrences = [];
118
+ let applicableCount = 0;
119
+
120
+ // Composed-tree descendant walk: el.querySelectorAll only sees the raw
121
+ // light-DOM subtree, so a container whose real owned children are
122
+ // distributed via <slot> (e.g. a shadow-DOM role="list" wrapping
123
+ // <slot></slot>, with the actual role="listitem" elements living in the
124
+ // light DOM and projected in) would never find them there — same class
125
+ // of bug as aria-required-parent's ancestor search, just in the opposite
126
+ // (descendant) direction. Found via Adobe Spectrum Web Components'
127
+ // sp-sidenav-item: its shadow root's role="list" div owns its listitems
128
+ // only through slot projection.
129
+ //
130
+ // Deliberately scoped to slot expansion only — does NOT separately
131
+ // descend into an unrelated nested custom element's own shadow root
132
+ // (e.g. a <my-widget> child with no <slot> involvement at all). That's a
133
+ // qualitatively different question (does an arbitrary component's own
134
+ // internal structure count as this container's "owned children"?) with
135
+ // no confirmed real-world case driving it yet; slot projection is the
136
+ // shape actually observed.
137
+ function collectComposedDescendants(node, out, seen, limit) {
138
+ if (!node || !node.children) return;
139
+ for (const child of Array.from(node.children)) {
140
+ if (out.length >= limit) return;
141
+ if (seen.has(child)) continue;
142
+
143
+ if ((child.tagName || '').toLowerCase() === 'slot' && typeof child.assignedElements === 'function') {
144
+ let assigned = [];
145
+ try {
146
+ assigned = child.assignedElements({ flatten: true }) || [];
147
+ } catch {
148
+ assigned = [];
149
+ }
150
+ for (const a of assigned) {
151
+ if (seen.has(a)) continue;
152
+ seen.add(a);
153
+ out.push(a);
154
+ if (out.length >= limit) return;
155
+ collectComposedDescendants(a, out, seen, limit);
156
+ if (out.length >= limit) return;
157
+ }
158
+ continue; // a <slot>'s own childNodes are unrendered fallback content once something is assigned
159
+ }
160
+
161
+ seen.add(child);
162
+ out.push(child);
163
+ collectComposedDescendants(child, out, seen, limit);
164
+ }
165
+ }
166
+
167
+ for (const el of nodes) {
168
+ if (!el || !el.getAttribute) continue;
169
+
170
+ const role = ariaHelpers.getExplicitRole(el);
171
+ if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
172
+
173
+ const requiredOwned = ariaHelpers.getRequiredOwnedRoles(role);
174
+ if (!requiredOwned || !requiredOwned.length) continue;
175
+
176
+ if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
177
+ if (isMarkedBusy(el)) continue; // author has signaled transient incompleteness per WAI-ARIA
178
+
179
+ applicableCount += 1;
180
+
181
+ const ownedSet = new Set(requiredOwned);
182
+ let found = false;
183
+
184
+ // Fast path first: native querySelectorAll over the curated candidate
185
+ // selector, exactly as before this fix — covers the overwhelming
186
+ // majority of containers (no shadow DOM involved at all) with zero
187
+ // added cost.
188
+ let descendants = [];
189
+ try {
190
+ descendants = el.querySelectorAll(CANDIDATE_SELECTOR);
191
+ } catch {
192
+ descendants = [];
193
+ }
194
+ for (const cand of descendants) {
195
+ const candRole = ariaHelpers.getContainmentRole(cand);
196
+ if (candRole && ownedSet.has(candRole)) {
197
+ found = true;
198
+ break;
199
+ }
200
+ }
201
+
202
+ // Slow path only when the fast path found nothing AND there's an actual
203
+ // <slot> somewhere in the subtree to expand — bounds the extra cost to
204
+ // exactly the containers that could possibly need it.
205
+ if (!found) {
206
+ let hasSlot = false;
207
+ try {
208
+ hasSlot = !!el.querySelector('slot');
209
+ } catch {
210
+ hasSlot = false;
211
+ }
212
+ if (hasSlot) {
213
+ const composed = [];
214
+ try {
215
+ collectComposedDescendants(el, composed, new Set(), 5000);
216
+ } catch {
217
+ // fall through with whatever was collected before the error
218
+ }
219
+ for (const cand of composed) {
220
+ if (!cand || !cand.getAttribute) continue;
221
+ const candRole = ariaHelpers.getContainmentRole(cand);
222
+ if (candRole && ownedSet.has(candRole)) {
223
+ found = true;
224
+ break;
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ if (!found) {
231
+ const ownsAttr = el.getAttribute('aria-owns');
232
+ if (ownsAttr && helpers.resolveIdRefs) {
233
+ const resolved = helpers.resolveIdRefs(ownsAttr, ctx, { maxRefs: 50 });
234
+ for (const ownedEl of resolved.refs || []) {
235
+ const candRole = ariaHelpers.getContainmentRole(ownedEl);
236
+ if (candRole && ownedSet.has(candRole)) {
237
+ found = true;
238
+ break;
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ if (found) continue;
245
+
246
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
247
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
248
+
249
+ occurrences.push({
250
+ selector: stableSelector,
251
+ html,
252
+ summary: 'This container role has no owned child with a required role.',
253
+ hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
254
+ i18n: {
255
+ summaryKey: 'ariaRequiredChildren_summary_fail',
256
+ hintKey: 'ariaRequiredChildren_hint_fail',
257
+ params: { role, requiredRoles: requiredOwned.join(', ') }
258
+ },
259
+ data: {
260
+ details: { reasonCode: 'ARIA_REQUIRED_CHILD_MISSING', role, requiredOwnedRoles: requiredOwned }
261
+ }
262
+ });
263
+ }
264
+
265
+ if (applicableCount === 0) {
266
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
267
+ }
268
+ if (occurrences.length) {
269
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
270
+ }
271
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
272
+ }
273
+
274
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check aria-required-parent
5
+ * @atomic true
6
+ * @summary Roles that require a specific ancestor/owner context role must have one
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 roles with a documented, non-empty "required context
12
+ * role" entry (listitem, option, menuitem, menuitemcheckbox,
13
+ * menuitemradio, tab, treeitem, row, cell, gridcell, columnheader,
14
+ * rowheader, rowgroup).
15
+ * @expectation
16
+ * The element has an ancestor (DOM containment) or owner (via that
17
+ * ancestor/owner's aria-owns) whose effective role is one of the
18
+ * acceptable context roles for this element's role.
19
+ * @implementation-notes
20
+ * - Deliberately scoped to REQUIRED_CONTEXT_ROLE in src/core/aria-helpers.js
21
+ * (see that file's header for the conservative-scope rationale); roles
22
+ * with an explicitly empty entry (e.g. tabpanel) are left unconstrained.
23
+ * - Context-role matching uses ariaHelpers.getContainmentRole, which
24
+ * combines explicit role="" attributes with a small, curated native-HTML-
25
+ * tag mapping, so a native ancestor (e.g. <table>/<tr>/<select>) without
26
+ * an explicit role still satisfies the requirement.
27
+ * - Ancestor search walks the flat/composed tree (helpers.composedParent:
28
+ * assignedSlot, then parentNode, then shadow host), not raw parentElement,
29
+ * so a slotted element's real rendered ancestor context (e.g. a shadow-
30
+ * tree role="list" wrapper around its <slot>) is found even though it's
31
+ * invisible to plain DOM containment; aria-owns is checked as a second,
32
+ * independent path via a reverse lookup over the search root.
33
+ * - Gated on isAccTreeEligible for the element itself. The ancestor-role
34
+ * walk itself doesn't care about visibility (a hidden ancestor's role is
35
+ * still found by plain DOM/composed-tree containment, so a genuinely
36
+ * correctly-nested-but-hidden widget was never at risk here) — the
37
+ * remaining false-positive shape is an element whose required ancestor
38
+ * context doesn't exist YET because it (and its wrapping context) are
39
+ * assembled together at reveal time (e.g. a portal-rendered item staged
40
+ * outside the live menu until opened). Same category of fix as
41
+ * aria-required-children/aria-prohibited-children, applied for
42
+ * consistency; an element that isn't currently exposed to the
43
+ * accessibility tree is skipped (notApplicable), not failed.
44
+ */
45
+
46
+ const id = 'aria-required-parent';
47
+
48
+ const meta = {
49
+ title: 'Roles requiring a specific context role must be in that context',
50
+ description: 'Checks that roles with a documented "required context role" entry (listitem, option, tab, treeitem, row, cell, ...) have an ancestor or aria-owns owner with an acceptable context role.',
51
+ i18n: {
52
+ titleKey: 'ariaRequiredParent_title',
53
+ descriptionKey: 'ariaRequiredParent_description'
54
+ },
55
+ helpUrl: null,
56
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
57
+ wcagSc: ['4.1.2'],
58
+ normativeMappings: [
59
+ { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
60
+ ],
61
+ defaultSeverity: 'moderate',
62
+ category: 'robust',
63
+ type: 'automatic',
64
+ defaultConfidence: 'medium',
65
+ coverage: { facetsBySc: { '4.1.2': ['aria-role-required-context-parent'] } }
66
+ };
67
+
68
+ function runInPage(ctx) {
69
+ const { document, root, helpers, rule } = ctx;
70
+ const safeRoot = root || document;
71
+
72
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
73
+ if (!ariaHelpers) {
74
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
75
+ }
76
+
77
+ function isEligibleAcc(el) {
78
+ const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
79
+ if (!fn) return true;
80
+ try {
81
+ const r = fn(el, ctx);
82
+ if (typeof r === 'boolean') return r;
83
+ return !!(r && r.eligible);
84
+ } catch {
85
+ return true;
86
+ }
87
+ }
88
+
89
+ // Roles that may host a nested listitem/treeitem group without breaking
90
+ // the required-context chain (verified against a widely-used reference
91
+ // engine's own getMissingContext, which special-cases exactly these two
92
+ // roles via its ownGroupRoles option).
93
+ const GROUP_TRANSPARENT_FOR_ROLES = new Set(['listitem', 'treeitem']);
94
+
95
+ // A real ancestor role — not "no role at all" and not the two roles that
96
+ // strip an element from the accessibility tree's parent/child chain
97
+ // entirely (presentation/none) — stops the search, matching that reference
98
+ // engine's getMissingContext. This is stricter than "any ancestor with the right
99
+ // role anywhere up the tree": the required-context relationship is about
100
+ // the accessibility tree's actual PARENT, so an intervening ancestor with
101
+ // its OWN distinct real role (e.g. a plain <li>'s native "listitem" role)
102
+ // blocks the search even if a further-up ancestor has the correct role.
103
+ // Found via a real page — Le Monde's review-carousel tablist, where each
104
+ // <button role="tab"> sits inside a plain <li> (native listitem) inside
105
+ // <ul role="tablist">: that reference engine correctly fails this (the tablist is never the
106
+ // tab's accessible-tree parent, listitem is), which the old "walk every
107
+ // ancestor" version here missed entirely.
108
+ function getRealContextRole(el) {
109
+ const role = ariaHelpers.getContainmentRole(el);
110
+ if (!role || role === 'presentation' || role === 'none') return '';
111
+ return role;
112
+ }
113
+
114
+ // Flat-tree ancestor walk (ctx.helpers.composedParent — assignedSlot wins
115
+ // over parentNode, then shadow host). A slotted light-DOM element's real
116
+ // rendered ancestor is whatever the shadow tree wraps its <slot> in (e.g.
117
+ // a role="list" container), not its own light-DOM parentElement — found
118
+ // via Adobe Spectrum Web Components' <sp-sidenav-item role="listitem">,
119
+ // distributed via slot="descendant" into its parent's shadow root, which
120
+ // wraps that slot in a <div role="list">. composedParent can return a
121
+ // non-Element node (a ShadowRoot, nodeType 11) when climbing out of a
122
+ // shadow tree that has no further light-DOM parent — skip those and keep
123
+ // climbing rather than treating them as a (roleless) context.
124
+ const getComposedParent = helpers && typeof helpers.composedParent === 'function'
125
+ ? helpers.composedParent
126
+ : function (n) { return n && n.parentElement ? n.parentElement : null; };
127
+
128
+ function hasAcceptableAncestorContext(el, acceptableRoles, ownRole) {
129
+ const allowsGroup = acceptableRoles.has('group');
130
+ let cur = getComposedParent(el);
131
+ let guard = 0;
132
+ while (cur && guard++ < 200) {
133
+ if (cur.nodeType !== 1) {
134
+ cur = getComposedParent(cur);
135
+ continue;
136
+ }
137
+ const role = getRealContextRole(cur);
138
+ if (!role) {
139
+ cur = getComposedParent(cur);
140
+ continue;
141
+ }
142
+ if (role === 'group' && allowsGroup && GROUP_TRANSPARENT_FOR_ROLES.has(ownRole)) {
143
+ cur = getComposedParent(cur);
144
+ continue;
145
+ }
146
+ return acceptableRoles.has(role);
147
+ }
148
+ return false;
149
+ }
150
+
151
+ function hasAcceptableOwnerContext(el, acceptableRoles) {
152
+ const elId = el.getAttribute('id');
153
+ const idTok = elId && String(elId).trim();
154
+ if (!idTok) return false;
155
+
156
+ const owners = helpers.queryAllSmart ? helpers.queryAllSmart('[aria-owns]', safeRoot) : helpers.queryAll('[aria-owns]', safeRoot);
157
+ for (const owner of owners) {
158
+ if (!owner || !owner.getAttribute) continue;
159
+ const ownsAttr = owner.getAttribute('aria-owns') || '';
160
+ const tokens = ownsAttr.split(/\s+/).filter(Boolean);
161
+ if (tokens.indexOf(idTok) === -1) continue;
162
+
163
+ const role = ariaHelpers.getContainmentRole(owner);
164
+ if (role && acceptableRoles.has(role)) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role]', safeRoot) : helpers.queryAll('[role]', safeRoot);
170
+
171
+ const occurrences = [];
172
+ let applicableCount = 0;
173
+
174
+ for (const el of nodes) {
175
+ if (!el || !el.getAttribute) continue;
176
+
177
+ const role = ariaHelpers.getExplicitRole(el);
178
+ if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
179
+
180
+ const requiredContext = ariaHelpers.getRequiredContextRoles(role);
181
+ if (!requiredContext || !requiredContext.length) continue; // no entry, or explicitly unconstrained
182
+
183
+ if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
184
+
185
+ applicableCount += 1;
186
+
187
+ const acceptableRoles = new Set(requiredContext);
188
+ const hasContext =
189
+ hasAcceptableAncestorContext(el, acceptableRoles, role) ||
190
+ hasAcceptableOwnerContext(el, acceptableRoles);
191
+
192
+ if (hasContext) continue;
193
+
194
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
195
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
196
+
197
+ occurrences.push({
198
+ selector: stableSelector,
199
+ html,
200
+ summary: 'This role requires a specific ancestor/owner context role, which was not found.',
201
+ hint: 'Place this element inside (or aria-owns-reference it from) an element with an acceptable context role.',
202
+ i18n: {
203
+ summaryKey: 'ariaRequiredParent_summary_fail',
204
+ hintKey: 'ariaRequiredParent_hint_fail',
205
+ params: { role, requiredRoles: requiredContext.join(', ') }
206
+ },
207
+ data: {
208
+ details: { reasonCode: 'ARIA_REQUIRED_PARENT_MISSING', role, requiredContextRoles: requiredContext }
209
+ }
210
+ });
211
+ }
212
+
213
+ if (applicableCount === 0) {
214
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
215
+ }
216
+ if (occurrences.length) {
217
+ return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
218
+ }
219
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
220
+ }
221
+
222
+ module.exports = { id, meta, runInPage };