@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,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 };
|