@surea11y/core 1.2.0 → 1.4.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 +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -20,86 +22,54 @@
|
|
|
20
22
|
* owned set. Nothing else is a structurally valid direct child of a
|
|
21
23
|
* composite/container role.
|
|
22
24
|
* @implementation-notes
|
|
23
|
-
* - A distinct atomic decision from aria-required-children (see
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* container
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
* - Fixed 2026-07-30: a roleless-but-focusable descendant's message and
|
|
56
|
-
* `data.details.attr` used to always claim "carries tabindex" even when
|
|
57
|
-
* the element had no tabindex attribute at all and was only focusable
|
|
58
|
-
* natively (e.g. an <a href> link). `helpers.getFocusableInfo`'s
|
|
59
|
-
* `mechanism` field ('tabindex' | 'native' | ...) is now used to tell
|
|
60
|
-
* the two apart, with a distinct `nativeFocusable` attr/message for the
|
|
61
|
-
* native case. Found via a real Angular app: a routerLink <a> inside a
|
|
62
|
-
* role="list" was reported as "carries tabindex" though the rendered
|
|
63
|
-
* markup had no such attribute.
|
|
64
|
-
* - Recursion stops at the first non-transparent role boundary, same as
|
|
65
|
-
* that reference engine: a nested container with its own real role (e.g. a
|
|
66
|
-
* <div role="listbox"> inside a menubar) is evaluated as ITS OWN
|
|
67
|
-
* owned-role entry against the outer container (and, separately, gets
|
|
68
|
-
* its own applicability pass as a container in the same rule run) —
|
|
69
|
-
* its descendants are never misattributed to the outer container.
|
|
70
|
-
* - Fixed 2026-07-31: child-role resolution used `ariaHelpers.getExplicitRole`
|
|
71
|
-
* (explicit role="" attribute only), unlike aria-required-children's
|
|
72
|
-
* descendant matching which uses `ariaHelpers.getContainmentRole` (explicit
|
|
25
|
+
* - A distinct atomic decision from aria-required-children (see that
|
|
26
|
+
* rule): "does at least one required child exist" vs "is every owned
|
|
27
|
+
* child one of the allowed roles" — split per this repo's "one rule =
|
|
28
|
+
* one normative decision" principle.
|
|
29
|
+
* - The "allowed owned roles" set is exactly REQUIRED_OWNED_ROLES, not a
|
|
30
|
+
* separately authored, broader list: an owned element is allowed only if
|
|
31
|
+
* its role is literally in the container's required set.
|
|
32
|
+
* - A ROLELESS descendant that has any global WAI-ARIA attribute or is
|
|
33
|
+
* focusable is also flagged: it's treated as an owned entry with
|
|
34
|
+
* `role: null`, which can never match a container's required-owned-roles
|
|
35
|
+
* set, so it's always "unallowed". Both signals
|
|
36
|
+
* (global-attribute presence, focusability via the shared
|
|
37
|
+
* `helpers.getFocusableInfo`) are static, declarative markup facts with
|
|
38
|
+
* no live-DOM/hydration risk, unlike e.g.
|
|
39
|
+
* `aria-checked-state-mismatch`'s DOM-property comparison.
|
|
40
|
+
* `helpers.getFocusableInfo`'s `mechanism` field ('tabindex' | 'native' |
|
|
41
|
+
* ...) distinguishes an explicit `tabindex` attribute from native
|
|
42
|
+
* focusability (e.g. an `<a href>`), so the reported `data.details.attr`
|
|
43
|
+
* and message correctly say `nativeFocusable` rather than claiming a
|
|
44
|
+
* tabindex attribute that isn't actually present in the markup.
|
|
45
|
+
* - Recursion stops at the first non-transparent role boundary: a nested
|
|
46
|
+
* container with its own real role
|
|
47
|
+
* (e.g. a `<div role="listbox">` inside a menubar) is evaluated as its
|
|
48
|
+
* 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
|
+
* — its descendants are never misattributed to the outer container.
|
|
51
|
+
* - Child-role resolution uses `ariaHelpers.getContainmentRole` (explicit
|
|
73
52
|
* role, falling back to the native-tag map — li/tr/td/th/tbody/ul/ol/
|
|
74
|
-
* table/select/input[type=radio]
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* without re-deriving it, and that reasoning turned out not to hold —
|
|
95
|
-
* a closed dialog/flyout menu populated on open is a real false-positive
|
|
96
|
-
* shape. In this rule specifically the descendant-level eligibility gate
|
|
97
|
-
* already made the container-level gate redundant for correctness (an
|
|
98
|
-
* ineligible container has no eligible descendants either, so `owned`
|
|
99
|
-
* ends up empty and nothing fails) — but skipping the container up front
|
|
100
|
-
* reports `notApplicable` instead of a vacuous `pass`, which is the more
|
|
101
|
-
* accurate outcome for a container that isn't currently exposed at all,
|
|
102
|
-
* and avoids walking a subtree whose result is already known.
|
|
53
|
+
* table/select/input[type=radio]), the same resolution
|
|
54
|
+
* aria-required-children's descendant matching uses — not
|
|
55
|
+
* `getExplicitRole`, which only sees an explicit role="" attribute. A
|
|
56
|
+
* bare `<li>` with no role="" (the common CSS-reset workaround
|
|
57
|
+
* `<ul role="list"><li>...</li></ul>`) must still resolve to the
|
|
58
|
+
* implicit listitem role so the walk stops at that boundary instead of
|
|
59
|
+
* recursing straight through it into the listitem's own subtree; the
|
|
60
|
+
* same applies to every container role in REQUIRED_OWNED_ROLES whose
|
|
61
|
+
* native-tag counterpart the child map covers (e.g. a bare
|
|
62
|
+
* `<tr>`/`<td>` under a role="table"/"grid"/"row" container).
|
|
63
|
+
* - Gated on isAccTreeEligible for the container itself, matching
|
|
64
|
+
* aria-required-children: a closed dialog/flyout menu populated on open
|
|
65
|
+
* is a real false-positive shape otherwise. The descendant-level
|
|
66
|
+
* eligibility gate alone would make the container-level gate redundant
|
|
67
|
+
* for correctness (an ineligible container has no eligible descendants
|
|
68
|
+
* either, so `owned` ends up empty and nothing fails), but skipping the
|
|
69
|
+
* container up front reports `notApplicable` instead of a vacuous
|
|
70
|
+
* `pass` — the more accurate outcome for a container that isn't
|
|
71
|
+
* currently exposed at all — and avoids walking a subtree whose result
|
|
72
|
+
* is already known.
|
|
103
73
|
* - No aria-busy exemption here (unlike aria-required-children): the
|
|
104
74
|
* WAI-ARIA spec's aria-busy escape hatch is specifically about a
|
|
105
75
|
* container missing its required owned elements while loading, not
|
|
@@ -111,7 +81,8 @@ const id = 'aria-prohibited-children';
|
|
|
111
81
|
|
|
112
82
|
const meta = {
|
|
113
83
|
title: 'Container roles must not own a child with a disallowed role',
|
|
114
|
-
description:
|
|
84
|
+
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 — the same set as its required owned roles.",
|
|
115
86
|
i18n: {
|
|
116
87
|
titleKey: 'ariaProhibitedChildren_title',
|
|
117
88
|
descriptionKey: 'ariaProhibitedChildren_description'
|
|
@@ -120,7 +91,13 @@ const meta = {
|
|
|
120
91
|
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
121
92
|
wcagSc: ['4.1.2'],
|
|
122
93
|
normativeMappings: [
|
|
123
|
-
{
|
|
94
|
+
{
|
|
95
|
+
standard: 'WCAG',
|
|
96
|
+
version: '2.2',
|
|
97
|
+
requirement: '4.1.2',
|
|
98
|
+
title: 'Name, Role, Value',
|
|
99
|
+
conformanceLevel: 'A'
|
|
100
|
+
}
|
|
124
101
|
],
|
|
125
102
|
defaultSeverity: 'moderate',
|
|
126
103
|
category: 'robust',
|
|
@@ -130,7 +107,7 @@ const meta = {
|
|
|
130
107
|
};
|
|
131
108
|
|
|
132
109
|
function runInPage(ctx) {
|
|
133
|
-
const {
|
|
110
|
+
const { helpers, rule } = ctx;
|
|
134
111
|
|
|
135
112
|
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
136
113
|
if (!ariaHelpers) {
|
|
@@ -138,7 +115,8 @@ function runInPage(ctx) {
|
|
|
138
115
|
}
|
|
139
116
|
|
|
140
117
|
function isEligibleAcc(el) {
|
|
141
|
-
const fn =
|
|
118
|
+
const fn =
|
|
119
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
142
120
|
if (!fn) return true;
|
|
143
121
|
try {
|
|
144
122
|
const r = fn(el, ctx);
|
|
@@ -153,14 +131,32 @@ function runInPage(ctx) {
|
|
|
153
131
|
// aria-allowed-attr.js's GLOBAL_ATTRS — duplicated, not imported, since
|
|
154
132
|
// runInPage must be self-contained per scripts/build-core.js). A
|
|
155
133
|
// roleless descendant carrying any of these is a real accessible-tree
|
|
156
|
-
// node
|
|
134
|
+
// node, not a transparent wrapper.
|
|
157
135
|
const GLOBAL_ARIA_ATTRS = [
|
|
158
|
-
'aria-atomic',
|
|
159
|
-
'aria-
|
|
160
|
-
'aria-
|
|
161
|
-
'aria-
|
|
162
|
-
'aria-
|
|
163
|
-
'aria-
|
|
136
|
+
'aria-atomic',
|
|
137
|
+
'aria-braillelabel',
|
|
138
|
+
'aria-brailleroledescription',
|
|
139
|
+
'aria-busy',
|
|
140
|
+
'aria-controls',
|
|
141
|
+
'aria-current',
|
|
142
|
+
'aria-describedby',
|
|
143
|
+
'aria-description',
|
|
144
|
+
'aria-details',
|
|
145
|
+
'aria-disabled',
|
|
146
|
+
'aria-dropeffect',
|
|
147
|
+
'aria-errormessage',
|
|
148
|
+
'aria-flowto',
|
|
149
|
+
'aria-grabbed',
|
|
150
|
+
'aria-haspopup',
|
|
151
|
+
'aria-hidden',
|
|
152
|
+
'aria-invalid',
|
|
153
|
+
'aria-keyshortcuts',
|
|
154
|
+
'aria-label',
|
|
155
|
+
'aria-labelledby',
|
|
156
|
+
'aria-live',
|
|
157
|
+
'aria-owns',
|
|
158
|
+
'aria-relevant',
|
|
159
|
+
'aria-roledescription'
|
|
164
160
|
];
|
|
165
161
|
|
|
166
162
|
function getGlobalAriaAttr(el) {
|
|
@@ -179,13 +175,12 @@ function runInPage(ctx) {
|
|
|
179
175
|
// non-transparent role boundary otherwise — see header comment. A
|
|
180
176
|
// roleless descendant is ALSO a non-transparent boundary (an owned
|
|
181
177
|
// entry with role: null, which can never satisfy a required-role set)
|
|
182
|
-
// when it carries a global aria-* attribute or is focusable
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// wrapper the walk should pass through.
|
|
178
|
+
// when it carries a global aria-* attribute or is focusable.
|
|
179
|
+
// kidRole comes from getContainmentRole, not getExplicitRole: "roleless"
|
|
180
|
+
// here means neither an explicit role="" NOR one of the native
|
|
181
|
+
// containment tags (li, tr, td, ...), so a bare <li>/<tr>/... is a real
|
|
182
|
+
// listitem/row boundary, not a transparent wrapper the walk should pass
|
|
183
|
+
// through.
|
|
189
184
|
function collectOwnedRoles(el, requiredSet, out, depth) {
|
|
190
185
|
if (depth > MAX_DEPTH) return;
|
|
191
186
|
const kids = el.children ? Array.prototype.slice.call(el.children) : [];
|
|
@@ -195,13 +190,17 @@ function runInPage(ctx) {
|
|
|
195
190
|
|
|
196
191
|
const kidRole = ariaHelpers.getContainmentRole(kid);
|
|
197
192
|
const isPresentational = kidRole === 'presentation' || kidRole === 'none';
|
|
198
|
-
const isTransparentGroup =
|
|
193
|
+
const isTransparentGroup =
|
|
194
|
+
(kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
|
|
199
195
|
|
|
200
196
|
if (!kidRole && !isPresentational) {
|
|
201
197
|
const globalAttr = getGlobalAriaAttr(kid);
|
|
202
|
-
let mechanism
|
|
198
|
+
let mechanism;
|
|
203
199
|
try {
|
|
204
|
-
const fi =
|
|
200
|
+
const fi =
|
|
201
|
+
helpers && typeof helpers.getFocusableInfo === 'function'
|
|
202
|
+
? helpers.getFocusableInfo(kid, ctx)
|
|
203
|
+
: null;
|
|
205
204
|
mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
|
|
206
205
|
} catch {
|
|
207
206
|
mechanism = 'none';
|
|
@@ -230,7 +229,9 @@ function runInPage(ctx) {
|
|
|
230
229
|
}
|
|
231
230
|
}
|
|
232
231
|
|
|
233
|
-
const nodes = helpers.queryAllSmart
|
|
232
|
+
const nodes = helpers.queryAllSmart
|
|
233
|
+
? helpers.queryAllSmart('[role]')
|
|
234
|
+
: helpers.queryAll('[role]');
|
|
234
235
|
|
|
235
236
|
const occurrences = [];
|
|
236
237
|
let applicableCount = 0;
|
|
@@ -256,7 +257,9 @@ function runInPage(ctx) {
|
|
|
256
257
|
if (entry.role && requiredSet.has(entry.role)) continue;
|
|
257
258
|
|
|
258
259
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(entry.el) : 'html';
|
|
259
|
-
const html = helpers.getOuterHtmlSnippet
|
|
260
|
+
const html = helpers.getOuterHtmlSnippet
|
|
261
|
+
? helpers.getOuterHtmlSnippet(entry.el)
|
|
262
|
+
: entry.el.outerHTML || '';
|
|
260
263
|
const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
261
264
|
|
|
262
265
|
const isRoleless = !entry.role;
|
|
@@ -313,7 +316,12 @@ function runInPage(ctx) {
|
|
|
313
316
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
314
317
|
}
|
|
315
318
|
if (occurrences.length) {
|
|
316
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
ruleId: rule.ruleId,
|
|
321
|
+
outcome: 'fail',
|
|
322
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
323
|
+
occurrences
|
|
324
|
+
};
|
|
317
325
|
}
|
|
318
326
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
319
327
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -18,13 +20,10 @@
|
|
|
18
20
|
* - Deliberately scoped to REQUIRED_PROPS_BY_ROLE in src/core/aria-helpers.js,
|
|
19
21
|
* which only lists a required property when the spec is unambiguous and
|
|
20
22
|
* context-independent — see that file's header for the rationale.
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
23
|
+
* - `meter`'s `aria-valuenow` is required. Deliberately NOT required:
|
|
24
|
+
* `progressbar`'s `aria-valuenow` (a legitimately indeterminate
|
|
25
|
+
* progressbar omits it) and `combobox`'s `aria-controls` (conditional per
|
|
26
|
+
* MDN's combobox role page — only required once the popup is actually
|
|
28
27
|
* displayed, not unconditionally). See src/core/aria-helpers.js's
|
|
29
28
|
* REQUIRED_PROPS_BY_ROLE comment for the full reasoning.
|
|
30
29
|
* - Gated on isAccTreeEligible for the element itself: unlike a syntax-
|
|
@@ -50,7 +49,8 @@ const id = 'aria-required-attr';
|
|
|
50
49
|
|
|
51
50
|
const meta = {
|
|
52
51
|
title: 'Roles with a required ARIA state/property must carry it',
|
|
53
|
-
description:
|
|
52
|
+
description:
|
|
53
|
+
'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
54
|
i18n: {
|
|
55
55
|
titleKey: 'ariaRequiredAttr_title',
|
|
56
56
|
descriptionKey: 'ariaRequiredAttr_description'
|
|
@@ -59,7 +59,13 @@ const meta = {
|
|
|
59
59
|
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
60
60
|
wcagSc: ['4.1.2'],
|
|
61
61
|
normativeMappings: [
|
|
62
|
-
{
|
|
62
|
+
{
|
|
63
|
+
standard: 'WCAG',
|
|
64
|
+
version: '2.2',
|
|
65
|
+
requirement: '4.1.2',
|
|
66
|
+
title: 'Name, Role, Value',
|
|
67
|
+
conformanceLevel: 'A'
|
|
68
|
+
}
|
|
63
69
|
],
|
|
64
70
|
defaultSeverity: 'serious',
|
|
65
71
|
category: 'robust',
|
|
@@ -69,7 +75,7 @@ const meta = {
|
|
|
69
75
|
};
|
|
70
76
|
|
|
71
77
|
function runInPage(ctx) {
|
|
72
|
-
const {
|
|
78
|
+
const { helpers, rule } = ctx;
|
|
73
79
|
|
|
74
80
|
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
75
81
|
if (!ariaHelpers) {
|
|
@@ -77,7 +83,8 @@ function runInPage(ctx) {
|
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
function isEligibleAcc(el) {
|
|
80
|
-
const fn =
|
|
86
|
+
const fn =
|
|
87
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
81
88
|
if (!fn) return true;
|
|
82
89
|
try {
|
|
83
90
|
const r = fn(el, ctx);
|
|
@@ -93,7 +100,9 @@ function runInPage(ctx) {
|
|
|
93
100
|
return v != null && String(v).trim().toLowerCase() === 'true';
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
const nodes = helpers.queryAllSmart
|
|
103
|
+
const nodes = helpers.queryAllSmart
|
|
104
|
+
? helpers.queryAllSmart('[role]')
|
|
105
|
+
: helpers.queryAll('[role]');
|
|
97
106
|
|
|
98
107
|
const occurrences = [];
|
|
99
108
|
let applicableCount = 0;
|
|
@@ -121,7 +130,7 @@ function runInPage(ctx) {
|
|
|
121
130
|
if (!missing.length) continue;
|
|
122
131
|
|
|
123
132
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
124
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
133
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
125
134
|
|
|
126
135
|
for (const attr of missing) {
|
|
127
136
|
occurrences.push({
|
|
@@ -145,9 +154,14 @@ function runInPage(ctx) {
|
|
|
145
154
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
146
155
|
}
|
|
147
156
|
if (occurrences.length) {
|
|
148
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
ruleId: rule.ruleId,
|
|
159
|
+
outcome: 'fail',
|
|
160
|
+
severity: rule.defaultSeverity || 'serious',
|
|
161
|
+
occurrences
|
|
162
|
+
};
|
|
149
163
|
}
|
|
150
164
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
151
165
|
}
|
|
152
166
|
|
|
153
|
-
module.exports = { id, meta, runInPage };
|
|
167
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -42,9 +44,7 @@
|
|
|
42
44
|
* widget is missing required owned elements due to script execution or
|
|
43
45
|
* loading, authors MUST mark a containing element with aria-busy equal
|
|
44
46
|
* to true." A container carrying aria-busy="true" is skipped the same
|
|
45
|
-
* way — only the exact string "true" counts (absent/"false" do not)
|
|
46
|
-
* matching a widely-used reference engine's own aria-required-children
|
|
47
|
-
* behavior.
|
|
47
|
+
* way — only the exact string "true" counts (absent/"false" do not).
|
|
48
48
|
* - Descendant search tries a fast native querySelectorAll(CANDIDATE_
|
|
49
49
|
* SELECTOR) first (covers the light-DOM-only common case with no added
|
|
50
50
|
* cost); only when that finds nothing AND the container has a <slot>
|
|
@@ -53,15 +53,15 @@
|
|
|
53
53
|
* querySelectorAll only sees a <slot>'s unrendered fallback content, never
|
|
54
54
|
* what's actually distributed into it. Deliberately scoped to slot
|
|
55
55
|
* expansion only, not a general "also descend into any nested custom
|
|
56
|
-
* element's own shadow root" walk — no
|
|
57
|
-
* that yet.
|
|
56
|
+
* element's own shadow root" walk — no known case needs that yet.
|
|
58
57
|
*/
|
|
59
58
|
|
|
60
59
|
const id = 'aria-required-children';
|
|
61
60
|
|
|
62
61
|
const meta = {
|
|
63
62
|
title: 'Container roles must own at least one required child role',
|
|
64
|
-
description:
|
|
63
|
+
description:
|
|
64
|
+
'Checks that container roles with a documented "required owned elements" entry (list, listbox, menu, radiogroup, table, grid, tablist, tree, row, ...) contain at least one descendant or aria-owns-referenced element with an acceptable owned role.',
|
|
65
65
|
i18n: {
|
|
66
66
|
titleKey: 'ariaRequiredChildren_title',
|
|
67
67
|
descriptionKey: 'ariaRequiredChildren_description'
|
|
@@ -70,7 +70,13 @@ const meta = {
|
|
|
70
70
|
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
71
71
|
wcagSc: ['4.1.2'],
|
|
72
72
|
normativeMappings: [
|
|
73
|
-
{
|
|
73
|
+
{
|
|
74
|
+
standard: 'WCAG',
|
|
75
|
+
version: '2.2',
|
|
76
|
+
requirement: '4.1.2',
|
|
77
|
+
title: 'Name, Role, Value',
|
|
78
|
+
conformanceLevel: 'A'
|
|
79
|
+
}
|
|
74
80
|
],
|
|
75
81
|
defaultSeverity: 'moderate',
|
|
76
82
|
category: 'robust',
|
|
@@ -80,7 +86,7 @@ const meta = {
|
|
|
80
86
|
};
|
|
81
87
|
|
|
82
88
|
function runInPage(ctx) {
|
|
83
|
-
const {
|
|
89
|
+
const { helpers, rule } = ctx;
|
|
84
90
|
|
|
85
91
|
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
86
92
|
if (!ariaHelpers) {
|
|
@@ -88,7 +94,8 @@ function runInPage(ctx) {
|
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
function isEligibleAcc(el) {
|
|
91
|
-
const fn =
|
|
97
|
+
const fn =
|
|
98
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
92
99
|
if (!fn) return true;
|
|
93
100
|
try {
|
|
94
101
|
const r = fn(el, ctx);
|
|
@@ -109,9 +116,12 @@ function runInPage(ctx) {
|
|
|
109
116
|
// (kept in sync with aria-helpers.js NATIVE_CONTAINMENT_ROLE_BY_ELEMENT).
|
|
110
117
|
// Declared inside runInPage — see scripts/build-core.js header
|
|
111
118
|
// ("runInPage MUST be self-contained").
|
|
112
|
-
const CANDIDATE_SELECTOR =
|
|
119
|
+
const CANDIDATE_SELECTOR =
|
|
120
|
+
'[role], li, option, tr, td, th, thead, tbody, tfoot, ul, ol, table, select, input[type="radio"]';
|
|
113
121
|
|
|
114
|
-
const nodes = helpers.queryAllSmart
|
|
122
|
+
const nodes = helpers.queryAllSmart
|
|
123
|
+
? helpers.queryAllSmart('[role]')
|
|
124
|
+
: helpers.queryAll('[role]');
|
|
115
125
|
|
|
116
126
|
const occurrences = [];
|
|
117
127
|
let applicableCount = 0;
|
|
@@ -122,25 +132,26 @@ function runInPage(ctx) {
|
|
|
122
132
|
// <slot></slot>, with the actual role="listitem" elements living in the
|
|
123
133
|
// light DOM and projected in) would never find them there — same class
|
|
124
134
|
// of bug as aria-required-parent's ancestor search, just in the opposite
|
|
125
|
-
// (descendant) direction.
|
|
126
|
-
// sp-sidenav-item: its shadow root's role="list" div owns its listitems
|
|
127
|
-
// only through slot projection.
|
|
135
|
+
// (descendant) direction.
|
|
128
136
|
//
|
|
129
137
|
// Deliberately scoped to slot expansion only — does NOT separately
|
|
130
138
|
// descend into an unrelated nested custom element's own shadow root
|
|
131
139
|
// (e.g. a <my-widget> child with no <slot> involvement at all). That's a
|
|
132
140
|
// qualitatively different question (does an arbitrary component's own
|
|
133
141
|
// internal structure count as this container's "owned children"?) with
|
|
134
|
-
// no
|
|
135
|
-
//
|
|
142
|
+
// no known case driving it yet; slot projection is the shape that
|
|
143
|
+
// actually comes up.
|
|
136
144
|
function collectComposedDescendants(node, out, seen, limit) {
|
|
137
145
|
if (!node || !node.children) return;
|
|
138
146
|
for (const child of Array.from(node.children)) {
|
|
139
147
|
if (out.length >= limit) return;
|
|
140
148
|
if (seen.has(child)) continue;
|
|
141
149
|
|
|
142
|
-
if (
|
|
143
|
-
|
|
150
|
+
if (
|
|
151
|
+
(child.tagName || '').toLowerCase() === 'slot' &&
|
|
152
|
+
typeof child.assignedElements === 'function'
|
|
153
|
+
) {
|
|
154
|
+
let assigned;
|
|
144
155
|
try {
|
|
145
156
|
assigned = child.assignedElements({ flatten: true }) || [];
|
|
146
157
|
} catch {
|
|
@@ -184,7 +195,7 @@ function runInPage(ctx) {
|
|
|
184
195
|
// selector, exactly as before this fix — covers the overwhelming
|
|
185
196
|
// majority of containers (no shadow DOM involved at all) with zero
|
|
186
197
|
// added cost.
|
|
187
|
-
let descendants
|
|
198
|
+
let descendants;
|
|
188
199
|
try {
|
|
189
200
|
descendants = el.querySelectorAll(CANDIDATE_SELECTOR);
|
|
190
201
|
} catch {
|
|
@@ -202,7 +213,7 @@ function runInPage(ctx) {
|
|
|
202
213
|
// <slot> somewhere in the subtree to expand — bounds the extra cost to
|
|
203
214
|
// exactly the containers that could possibly need it.
|
|
204
215
|
if (!found) {
|
|
205
|
-
let hasSlot
|
|
216
|
+
let hasSlot;
|
|
206
217
|
try {
|
|
207
218
|
hasSlot = !!el.querySelector('slot');
|
|
208
219
|
} catch {
|
|
@@ -243,7 +254,7 @@ function runInPage(ctx) {
|
|
|
243
254
|
if (found) continue;
|
|
244
255
|
|
|
245
256
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
246
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
257
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
247
258
|
|
|
248
259
|
occurrences.push({
|
|
249
260
|
selector: stableSelector,
|
|
@@ -256,7 +267,11 @@ function runInPage(ctx) {
|
|
|
256
267
|
params: { role, requiredRoles: requiredOwned.join(', ') }
|
|
257
268
|
},
|
|
258
269
|
data: {
|
|
259
|
-
details: {
|
|
270
|
+
details: {
|
|
271
|
+
reasonCode: 'ARIA_REQUIRED_CHILD_MISSING',
|
|
272
|
+
role,
|
|
273
|
+
requiredOwnedRoles: requiredOwned
|
|
274
|
+
}
|
|
260
275
|
}
|
|
261
276
|
});
|
|
262
277
|
}
|
|
@@ -265,9 +280,14 @@ function runInPage(ctx) {
|
|
|
265
280
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
266
281
|
}
|
|
267
282
|
if (occurrences.length) {
|
|
268
|
-
return {
|
|
283
|
+
return {
|
|
284
|
+
ruleId: rule.ruleId,
|
|
285
|
+
outcome: 'fail',
|
|
286
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
287
|
+
occurrences
|
|
288
|
+
};
|
|
269
289
|
}
|
|
270
290
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
271
291
|
}
|
|
272
292
|
|
|
273
|
-
module.exports = { id, meta, runInPage };
|
|
293
|
+
module.exports = { id, meta, runInPage };
|