@surea11y/core 1.4.1 → 1.6.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 +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -12,23 +12,38 @@
|
|
|
12
12
|
* Applies to elements with an explicit, valid role that is one of the
|
|
13
13
|
* container roles with a documented "required owned elements" entry
|
|
14
14
|
* (the same REQUIRED_OWNED_ROLES table aria-required-children
|
|
15
|
-
* uses
|
|
15
|
+
* uses, see src/core/aria-helpers.js).
|
|
16
16
|
* @expectation
|
|
17
17
|
* Every accessible-tree-owned descendant of the container (after
|
|
18
18
|
* pruning role="none"/"presentation" elements and any "group"/
|
|
19
|
-
* "rowgroup" wrapper
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* owned set. Nothing else is a structurally
|
|
23
|
-
* composite/container role
|
|
19
|
+
* "rowgroup" wrapper, both always transparent for owned-element
|
|
20
|
+
* matching per WAI-ARIA, regardless of whether "group"/"rowgroup" is
|
|
21
|
+
* itself in the container's own required-owned-roles set) has a role
|
|
22
|
+
* from that same required-owned set. Nothing else is a structurally
|
|
23
|
+
* valid direct child of a composite/container role, where "allowed" is
|
|
24
|
+
* the container's required-owned roles plus the small
|
|
25
|
+
* ALLOWED_EXTRA_OWNED_ROLES set of roles it may own without being
|
|
26
|
+
* required to (a separator between menu items, a caption on a grid). A
|
|
27
|
+
* roleless wrapper
|
|
28
|
+
* is descended into to reach the items a component library buries
|
|
29
|
+
* inside it, but once one is found there the rest of that wrapper's
|
|
30
|
+
* subtree is the item's own content and is not judged against the
|
|
31
|
+
* container.
|
|
24
32
|
* @implementation-notes
|
|
25
33
|
* - A distinct atomic decision from aria-required-children (see that
|
|
26
34
|
* rule): "does at least one required child exist" vs "is every owned
|
|
27
|
-
* child one of the allowed roles"
|
|
35
|
+
* child one of the allowed roles", split per this repo's "one rule =
|
|
28
36
|
* one normative decision" principle.
|
|
29
|
-
* - The "allowed owned roles" set is
|
|
30
|
-
*
|
|
31
|
-
*
|
|
37
|
+
* - The "allowed owned roles" set is REQUIRED_OWNED_ROLES plus
|
|
38
|
+
* ALLOWED_EXTRA_OWNED_ROLES, because WAI-ARIA's "Required Owned
|
|
39
|
+
* Elements" says what a container MUST contain, not the exhaustive list
|
|
40
|
+
* of what it MAY contain. The extra table is kept small, and each entry
|
|
41
|
+
* needs a source: ARIA giving the child a Required Context Role that
|
|
42
|
+
* names this container (caption in table/grid), or the child role's own
|
|
43
|
+
* definition placing it there (separator in menu/menubar). Recorded and
|
|
44
|
+
* validated in scripts/generate-aria-tables.js. The two sets are
|
|
45
|
+
* used for different questions: only a REQUIRED role makes a roleless
|
|
46
|
+
* wrapper an item wrapper, while the allowed set decides the verdict.
|
|
32
47
|
* - A ROLELESS descendant that has any global WAI-ARIA attribute or is
|
|
33
48
|
* focusable is also flagged: it's treated as an owned entry with
|
|
34
49
|
* `role: null`, which can never match a container's required-owned-roles
|
|
@@ -42,16 +57,39 @@
|
|
|
42
57
|
* focusability (e.g. an `<a href>`), so the reported `data.details.attr`
|
|
43
58
|
* and message correctly say `nativeFocusable` rather than claiming a
|
|
44
59
|
* tabindex attribute that isn't actually present in the markup.
|
|
60
|
+
* - A roleless wrapper is transparent only as a route to the items inside
|
|
61
|
+
* it, never as a way to attribute the item's own content to the
|
|
62
|
+
* container. Unlike role="none"/"presentation", a roleless element is
|
|
63
|
+
* NOT removed from the accessibility tree: it is exposed as a generic
|
|
64
|
+
* node, so strictly nothing inside it is the container's child at all.
|
|
65
|
+
* The walk descends anyway, because component markup routinely buries
|
|
66
|
+
* the real item several roleless levels down (an Angular Material card
|
|
67
|
+
* whose radio sits at card > header > mat-radio-button > div > div >
|
|
68
|
+
* input[type=radio]) and refusing to descend would report every such
|
|
69
|
+
* container as owning nothing. Applied in both directions, that
|
|
70
|
+
* leniency turned every role-bearing element anywhere in an item's
|
|
71
|
+
* subtree into an owned child of the container: a `role="separator"`
|
|
72
|
+
* dividing two columns inside a radio card was reported as a
|
|
73
|
+
* prohibited child of the radiogroup. So when a roleless wrapper turns
|
|
74
|
+
* out to hold a required item, only the items it holds are collected;
|
|
75
|
+
* a wrapper holding no item at all is interposed content, and
|
|
76
|
+
* everything found inside it is still reported.
|
|
45
77
|
* - Recursion stops at the first non-transparent role boundary: a nested
|
|
46
78
|
* container with its own real role
|
|
47
79
|
* (e.g. a `<div role="listbox">` inside a menubar) is evaluated as its
|
|
48
80
|
* own owned-role entry against the outer container (and, separately,
|
|
49
|
-
* gets its own applicability pass as a container in the same rule run)
|
|
50
|
-
*
|
|
81
|
+
* gets its own applicability pass as a container in the same rule run),
|
|
82
|
+
* so its descendants are never misattributed to the outer container.
|
|
83
|
+
* "group"/"rowgroup" are always transparent instead, for any container
|
|
84
|
+
* role, not only the few (menu, menubar, tree) whose own required-owned
|
|
85
|
+
* set names "group" as an acceptable leaf role. Confirmed by ACT
|
|
86
|
+
* bc4a75's own examples, e.g. a `role="list"` (no "group" in its
|
|
87
|
+
* required-owned set) still treating a `role="group"` wrapper as
|
|
88
|
+
* transparent.
|
|
51
89
|
* - Child-role resolution uses `ariaHelpers.getContainmentRole` (explicit
|
|
52
|
-
* role, falling back to the native-tag map
|
|
90
|
+
* role, falling back to the native-tag map: li/tr/td/th/tbody/ul/ol/
|
|
53
91
|
* table/select/input[type=radio]), the same resolution
|
|
54
|
-
* aria-required-children's descendant matching uses
|
|
92
|
+
* aria-required-children's descendant matching uses, not
|
|
55
93
|
* `getExplicitRole`, which only sees an explicit role="" attribute. A
|
|
56
94
|
* bare `<li>` with no role="" (the common CSS-reset workaround
|
|
57
95
|
* `<ul role="list"><li>...</li></ul>`) must still resolve to the
|
|
@@ -67,14 +105,14 @@
|
|
|
67
105
|
* for correctness (an ineligible container has no eligible descendants
|
|
68
106
|
* either, so `owned` ends up empty and nothing fails), but skipping the
|
|
69
107
|
* container up front reports `notApplicable` instead of a vacuous
|
|
70
|
-
* `pass
|
|
71
|
-
* currently exposed at all
|
|
108
|
+
* `pass`, the more accurate outcome for a container that isn't
|
|
109
|
+
* currently exposed at all, and avoids walking a subtree whose result
|
|
72
110
|
* is already known.
|
|
73
111
|
* - No aria-busy exemption here (unlike aria-required-children): the
|
|
74
112
|
* WAI-ARIA spec's aria-busy escape hatch is specifically about a
|
|
75
113
|
* container missing its required owned elements while loading, not
|
|
76
|
-
* about a container that already has extra/disallowed owned elements
|
|
77
|
-
*
|
|
114
|
+
* about a container that already has extra/disallowed owned elements.
|
|
115
|
+
* That scenario isn't this rule's concern.
|
|
78
116
|
*/
|
|
79
117
|
|
|
80
118
|
const id = 'aria-prohibited-children';
|
|
@@ -82,7 +120,7 @@ const id = 'aria-prohibited-children';
|
|
|
82
120
|
const meta = {
|
|
83
121
|
title: 'Container roles must not own a child with a disallowed role',
|
|
84
122
|
description:
|
|
85
|
-
"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
|
|
123
|
+
"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.",
|
|
86
124
|
i18n: {
|
|
87
125
|
titleKey: 'ariaProhibitedChildren_title',
|
|
88
126
|
descriptionKey: 'ariaProhibitedChildren_description'
|
|
@@ -128,7 +166,7 @@ function runInPage(ctx) {
|
|
|
128
166
|
}
|
|
129
167
|
|
|
130
168
|
// The WAI-ARIA "Global States and Properties" set (same list as
|
|
131
|
-
// aria-allowed-attr.js's GLOBAL_ATTRS
|
|
169
|
+
// aria-allowed-attr.js's GLOBAL_ATTRS, duplicated rather than imported, since
|
|
132
170
|
// runInPage must be self-contained per scripts/build-core.js). A
|
|
133
171
|
// roleless descendant carrying any of these is a real accessible-tree
|
|
134
172
|
// node, not a transparent wrapper.
|
|
@@ -167,21 +205,41 @@ function runInPage(ctx) {
|
|
|
167
205
|
return null;
|
|
168
206
|
}
|
|
169
207
|
|
|
208
|
+
// Roles a container may own beyond its REQUIRED owned elements. WAI-ARIA's
|
|
209
|
+
// "Required Owned Elements" says what a container must contain, not the
|
|
210
|
+
// exhaustive list of what it may contain; using the required set as both
|
|
211
|
+
// reported a separator between menu items, and a caption on a grid, as
|
|
212
|
+
// prohibited children. Generated from scripts/generate-aria-tables.js, which
|
|
213
|
+
// documents the source for every entry and validates each against
|
|
214
|
+
// aria-query's Required Context Role data.
|
|
215
|
+
// <generated:aria-allowed-extra-owned-roles>
|
|
216
|
+
const ALLOWED_EXTRA_OWNED_ROLES = {
|
|
217
|
+
grid: ['caption'],
|
|
218
|
+
menu: ['separator'],
|
|
219
|
+
menubar: ['separator'],
|
|
220
|
+
table: ['caption']
|
|
221
|
+
};
|
|
222
|
+
// </generated:aria-allowed-extra-owned-roles>
|
|
223
|
+
|
|
170
224
|
const MAX_DEPTH = 40;
|
|
171
225
|
|
|
172
226
|
// Collects this container's owned-role entries, pruning role="none"/
|
|
173
|
-
// "presentation" and
|
|
174
|
-
//
|
|
175
|
-
// non-transparent role boundary otherwise
|
|
176
|
-
// roleless
|
|
177
|
-
//
|
|
178
|
-
//
|
|
227
|
+
// "presentation" and "group"/"rowgroup" wrappers as transparent
|
|
228
|
+
// (recursing through them unconditionally, see header comment), and
|
|
229
|
+
// stopping at the first non-transparent role boundary otherwise. A
|
|
230
|
+
// roleless wrapper is transparent too, but only as a way to reach the
|
|
231
|
+
// items buried inside it: once one is found there, the rest of that
|
|
232
|
+
// wrapper's subtree belongs to the item, not to this container (see the
|
|
233
|
+
// roleless branch below). A roleless descendant is a non-transparent
|
|
234
|
+
// boundary (an owned entry with role: null, which can never satisfy a
|
|
235
|
+
// required-role set) when it carries a global aria-* attribute or is
|
|
236
|
+
// focusable.
|
|
179
237
|
// kidRole comes from getContainmentRole, not getExplicitRole: "roleless"
|
|
180
238
|
// here means neither an explicit role="" NOR one of the native
|
|
181
239
|
// containment tags (li, tr, td, ...), so a bare <li>/<tr>/... is a real
|
|
182
240
|
// listitem/row boundary, not a transparent wrapper the walk should pass
|
|
183
241
|
// through.
|
|
184
|
-
function collectOwnedRoles(el,
|
|
242
|
+
function collectOwnedRoles(el, out, depth, requiredSet) {
|
|
185
243
|
if (depth > MAX_DEPTH) return;
|
|
186
244
|
const kids = el.children ? Array.prototype.slice.call(el.children) : [];
|
|
187
245
|
for (const kid of kids) {
|
|
@@ -190,8 +248,7 @@ function runInPage(ctx) {
|
|
|
190
248
|
|
|
191
249
|
const kidRole = ariaHelpers.getContainmentRole(kid);
|
|
192
250
|
const isPresentational = kidRole === 'presentation' || kidRole === 'none';
|
|
193
|
-
const isTransparentGroup =
|
|
194
|
-
(kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
|
|
251
|
+
const isTransparentGroup = kidRole === 'group' || kidRole === 'rowgroup';
|
|
195
252
|
|
|
196
253
|
if (!kidRole && !isPresentational) {
|
|
197
254
|
const globalAttr = getGlobalAriaAttr(kid);
|
|
@@ -207,7 +264,7 @@ function runInPage(ctx) {
|
|
|
207
264
|
}
|
|
208
265
|
if (globalAttr || mechanism !== 'none') {
|
|
209
266
|
// `mechanism` distinguishes an actual tabindex="" attribute from
|
|
210
|
-
// native focusability (e.g. <a href>, <button>, <input>)
|
|
267
|
+
// native focusability (e.g. <a href>, <button>, <input>): these
|
|
211
268
|
// are different facts and must not be reported as the same
|
|
212
269
|
// "carries tabindex" claim (a native anchor with no tabindex
|
|
213
270
|
// attribute at all is not "carrying tabindex").
|
|
@@ -217,8 +274,37 @@ function runInPage(ctx) {
|
|
|
217
274
|
}
|
|
218
275
|
}
|
|
219
276
|
|
|
220
|
-
if (
|
|
221
|
-
|
|
277
|
+
if (isPresentational || isTransparentGroup) {
|
|
278
|
+
// role="none"/"presentation" really is removed from the accessibility
|
|
279
|
+
// tree, and its children are promoted to this container, so whatever
|
|
280
|
+
// is inside becomes an owned child in its own right. group/rowgroup stay
|
|
281
|
+
// unconditionally transparent for the reason in the header comment.
|
|
282
|
+
collectOwnedRoles(kid, out, depth + 1, requiredSet);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!kidRole) {
|
|
287
|
+
// A roleless wrapper is NOT removed from the accessibility tree: it is
|
|
288
|
+
// exposed as a generic node, so strictly speaking nothing inside it is
|
|
289
|
+
// this container's child at all. The walk descends anyway, because a
|
|
290
|
+
// component library routinely buries the real item several roleless
|
|
291
|
+
// levels down (an Angular Material card whose radio sits at
|
|
292
|
+
// card > header > mat-radio-button > div > div > input[type=radio]),
|
|
293
|
+
// and refusing to descend would report every such container as
|
|
294
|
+
// missing its items.
|
|
295
|
+
//
|
|
296
|
+
// That leniency has to run one way only. If the wrapper turns out to
|
|
297
|
+
// hold a required item, the wrapper is an item wrapper and everything
|
|
298
|
+
// else inside it is the ITEM's content, not the container's children:
|
|
299
|
+
// a mat-divider sitting in the card body beside the radio is not an
|
|
300
|
+
// owned child of the radiogroup, and reporting it as one is a false
|
|
301
|
+
// positive on ordinary component markup. Only the items are collected
|
|
302
|
+
// in that case. A wrapper holding no item at all is pure interposed
|
|
303
|
+
// content, so everything found in it is still reported.
|
|
304
|
+
const nested = [];
|
|
305
|
+
collectOwnedRoles(kid, nested, depth + 1, requiredSet);
|
|
306
|
+
const items = nested.filter((entry) => entry.role && requiredSet.has(entry.role));
|
|
307
|
+
for (const entry of items.length ? items : nested) out.push(entry);
|
|
222
308
|
continue;
|
|
223
309
|
}
|
|
224
310
|
|
|
@@ -249,17 +335,20 @@ function runInPage(ctx) {
|
|
|
249
335
|
|
|
250
336
|
applicableCount += 1;
|
|
251
337
|
|
|
338
|
+
// Two different sets on purpose. requiredSet drives the item-wrapper
|
|
339
|
+
// detection in collectOwnedRoles: only a REQUIRED role makes a roleless
|
|
340
|
+
// wrapper an item wrapper, so a wrapper holding nothing but a separator is
|
|
341
|
+
// still interposed content. allowedRoles decides the verdict, and includes
|
|
342
|
+
// the roles a container may own without being required to.
|
|
252
343
|
const requiredSet = new Set(requiredOwned);
|
|
344
|
+
const allowedRoles = requiredOwned.concat(ALLOWED_EXTRA_OWNED_ROLES[role] || []);
|
|
345
|
+
const allowedSet = new Set(allowedRoles);
|
|
253
346
|
const owned = [];
|
|
254
|
-
collectOwnedRoles(el,
|
|
347
|
+
collectOwnedRoles(el, owned, 0, requiredSet);
|
|
255
348
|
|
|
256
349
|
for (const entry of owned) {
|
|
257
|
-
if (entry.role &&
|
|
350
|
+
if (entry.role && allowedSet.has(entry.role)) continue;
|
|
258
351
|
|
|
259
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
|
|
260
|
-
const html = helpers.getOuterHtmlSnippet
|
|
261
|
-
? helpers.getOuterHtmlSnippet(entry.el)
|
|
262
|
-
: entry.el.outerHTML || '';
|
|
263
352
|
const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
264
353
|
|
|
265
354
|
const isRoleless = !entry.role;
|
|
@@ -281,34 +370,38 @@ function runInPage(ctx) {
|
|
|
281
370
|
hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
|
|
282
371
|
} else {
|
|
283
372
|
summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
|
|
284
|
-
hint = `Remove or change this role so it matches one of the container's allowed owned roles (${
|
|
373
|
+
hint = `Remove or change this role so it matches one of the container's allowed owned roles (${allowedRoles.join(', ')}), or move this element outside the ${role} container.`;
|
|
285
374
|
summaryKey = 'ariaProhibitedChildren_summary_fail';
|
|
286
375
|
hintKey = 'ariaProhibitedChildren_hint_fail';
|
|
287
376
|
}
|
|
288
377
|
|
|
289
|
-
occurrences.push(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
378
|
+
occurrences.push(
|
|
379
|
+
helpers.reportOccurrence(entry.el, {
|
|
380
|
+
summary,
|
|
381
|
+
hint,
|
|
382
|
+
i18n: {
|
|
383
|
+
summaryKey,
|
|
384
|
+
hintKey,
|
|
385
|
+
params: isRoleless
|
|
386
|
+
? { attr: entry.attr, containerRole: role }
|
|
387
|
+
: {
|
|
388
|
+
childRole: entry.role,
|
|
389
|
+
containerRole: role,
|
|
390
|
+
allowedRoles: allowedRoles.join(', ')
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
data: {
|
|
394
|
+
details: {
|
|
395
|
+
reasonCode: isRoleless ? 'ARIA_PROHIBITED_CHILD_ROLELESS' : 'ARIA_PROHIBITED_CHILD',
|
|
396
|
+
childRole: entry.role,
|
|
397
|
+
attr: entry.attr,
|
|
398
|
+
containerRole: role,
|
|
399
|
+
containerSelector,
|
|
400
|
+
allowedOwnedRoles: allowedRoles
|
|
401
|
+
}
|
|
309
402
|
}
|
|
310
|
-
}
|
|
311
|
-
|
|
403
|
+
})
|
|
404
|
+
);
|
|
312
405
|
}
|
|
313
406
|
}
|
|
314
407
|
|
|
@@ -12,24 +12,36 @@
|
|
|
12
12
|
* Applies to elements with an explicit, valid, non-abstract role that is
|
|
13
13
|
* also one of the small set of roles with a documented, context-
|
|
14
14
|
* independent required state/property (checkbox, combobox, heading,
|
|
15
|
-
* menuitemcheckbox, menuitemradio, meter, radio, scrollbar,
|
|
16
|
-
* switch)
|
|
15
|
+
* menuitemcheckbox, menuitemradio, meter, radio, scrollbar, separator,
|
|
16
|
+
* slider, switch) -- except when that explicit role is identical to the
|
|
17
|
+
* element's own native/implicit role (ACT 4e8ab6: e.g.
|
|
18
|
+
* <input type="checkbox" role="checkbox">, which is exempt because the
|
|
19
|
+
* native control's own state exposure already covers it; no aria-checked
|
|
20
|
+
* is required. helpers.aria.getNativeRoleForElement resolves this).
|
|
17
21
|
* @expectation
|
|
18
22
|
* Every required aria-* attribute for that role is present (and non-empty).
|
|
19
23
|
* @implementation-notes
|
|
20
|
-
* -
|
|
24
|
+
* - Scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
|
|
21
25
|
* which only lists a required property when the spec is unambiguous and
|
|
22
|
-
* context-independent
|
|
23
|
-
* - `meter`'s `aria-valuenow` is required.
|
|
24
|
-
* `progressbar`'s `aria-valuenow` (a legitimately
|
|
25
|
-
* progressbar omits it) and `combobox`'s `aria-controls`
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* context-independent, see that file's header for the rationale.
|
|
27
|
+
* - `meter`'s `aria-valuenow` is required. NOT required
|
|
28
|
+
* unconditionally: `progressbar`'s `aria-valuenow` (a legitimately
|
|
29
|
+
* indeterminate progressbar omits it) and `combobox`'s `aria-controls`
|
|
30
|
+
* (only required once the popup is actually displayed). ACT 4e8ab6's own
|
|
31
|
+
* test corpus confirms the conditional trigger: a role="combobox" with
|
|
32
|
+
* aria-expanded="true" and no (or empty) aria-controls fails, so that
|
|
33
|
+
* specific combination is checked directly below rather than through
|
|
34
|
+
* REQUIRED_PROPS_BY_ROLE's unconditional table. `separator`'s
|
|
35
|
+
* `aria-valuenow` is conditional in the same way: a plain separator is a
|
|
36
|
+
* structural divider that needs no value, but a focusable one is a
|
|
37
|
+
* splitter the user can move, and WAI-ARIA requires the value then. ACT
|
|
38
|
+
* 4e8ab6 fails exactly that shape (`<div role="separator" tabindex="0">`
|
|
39
|
+
* with no aria-valuenow), so focusability is read from
|
|
40
|
+
* helpers.getFocusableInfo at the same point.
|
|
29
41
|
* - Gated on isAccTreeEligible for the element itself: unlike a syntax-
|
|
30
42
|
* level check (attribute name/value validity), "does this element
|
|
31
43
|
* currently carry its required state attribute" is not fixed once
|
|
32
|
-
* written
|
|
44
|
+
* written, checkbox/switch/radio's aria-checked and slider/scrollbar's
|
|
33
45
|
* aria-valuenow are exactly the kind of live-widget-state attribute
|
|
34
46
|
* component libraries set during hydration/mount, at the same moment
|
|
35
47
|
* the element becomes exposed. Same false-positive shape as
|
|
@@ -95,6 +107,21 @@ function runInPage(ctx) {
|
|
|
95
107
|
}
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
// Focusability decides whether a separator is a widget; the same helper
|
|
111
|
+
// aria-hidden-focus and nested-interactive-controls-absent rely on, so
|
|
112
|
+
// :disabled, inert and invalid tabindex values are already accounted for.
|
|
113
|
+
function isFocusable(el) {
|
|
114
|
+
const fn =
|
|
115
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
116
|
+
if (!fn) return false;
|
|
117
|
+
try {
|
|
118
|
+
const info = fn(el, ctx);
|
|
119
|
+
return !!(info && info.focusable);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
function isMarkedBusy(el) {
|
|
99
126
|
const v = el.getAttribute('aria-busy');
|
|
100
127
|
return v != null && String(v).trim().toLowerCase() === 'true';
|
|
@@ -113,7 +140,28 @@ function runInPage(ctx) {
|
|
|
113
140
|
const role = ariaHelpers.getExplicitRole(el);
|
|
114
141
|
if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
|
|
115
142
|
|
|
116
|
-
|
|
143
|
+
// ACT 4e8ab6: an explicit role identical to the element's own native
|
|
144
|
+
// role is exempt -- the native control's own state exposure already
|
|
145
|
+
// covers it (e.g. <input type="checkbox" role="checkbox"> needs no
|
|
146
|
+
// aria-checked; the browser exposes .checked natively).
|
|
147
|
+
if (ariaHelpers.getNativeRoleForElement && ariaHelpers.getNativeRoleForElement(el) === role) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const required = ariaHelpers.getRequiredAttrsForRole(role).slice();
|
|
152
|
+
|
|
153
|
+
// combobox's aria-controls is required only once the popup is actually
|
|
154
|
+
// displayed (aria-expanded="true") -- see this file's header comment.
|
|
155
|
+
if (role === 'combobox' && String(el.getAttribute('aria-expanded') || '').trim() === 'true') {
|
|
156
|
+
required.push('aria-controls');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A separator only carries a value when it is focusable, i.e. a
|
|
160
|
+
// splitter the user can move -- see this file's header comment.
|
|
161
|
+
if (role === 'separator' && isFocusable(el)) {
|
|
162
|
+
required.push('aria-valuenow');
|
|
163
|
+
}
|
|
164
|
+
|
|
117
165
|
if (!required.length) continue;
|
|
118
166
|
|
|
119
167
|
if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
|
|
@@ -129,24 +177,21 @@ function runInPage(ctx) {
|
|
|
129
177
|
|
|
130
178
|
if (!missing.length) continue;
|
|
131
179
|
|
|
132
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
133
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
134
|
-
|
|
135
180
|
for (const attr of missing) {
|
|
136
|
-
occurrences.push(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
181
|
+
occurrences.push(
|
|
182
|
+
helpers.reportOccurrence(el, {
|
|
183
|
+
summary: 'This attribute is required for this element’s role, but is missing.',
|
|
184
|
+
hint: 'Add this attribute with a valid value for this role.',
|
|
185
|
+
i18n: {
|
|
186
|
+
summaryKey: 'ariaRequiredAttr_summary_fail',
|
|
187
|
+
hintKey: 'ariaRequiredAttr_hint_fail',
|
|
188
|
+
params: { attr, role }
|
|
189
|
+
},
|
|
190
|
+
data: {
|
|
191
|
+
details: { reasonCode: 'ARIA_ATTR_REQUIRED_MISSING', attr, role }
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
);
|
|
150
195
|
}
|
|
151
196
|
}
|
|
152
197
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* At least one descendant, or one aria-owns-referenced element, has one
|
|
18
18
|
* of the acceptable owned roles for that container role.
|
|
19
19
|
* @implementation-notes
|
|
20
|
-
* -
|
|
20
|
+
* - Scoped to REQUIRED_OWNED_ROLES in src/core/aria-helpers.js
|
|
21
21
|
* (see that file's header for the conservative-scope rationale).
|
|
22
22
|
* - Owned-role matching uses ariaHelpers.getContainmentRole, which combines
|
|
23
23
|
* explicit role="" attributes with a small, curated native-HTML-tag
|
|
@@ -28,12 +28,20 @@
|
|
|
28
28
|
* - Only one qualifying descendant/owned element is required (per
|
|
29
29
|
* WAI-ARIA "required owned elements": any one acceptable role satisfies
|
|
30
30
|
* the requirement); the full subtree is scanned without excluding nested
|
|
31
|
-
* containers with their own differing role, favoring simplicity
|
|
31
|
+
* containers with their own differing role, favoring simplicity, this
|
|
32
32
|
* can only under-report (recall), never over-report (fail integrity).
|
|
33
|
+
* - "At least one required child exists" is the whole of this rule's
|
|
34
|
+
* decision. Whether every owned child is ALLOWED is
|
|
35
|
+
* aria-prohibited-children's, and that rule does walk the owned graph
|
|
36
|
+
* exclusively, with group/rowgroup transparency. ACT bc4a75 asks both
|
|
37
|
+
* questions at once, which is why the mapping lists the two rules as a
|
|
38
|
+
* family: read on its own, this rule looks like it under-reports a
|
|
39
|
+
* container mixing valid and invalid children, and the sibling is what
|
|
40
|
+
* catches it.
|
|
33
41
|
* - Gated on isAccTreeEligible for the container itself: unlike this
|
|
34
42
|
* file's sibling attribute/role-validity checks (e.g. aria-roles-valid),
|
|
35
43
|
* "does this container currently have a required child" is not a fact
|
|
36
|
-
* that stays fixed once written
|
|
44
|
+
* that stays fixed once written, it is routinely filled in by the same
|
|
37
45
|
* script/interaction that reveals the container (a closed flyout menu
|
|
38
46
|
* or <dialog> populated on open). Flagging it while the container isn't
|
|
39
47
|
* currently exposed to the accessibility tree is a false positive; such
|
|
@@ -44,16 +52,16 @@
|
|
|
44
52
|
* widget is missing required owned elements due to script execution or
|
|
45
53
|
* loading, authors MUST mark a containing element with aria-busy equal
|
|
46
54
|
* to true." A container carrying aria-busy="true" is skipped the same
|
|
47
|
-
* way
|
|
55
|
+
* way, only the exact string "true" counts (absent/"false" do not).
|
|
48
56
|
* - Descendant search tries a fast native querySelectorAll(CANDIDATE_
|
|
49
57
|
* SELECTOR) first (covers the light-DOM-only common case with no added
|
|
50
58
|
* cost); only when that finds nothing AND the container has a <slot>
|
|
51
59
|
* anywhere in its subtree does it fall back to a composed-tree walk that
|
|
52
|
-
* expands <slot> elements via assignedElements({flatten:true})
|
|
60
|
+
* expands <slot> elements via assignedElements({flatten:true}). Plain
|
|
53
61
|
* querySelectorAll only sees a <slot>'s unrendered fallback content, never
|
|
54
|
-
* what's actually distributed into it.
|
|
62
|
+
* what's actually distributed into it. Scoped to slot
|
|
55
63
|
* expansion only, not a general "also descend into any nested custom
|
|
56
|
-
* element's own shadow root" walk
|
|
64
|
+
* element's own shadow root" walk, no known case needs that yet.
|
|
57
65
|
*/
|
|
58
66
|
|
|
59
67
|
const id = 'aria-required-children';
|
|
@@ -114,7 +122,7 @@ function runInPage(ctx) {
|
|
|
114
122
|
// Candidate selector for descendant scanning: explicit role attributes,
|
|
115
123
|
// plus every native tag ariaHelpers.getContainmentRole() recognizes
|
|
116
124
|
// (kept in sync with aria-helpers.js NATIVE_CONTAINMENT_ROLE_BY_ELEMENT).
|
|
117
|
-
// Declared inside runInPage
|
|
125
|
+
// Declared inside runInPage, see scripts/build-core.js header
|
|
118
126
|
// ("runInPage MUST be self-contained").
|
|
119
127
|
const CANDIDATE_SELECTOR =
|
|
120
128
|
'[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
|
|
@@ -130,11 +138,11 @@ function runInPage(ctx) {
|
|
|
130
138
|
// light-DOM subtree, so a container whose real owned children are
|
|
131
139
|
// distributed via <slot> (e.g. a shadow-DOM role="list" wrapping
|
|
132
140
|
// <slot></slot>, with the actual role="listitem" elements living in the
|
|
133
|
-
// light DOM and projected in) would never find them there
|
|
141
|
+
// light DOM and projected in) would never find them there, same class
|
|
134
142
|
// of bug as aria-required-parent's ancestor search, just in the opposite
|
|
135
143
|
// (descendant) direction.
|
|
136
144
|
//
|
|
137
|
-
//
|
|
145
|
+
// Scoped to slot expansion only, does NOT separately
|
|
138
146
|
// descend into an unrelated nested custom element's own shadow root
|
|
139
147
|
// (e.g. a <my-widget> child with no <slot> involvement at all). That's a
|
|
140
148
|
// qualitatively different question (does an arbitrary component's own
|
|
@@ -192,9 +200,8 @@ function runInPage(ctx) {
|
|
|
192
200
|
let found = false;
|
|
193
201
|
|
|
194
202
|
// Fast path first: native querySelectorAll over the curated candidate
|
|
195
|
-
// selector
|
|
196
|
-
//
|
|
197
|
-
// added cost.
|
|
203
|
+
// selector. Covers the overwhelming majority of containers (no shadow
|
|
204
|
+
// DOM involved at all) with zero added cost.
|
|
198
205
|
let descendants;
|
|
199
206
|
try {
|
|
200
207
|
descendants = el.querySelectorAll(CANDIDATE_SELECTOR);
|
|
@@ -210,7 +217,7 @@ function runInPage(ctx) {
|
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
// Slow path only when the fast path found nothing AND there's an actual
|
|
213
|
-
// <slot> somewhere in the subtree to expand
|
|
220
|
+
// <slot> somewhere in the subtree to expand, bounds the extra cost to
|
|
214
221
|
// exactly the containers that could possibly need it.
|
|
215
222
|
if (!found) {
|
|
216
223
|
let hasSlot;
|
|
@@ -253,27 +260,24 @@ function runInPage(ctx) {
|
|
|
253
260
|
|
|
254
261
|
if (found) continue;
|
|
255
262
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
|
|
272
|
-
role,
|
|
273
|
-
requiredOwnedRoles: requiredOwned
|
|
263
|
+
occurrences.push(
|
|
264
|
+
helpers.reportOccurrence(el, {
|
|
265
|
+
summary: 'This container role has no owned child with a required role.',
|
|
266
|
+
hint: 'Add a descendant (or aria-owns-referenced element) with one of the required owned roles.',
|
|
267
|
+
i18n: {
|
|
268
|
+
summaryKey: 'ariaRequiredChildren_summary_fail',
|
|
269
|
+
hintKey: 'ariaRequiredChildren_hint_fail',
|
|
270
|
+
params: { role, requiredRoles: requiredOwned.join(', ') }
|
|
271
|
+
},
|
|
272
|
+
data: {
|
|
273
|
+
details: {
|
|
274
|
+
reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
|
|
275
|
+
role,
|
|
276
|
+
requiredOwnedRoles: requiredOwned
|
|
277
|
+
}
|
|
274
278
|
}
|
|
275
|
-
}
|
|
276
|
-
|
|
279
|
+
})
|
|
280
|
+
);
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
if (applicableCount === 0) {
|