@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.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- 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 };
|