@surea11y/core 1.5.0 → 1.7.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 +240 -149
- package/README.md +51 -44
- package/docs/ACT_RULE_MAPPING.md +245 -0
- package/docs/API_STABILITY.md +53 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
- package/docs/DESIGN_CHALLENGES.md +367 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +42 -4
- package/docs/I18N.md +4 -4
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +9 -5
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_AUTHORING.md +63 -36
- package/docs/RULE_CATALOG.md +1928 -169
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +21 -2
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WCAG_CONFORMANCE.md +34 -10
- package/package.json +11 -9
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +74 -10
- package/src/checks/automatic/aria-allowed-role.js +34 -25
- package/src/checks/automatic/aria-braille-equivalent.js +21 -13
- package/src/checks/automatic/aria-conditional-attr.js +22 -15
- package/src/checks/automatic/aria-deprecated-role.js +13 -1
- package/src/checks/automatic/aria-hidden-body.js +3 -3
- package/src/checks/automatic/aria-hidden-focus.js +5 -5
- package/src/checks/automatic/aria-prohibited-attr.js +23 -18
- package/src/checks/automatic/aria-prohibited-children.js +136 -43
- package/src/checks/automatic/aria-required-attr.js +119 -24
- package/src/checks/automatic/aria-required-children.js +54 -30
- package/src/checks/automatic/aria-required-parent.js +93 -15
- package/src/checks/automatic/aria-role-name-present.js +37 -23
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -10
- package/src/checks/automatic/autocomplete-valid.js +2 -2
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/binary-control-name-present.js +27 -5
- package/src/checks/automatic/button-name-present.js +92 -6
- package/src/checks/automatic/combobox-name-present.js +26 -6
- package/src/checks/automatic/contrast-computable.js +42 -0
- package/src/checks/automatic/contrast-enhanced.js +33 -1
- package/src/checks/automatic/contrast-minimum.js +33 -1
- package/src/checks/automatic/css-orientation-lock.js +138 -24
- package/src/checks/automatic/definition-list-children-valid.js +7 -8
- package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
- package/src/checks/automatic/dialog-name-present.js +20 -2
- package/src/checks/automatic/duplicate-id-aria.js +10 -3
- package/src/checks/automatic/duplicate-id.js +203 -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 +10 -1
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +68 -7
- package/src/checks/automatic/iframe-name-present.js +37 -3
- package/src/checks/automatic/iframe-title-unique.js +1 -1
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +204 -68
- package/src/checks/automatic/link-in-text-block.js +285 -29
- package/src/checks/automatic/link-name-present.js +22 -1
- package/src/checks/automatic/list-children-valid.js +6 -6
- package/src/checks/automatic/listbox-name-present.js +28 -8
- package/src/checks/automatic/listitem-parent-valid.js +4 -4
- package/src/checks/automatic/menuitem-name-present.js +20 -2
- package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
- package/src/checks/automatic/meter-name-present.js +23 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
- package/src/checks/automatic/option-name-present.js +23 -4
- 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 +23 -4
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
- package/src/checks/automatic/searchbox-name-present.js +28 -8
- package/src/checks/automatic/server-side-image-map-absent.js +1 -1
- package/src/checks/automatic/slider-name-present.js +27 -6
- package/src/checks/automatic/spinbutton-name-present.js +28 -8
- package/src/checks/automatic/summary-name-present.js +18 -2
- 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 +21 -2
- package/src/checks/automatic/table-headers-attr-valid.js +43 -8
- package/src/checks/automatic/table-th-has-data-cells.js +61 -5
- package/src/checks/automatic/target-size-minimum.js +155 -58
- package/src/checks/automatic/td-has-header.js +24 -23
- package/src/checks/automatic/textbox-name-present.js +28 -8
- package/src/checks/automatic/tooltip-name-present.js +21 -2
- package/src/checks/automatic/treeitem-name-present.js +23 -4
- package/src/checks/automatic/valid-lang.js +92 -7
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +3 -3
- 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 +5 -5
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
- 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 +58 -11
- package/src/checks/manual/empty-table-header-manual.js +8 -8
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +563 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
- package/src/checks/manual/heading-order-manual.js +3 -3
- 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 +4 -4
- 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 +4 -4
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
- package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
- package/src/checks/manual/landmark-one-main-manual.js +6 -6
- package/src/checks/manual/landmark-unique-manual.js +9 -9
- package/src/checks/manual/link-name-quality-manual.js +161 -32
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +2 -2
- package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
- 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 +6 -6
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
- package/src/checks/manual/region-manual.js +19 -19
- package/src/checks/manual/scope-attr-valid-manual.js +2 -2
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +5 -5
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +2 -2
- package/src/checks/manual/table-duplicate-name-manual.js +2 -2
- 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 +8880 -41883
- package/src/earl.js +144 -0
- package/src/report.js +2 -2
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -37882
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/bin/surea11y-core.js +0 -20
|
@@ -7,28 +7,43 @@
|
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Container roles must not own an accessible-tree child with a disallowed role
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
|
-
* @sc
|
|
10
|
+
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
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,20 +120,20 @@ 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'
|
|
89
127
|
},
|
|
90
128
|
helpUrl: null,
|
|
91
|
-
tags: ['wcag2a', '
|
|
92
|
-
wcagSc: ['
|
|
129
|
+
tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
|
|
130
|
+
wcagSc: ['1.3.1'],
|
|
93
131
|
normativeMappings: [
|
|
94
132
|
{
|
|
95
133
|
standard: 'WCAG',
|
|
96
134
|
version: '2.2',
|
|
97
|
-
requirement: '
|
|
98
|
-
title: '
|
|
135
|
+
requirement: '1.3.1',
|
|
136
|
+
title: 'Info and Relationships',
|
|
99
137
|
conformanceLevel: 'A'
|
|
100
138
|
}
|
|
101
139
|
],
|
|
@@ -103,7 +141,7 @@ const meta = {
|
|
|
103
141
|
category: 'robust',
|
|
104
142
|
type: 'automatic',
|
|
105
143
|
defaultConfidence: 'medium',
|
|
106
|
-
coverage: { facetsBySc: { '
|
|
144
|
+
coverage: { facetsBySc: { '1.3.1': ['aria-role-owned-children-allowed'] } }
|
|
107
145
|
};
|
|
108
146
|
|
|
109
147
|
function runInPage(ctx) {
|
|
@@ -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,12 +335,19 @@ 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
352
|
const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
260
353
|
|
|
@@ -277,7 +370,7 @@ function runInPage(ctx) {
|
|
|
277
370
|
hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
|
|
278
371
|
} else {
|
|
279
372
|
summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
|
|
280
|
-
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.`;
|
|
281
374
|
summaryKey = 'ariaProhibitedChildren_summary_fail';
|
|
282
375
|
hintKey = 'ariaProhibitedChildren_hint_fail';
|
|
283
376
|
}
|
|
@@ -294,7 +387,7 @@ function runInPage(ctx) {
|
|
|
294
387
|
: {
|
|
295
388
|
childRole: entry.role,
|
|
296
389
|
containerRole: role,
|
|
297
|
-
allowedRoles:
|
|
390
|
+
allowedRoles: allowedRoles.join(', ')
|
|
298
391
|
}
|
|
299
392
|
},
|
|
300
393
|
data: {
|
|
@@ -304,7 +397,7 @@ function runInPage(ctx) {
|
|
|
304
397
|
attr: entry.attr,
|
|
305
398
|
containerRole: role,
|
|
306
399
|
containerSelector,
|
|
307
|
-
allowedOwnedRoles:
|
|
400
|
+
allowedOwnedRoles: allowedRoles
|
|
308
401
|
}
|
|
309
402
|
}
|
|
310
403
|
})
|
|
@@ -12,24 +12,50 @@
|
|
|
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
|
-
* Every required
|
|
22
|
+
* Every required state/property for that role is present and non-empty.
|
|
23
|
+
* Graded by whether ARIA supplies a stand-in for the missing attribute:
|
|
24
|
+
* - FAIL where it does not, so the state is simply not exposed
|
|
25
|
+
* (aria-checked on checkbox/radio/switch/menuitemcheckbox/menuitemradio,
|
|
26
|
+
* aria-valuenow on slider/scrollbar/meter and on a focusable separator).
|
|
27
|
+
* - CANTTELL where ARIA defines an implicit value the role falls back to
|
|
28
|
+
* (aria-expanded on combobox, aria-level on heading), so the role still
|
|
29
|
+
* exposes a value and only the author knows whether it is the right one.
|
|
19
30
|
* @implementation-notes
|
|
20
|
-
* -
|
|
31
|
+
* - The implicit-value table is generated from aria-query's requiredProps by
|
|
32
|
+
* scripts/generate-aria-tables.js (REQUIRED_PROP_IMPLICIT_VALUES in
|
|
33
|
+
* src/core/aria-helpers.js), so the two tiers cannot drift apart from the
|
|
34
|
+
* spec by hand. ACT 4e8ab6 maps this rule's requirement to WAI-ARIA rather
|
|
35
|
+
* than to WCAG, and names 1.3.1/4.1.2 as "less strict" precisely because
|
|
36
|
+
* they "allow for fallback default values"; the cantTell tier is that
|
|
37
|
+
* carve-out, not a softening of the fail tier.
|
|
38
|
+
* - Scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
|
|
21
39
|
* 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
|
-
*
|
|
40
|
+
* context-independent, see that file's header for the rationale.
|
|
41
|
+
* - `meter`'s `aria-valuenow` is required. NOT required
|
|
42
|
+
* unconditionally: `progressbar`'s `aria-valuenow` (a legitimately
|
|
43
|
+
* indeterminate progressbar omits it) and `combobox`'s `aria-controls`
|
|
44
|
+
* (only required once the popup is actually displayed). ACT 4e8ab6's own
|
|
45
|
+
* test corpus confirms the conditional trigger: a role="combobox" with
|
|
46
|
+
* aria-expanded="true" and no (or empty) aria-controls fails, so that
|
|
47
|
+
* specific combination is checked directly below rather than through
|
|
48
|
+
* REQUIRED_PROPS_BY_ROLE's unconditional table. `separator`'s
|
|
49
|
+
* `aria-valuenow` is conditional in the same way: a plain separator is a
|
|
50
|
+
* structural divider that needs no value, but a focusable one is a
|
|
51
|
+
* splitter the user can move, and WAI-ARIA requires the value then. ACT
|
|
52
|
+
* 4e8ab6 fails exactly that shape (`<div role="separator" tabindex="0">`
|
|
53
|
+
* with no aria-valuenow), so focusability is read from
|
|
54
|
+
* helpers.getFocusableInfo at the same point.
|
|
29
55
|
* - Gated on isAccTreeEligible for the element itself: unlike a syntax-
|
|
30
56
|
* level check (attribute name/value validity), "does this element
|
|
31
57
|
* currently carry its required state attribute" is not fixed once
|
|
32
|
-
* written
|
|
58
|
+
* written, checkbox/switch/radio's aria-checked and slider/scrollbar's
|
|
33
59
|
* aria-valuenow are exactly the kind of live-widget-state attribute
|
|
34
60
|
* component libraries set during hydration/mount, at the same moment
|
|
35
61
|
* the element becomes exposed. Same false-positive shape as
|
|
@@ -95,6 +121,21 @@ function runInPage(ctx) {
|
|
|
95
121
|
}
|
|
96
122
|
}
|
|
97
123
|
|
|
124
|
+
// Focusability decides whether a separator is a widget; the same helper
|
|
125
|
+
// aria-hidden-focus and nested-interactive-controls-absent rely on, so
|
|
126
|
+
// :disabled, inert and invalid tabindex values are already accounted for.
|
|
127
|
+
function isFocusable(el) {
|
|
128
|
+
const fn =
|
|
129
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
130
|
+
if (!fn) return false;
|
|
131
|
+
try {
|
|
132
|
+
const info = fn(el, ctx);
|
|
133
|
+
return !!(info && info.focusable);
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
98
139
|
function isMarkedBusy(el) {
|
|
99
140
|
const v = el.getAttribute('aria-busy');
|
|
100
141
|
return v != null && String(v).trim().toLowerCase() === 'true';
|
|
@@ -104,7 +145,8 @@ function runInPage(ctx) {
|
|
|
104
145
|
? helpers.queryAllSmart('[role]')
|
|
105
146
|
: helpers.queryAll('[role]');
|
|
106
147
|
|
|
107
|
-
const
|
|
148
|
+
const failOccurrences = [];
|
|
149
|
+
const cantTellOccurrences = [];
|
|
108
150
|
let applicableCount = 0;
|
|
109
151
|
|
|
110
152
|
for (const el of nodes) {
|
|
@@ -113,7 +155,28 @@ function runInPage(ctx) {
|
|
|
113
155
|
const role = ariaHelpers.getExplicitRole(el);
|
|
114
156
|
if (!role || !ariaHelpers.isValidConcreteRole(role)) continue; // aria-roles-valid's concern
|
|
115
157
|
|
|
116
|
-
|
|
158
|
+
// ACT 4e8ab6: an explicit role identical to the element's own native
|
|
159
|
+
// role is exempt -- the native control's own state exposure already
|
|
160
|
+
// covers it (e.g. <input type="checkbox" role="checkbox"> needs no
|
|
161
|
+
// aria-checked; the browser exposes .checked natively).
|
|
162
|
+
if (ariaHelpers.getNativeRoleForElement && ariaHelpers.getNativeRoleForElement(el) === role) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const required = ariaHelpers.getRequiredAttrsForRole(role).slice();
|
|
167
|
+
|
|
168
|
+
// combobox's aria-controls is required only once the popup is actually
|
|
169
|
+
// displayed (aria-expanded="true") -- see this file's header comment.
|
|
170
|
+
if (role === 'combobox' && String(el.getAttribute('aria-expanded') || '').trim() === 'true') {
|
|
171
|
+
required.push('aria-controls');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// A separator only carries a value when it is focusable, i.e. a
|
|
175
|
+
// splitter the user can move -- see this file's header comment.
|
|
176
|
+
if (role === 'separator' && isFocusable(el)) {
|
|
177
|
+
required.push('aria-valuenow');
|
|
178
|
+
}
|
|
179
|
+
|
|
117
180
|
if (!required.length) continue;
|
|
118
181
|
|
|
119
182
|
if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
|
|
@@ -130,8 +193,43 @@ function runInPage(ctx) {
|
|
|
130
193
|
if (!missing.length) continue;
|
|
131
194
|
|
|
132
195
|
for (const attr of missing) {
|
|
133
|
-
|
|
196
|
+
const implicit =
|
|
197
|
+
typeof ariaHelpers.getRequiredAttrImplicitValue === 'function'
|
|
198
|
+
? ariaHelpers.getRequiredAttrImplicitValue(role, attr)
|
|
199
|
+
: null;
|
|
200
|
+
|
|
201
|
+
if (implicit) {
|
|
202
|
+
cantTellOccurrences.push(
|
|
203
|
+
helpers.reportOccurrence(el, {
|
|
204
|
+
occurrenceOutcome: 'cantTell',
|
|
205
|
+
summary: `This attribute is required for this element’s role and is missing, but ARIA falls back to "${implicit}".`,
|
|
206
|
+
hint: 'Set the attribute explicitly if the implicit value is not the state you mean.',
|
|
207
|
+
i18n: {
|
|
208
|
+
summaryKey: 'ariaRequiredAttr_summary_cantTell',
|
|
209
|
+
hintKey: 'ariaRequiredAttr_hint_cantTell',
|
|
210
|
+
params: { attr, role, implicit }
|
|
211
|
+
},
|
|
212
|
+
uncertainty: {
|
|
213
|
+
code: 'spec-only',
|
|
214
|
+
needed: 'Whether the implicit fallback is the state the author meant.',
|
|
215
|
+
evidence: { attribute: attr, role, implicitValue: implicit }
|
|
216
|
+
},
|
|
217
|
+
data: {
|
|
218
|
+
details: {
|
|
219
|
+
reasonCode: 'ARIA_ATTR_REQUIRED_MISSING_IMPLICIT',
|
|
220
|
+
attr,
|
|
221
|
+
role,
|
|
222
|
+
implicitValue: implicit
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
failOccurrences.push(
|
|
134
231
|
helpers.reportOccurrence(el, {
|
|
232
|
+
occurrenceOutcome: 'fail',
|
|
135
233
|
summary: 'This attribute is required for this element’s role, but is missing.',
|
|
136
234
|
hint: 'Add this attribute with a valid value for this role.',
|
|
137
235
|
i18n: {
|
|
@@ -150,15 +248,12 @@ function runInPage(ctx) {
|
|
|
150
248
|
if (applicableCount === 0) {
|
|
151
249
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
152
250
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
251
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
252
|
+
failOccurrences,
|
|
253
|
+
cantTellOccurrences,
|
|
254
|
+
rule.defaultSeverity || 'serious'
|
|
255
|
+
);
|
|
256
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
162
257
|
}
|
|
163
258
|
|
|
164
259
|
module.exports = { id, meta, runInPage };
|