@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
|
@@ -9,20 +9,24 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.2.4, 3.2.5
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to
|
|
13
|
-
* content attribute
|
|
12
|
+
* Applies to the first <meta http-equiv="refresh"> element, in document
|
|
13
|
+
* order, with a valid content attribute, per HTML's shared declarative
|
|
14
|
+
* refresh steps, a document only ever acts on its first valid meta
|
|
15
|
+
* refresh, so a later one (valid or not) is inert and out of scope.
|
|
14
16
|
* @expectation
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* Running the shared declarative refresh steps against that element's
|
|
18
|
+
* content value results in a delay of exactly 0. An immediate (delay=0)
|
|
19
|
+
* redirect still passes at AAA, same as the A-level rule. There is
|
|
20
|
+
* nothing for a user to be interrupted mid-read by when nothing is
|
|
21
|
+
* displayed first. Any positive delay fails, with none of the A-level
|
|
22
|
+
* rule's >20-hour exemption: at AAA, WCAG 2.2.4 (Interruptions) and
|
|
23
|
+
* 3.2.5 (Change on Request) require that a *timed* automatic context
|
|
24
|
+
* change happen only at the user's request, regardless of how long the
|
|
25
|
+
* timer is.
|
|
23
26
|
* @implementation-notes
|
|
24
|
-
* - Distinct, atomic decision from meta-refresh-timing-absent:
|
|
25
|
-
*
|
|
27
|
+
* - Distinct, atomic decision from meta-refresh-timing-absent: that
|
|
28
|
+
* rule's >20-hour exemption for a positive delay is this rule's fail
|
|
29
|
+
* case (no exemption at AAA), but delay=0 passes in both rules.
|
|
26
30
|
* - A <meta> nested inside <noscript> is excluded: it only ever takes
|
|
27
31
|
* effect when scripting is disabled, which is never the case for any
|
|
28
32
|
* context capable of running accessibility tooling in the first place
|
|
@@ -35,7 +39,7 @@ const id = 'meta-refresh-no-exceptions';
|
|
|
35
39
|
const meta = {
|
|
36
40
|
title: 'Page must not use a meta refresh at all (AAA)',
|
|
37
41
|
description:
|
|
38
|
-
'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay
|
|
42
|
+
'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay. This is the stricter AAA-level counterpart of the A-level positive-delay-only check.',
|
|
39
43
|
i18n: {
|
|
40
44
|
titleKey: 'metaRefreshNoExceptions_title',
|
|
41
45
|
descriptionKey: 'metaRefreshNoExceptions_description'
|
|
@@ -122,10 +126,16 @@ function runInPage(ctx) {
|
|
|
122
126
|
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
|
|
123
127
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
124
128
|
if (!raw) continue;
|
|
125
|
-
|
|
129
|
+
const delay = parseRefreshDelay(raw);
|
|
130
|
+
if (delay === null) continue;
|
|
126
131
|
|
|
132
|
+
// Only the first valid meta refresh in document order is ever acted
|
|
133
|
+
// on by a browser -- any later one is inert and stops being evaluated
|
|
134
|
+
// entirely once a winner has been found.
|
|
127
135
|
applicableCount += 1;
|
|
128
136
|
|
|
137
|
+
if (delay === 0) break; // an immediate redirect is not a timed interruption
|
|
138
|
+
|
|
129
139
|
occurrences.push(
|
|
130
140
|
helpers.reportOccurrence(el, {
|
|
131
141
|
summary:
|
|
@@ -141,6 +151,7 @@ function runInPage(ctx) {
|
|
|
141
151
|
}
|
|
142
152
|
})
|
|
143
153
|
);
|
|
154
|
+
break;
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
if (applicableCount === 0) {
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.2.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <meta http-equiv="refresh">
|
|
13
|
-
* content attribute
|
|
12
|
+
* Applies to the first <meta http-equiv="refresh"> element, in document
|
|
13
|
+
* order, whose content attribute has a parseable leading delay value.
|
|
14
|
+
* Per HTML's shared declarative refresh steps, a document only ever
|
|
15
|
+
* acts on its first valid meta refresh; any later one (valid or not)
|
|
16
|
+
* is inert markup a browser never processes, so it is not evaluated.
|
|
14
17
|
* @expectation
|
|
15
18
|
* The delay is 0 (an immediate redirect, which users cannot be caught by
|
|
16
19
|
* mid-read), or exceeds 20 hours. Any other positive delay refreshes or
|
|
@@ -19,13 +22,13 @@
|
|
|
19
22
|
* be possible.
|
|
20
23
|
* @implementation-notes
|
|
21
24
|
* - An unparseable content value (no leading numeric delay) is not
|
|
22
|
-
* flagged
|
|
25
|
+
* flagged, this rule only reports a clearly-detected timed refresh,
|
|
23
26
|
* matching this engine's no-false-positives policy.
|
|
24
27
|
* - WCAG 2.2.1 Exception 3 exempts time limits longer than 20 hours (the
|
|
25
28
|
* rationale being that a delay this long gives users enough real-world
|
|
26
29
|
* time to act, so "adjustable" ceases to be a meaningful requirement).
|
|
27
30
|
* A delay over 72000 seconds is therefore not flagged.
|
|
28
|
-
* - A <meta> nested inside <noscript> is excluded
|
|
31
|
+
* - A <meta> nested inside <noscript> is excluded, see
|
|
29
32
|
* meta-refresh-no-exceptions's header comment for why.
|
|
30
33
|
*/
|
|
31
34
|
|
|
@@ -109,17 +112,20 @@ function runInPage(ctx) {
|
|
|
109
112
|
|
|
110
113
|
for (const el of nodes) {
|
|
111
114
|
if (!el || !el.getAttribute) continue;
|
|
112
|
-
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
|
|
115
|
+
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled, see meta-refresh-no-exceptions.js's header comment
|
|
113
116
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
114
117
|
if (!raw) continue;
|
|
115
118
|
|
|
116
119
|
const delay = parseRefreshDelay(raw);
|
|
117
120
|
if (delay === null) continue;
|
|
118
121
|
|
|
122
|
+
// Only the first valid meta refresh in document order is ever acted
|
|
123
|
+
// on by a browser -- any later one is inert and stops being evaluated
|
|
124
|
+
// entirely once a winner has been found.
|
|
119
125
|
applicableCount += 1;
|
|
120
126
|
|
|
121
|
-
if (!(delay > 0))
|
|
122
|
-
if (delay > EXEMPT_DELAY_SECONDS)
|
|
127
|
+
if (!(delay > 0)) break;
|
|
128
|
+
if (delay > EXEMPT_DELAY_SECONDS) break; // WCAG 2.2.1 Exception 3 (>20 hours)
|
|
123
129
|
|
|
124
130
|
occurrences.push(
|
|
125
131
|
helpers.reportOccurrence(el, {
|
|
@@ -135,6 +141,7 @@ function runInPage(ctx) {
|
|
|
135
141
|
}
|
|
136
142
|
})
|
|
137
143
|
);
|
|
144
|
+
break;
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
if (applicableCount === 0) {
|
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check meter-name-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Elements with role="meter" must have an accessible name
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 1.1.1
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to elements carrying role="meter" (the attribute must name that
|
|
13
|
+
* role alone, not a fallback list) that are included in the accessibility
|
|
14
|
+
* tree. An element with the matching implicit role but no role attribute
|
|
15
|
+
* is out of scope.
|
|
16
|
+
* @expectation
|
|
17
|
+
* The element has a non-empty accessible name from aria-label, from an
|
|
18
|
+
* aria-labelledby that resolves to non-empty text, or from title.
|
|
19
|
+
* role="meter" is name-from-author-only, so subtree text is never
|
|
20
|
+
* accepted: text sitting inside a custom meter widget is not reliably
|
|
21
|
+
* exposed as its name.
|
|
22
|
+
*/
|
|
23
|
+
|
|
5
24
|
const id = 'meter-name-present';
|
|
6
25
|
|
|
7
26
|
const meta = {
|
|
@@ -54,7 +73,7 @@ function runInPage(ctx) {
|
|
|
54
73
|
const raw = getAttr(el, 'aria-labelledby');
|
|
55
74
|
if (!raw) return '';
|
|
56
75
|
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
57
|
-
// name-from-content of the referenced element
|
|
76
|
+
// name-from-content of the referenced element, see dialog-name-
|
|
58
77
|
// present.js's identical fix for the full rationale (an <iframe>
|
|
59
78
|
// aria-labelledby target's only name source is its title attribute,
|
|
60
79
|
// which name-from-content alone can never see).
|
|
@@ -106,8 +125,8 @@ function runInPage(ctx) {
|
|
|
106
125
|
if (title) return { ok: true, method: 'title' };
|
|
107
126
|
|
|
108
127
|
// role="meter" is name-from-author-only per WAI-ARIA: aria-label,
|
|
109
|
-
// aria-labelledby, or title
|
|
110
|
-
// It must NOT fall back to subtree content
|
|
128
|
+
// aria-labelledby, or title, no content-based naming method at all.
|
|
129
|
+
// It must NOT fall back to subtree content, visible text near/inside a
|
|
111
130
|
// custom meter widget is not reliably exposed as its accessible name.
|
|
112
131
|
return { ok: false, method: 'none' };
|
|
113
132
|
}
|
|
@@ -137,7 +156,7 @@ function runInPage(ctx) {
|
|
|
137
156
|
occurrences.push(
|
|
138
157
|
helpers.reportOccurrence(el, {
|
|
139
158
|
summary: 'This meter has no accessible name.',
|
|
140
|
-
hint: "Provide aria-label, aria-labelledby, or a title attribute
|
|
159
|
+
hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this meter's accessible name.",
|
|
141
160
|
i18n: {
|
|
142
161
|
summaryKey: 'meterNamePresent_summary_fail',
|
|
143
162
|
hintKey: 'meterNamePresent_hint_fail',
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
* ARIA widget role: button, link, checkbox, radio, switch, tab, textbox,
|
|
15
15
|
* combobox, listbox, menuitem, menuitemcheckbox, menuitemradio, option,
|
|
16
16
|
* slider, spinbutton, searchbox, treeitem). The container is applicable
|
|
17
|
-
* regardless of whether it is itself focusable
|
|
17
|
+
* regardless of whether it is itself focusable, focusability is only
|
|
18
18
|
* used to decide whether a *descendant* nests an interactive control.
|
|
19
19
|
* @expectation
|
|
20
20
|
* The element does not contain, as a descendant, another *operable*
|
|
21
21
|
* interactive control (e.g. a <button> wrapping a <select>, or a link
|
|
22
22
|
* containing a checkbox). Nested interactive controls are not reliably
|
|
23
|
-
* announced or operable via assistive technology
|
|
23
|
+
* announced or operable via assistive technology, activating the outer
|
|
24
24
|
* control and the inner one become ambiguous, and some AT only exposes
|
|
25
25
|
* one of the two.
|
|
26
26
|
* @implementation-notes
|
|
@@ -89,7 +89,7 @@ const meta = {
|
|
|
89
89
|
function runInPage(ctx) {
|
|
90
90
|
const { helpers, rule } = ctx;
|
|
91
91
|
|
|
92
|
-
// Declared inside runInPage
|
|
92
|
+
// Declared inside runInPage, see scripts/build-core.js header
|
|
93
93
|
// ("runInPage MUST be self-contained").
|
|
94
94
|
const INTERACTIVE_SELECTOR = [
|
|
95
95
|
'a[href]',
|
|
@@ -228,8 +228,8 @@ function runInPage(ctx) {
|
|
|
228
228
|
// A composite-owned child (option in a listbox/combobox, tab in a
|
|
229
229
|
// tablist, ...) is not a nested interactive control: its container
|
|
230
230
|
// owns it and drives its focus (roving tabindex or
|
|
231
|
-
// aria-activedescendant). Treat it as an attribution boundary
|
|
232
|
-
// count it, and do not descend past it. Any control
|
|
231
|
+
// aria-activedescendant). Treat it as an attribution boundary, do not
|
|
232
|
+
// count it, and do not descend past it. Any control nested
|
|
233
233
|
// inside it is attributed to the child itself (examined as its own
|
|
234
234
|
// container in the main loop), keeping the report at the nearest
|
|
235
235
|
// interactive ancestor rather than bubbling up to the composite.
|
|
@@ -2,11 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check option-name-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Elements with role="option" must have an accessible name
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to elements carrying role="option" (the attribute must name that
|
|
13
|
+
* role alone, not a fallback list) that are included in the accessibility
|
|
14
|
+
* tree. An element with the matching implicit role but no role attribute
|
|
15
|
+
* is out of scope.
|
|
16
|
+
* @expectation
|
|
17
|
+
* The element has a non-empty accessible name from aria-label, from an
|
|
18
|
+
* aria-labelledby that resolves to non-empty text, from title, or,
|
|
19
|
+
* role="option" being name-from-content, from its own subtree text, where
|
|
20
|
+
* a descendant's own name (an <img alt>, aria-label or title) counts as
|
|
21
|
+
* that descendant's contribution rather than only its text nodes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
5
24
|
const id = 'option-name-present';
|
|
6
25
|
|
|
7
26
|
const meta = {
|
|
8
|
-
title: '
|
|
9
|
-
description: 'Checks that elements expose a non-empty accessible name.',
|
|
27
|
+
title: 'Options have an accessible name',
|
|
28
|
+
description: 'Checks that elements with role="option" expose a non-empty accessible name.',
|
|
10
29
|
i18n: {
|
|
11
30
|
titleKey: 'optionNamePresent_title',
|
|
12
31
|
descriptionKey: 'optionNamePresent_description'
|
|
@@ -51,7 +70,7 @@ function runInPage(ctx) {
|
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
function getConservativeSubtreeText(document, container) {
|
|
54
|
-
// "Name from content"
|
|
73
|
+
// "Name from content", recurses into descendants and uses each one's
|
|
55
74
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
56
75
|
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
57
76
|
// header comment in src/core/dom-helpers.js for the full rationale
|
|
@@ -70,7 +89,7 @@ function runInPage(ctx) {
|
|
|
70
89
|
const raw = getAttr(el, 'aria-labelledby');
|
|
71
90
|
if (!raw) return '';
|
|
72
91
|
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
73
|
-
// name-from-content of the referenced element
|
|
92
|
+
// name-from-content of the referenced element, see dialog-name-
|
|
74
93
|
// present.js's identical fix for the full rationale (an <iframe>
|
|
75
94
|
// aria-labelledby target's only name source is its title attribute,
|
|
76
95
|
// which name-from-content alone can never see).
|
|
@@ -2,11 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check page-title-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary The document must have a non-empty title
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 2.4.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to a run over a whole document. A run narrowed by
|
|
13
|
+
* contextSelector, or by engineOptions.fragment, is notApplicable: whether
|
|
14
|
+
* the page has a title is not a property any subtree can answer.
|
|
15
|
+
* @expectation
|
|
16
|
+
* The document has a <title> element, and document.title with whitespace
|
|
17
|
+
* collapsed is non-empty. The element is looked for anywhere in the
|
|
18
|
+
* document, not only inside <head>: a <title> the parser leaves outside
|
|
19
|
+
* <head> is still the document title in every browser. Whether that title
|
|
20
|
+
* describes the page is page-title-patterns' question.
|
|
21
|
+
*/
|
|
22
|
+
|
|
5
23
|
const id = 'page-title-present';
|
|
6
24
|
|
|
7
25
|
const meta = {
|
|
8
|
-
title: 'Page
|
|
9
|
-
description: 'Checks that the
|
|
26
|
+
title: 'Page has a non-empty title',
|
|
27
|
+
description: 'Checks that the page includes a non-empty <title> element.',
|
|
10
28
|
i18n: {
|
|
11
29
|
titleKey: 'pageTitlePresent_title',
|
|
12
30
|
descriptionKey: 'pageTitlePresent_description'
|
|
@@ -47,7 +65,7 @@ function runInPage(ctx) {
|
|
|
47
65
|
// Not restricted to `head > title`: per HTML parsing, a <title> encountered
|
|
48
66
|
// after <head> has closed is not re-parented into <head>, yet the browser
|
|
49
67
|
// (and document.title, used below) still recognizes it as the document's
|
|
50
|
-
// title
|
|
68
|
+
// title, matching that here avoids a false "missing title" report for a
|
|
51
69
|
// <title> that legitimately ended up outside <head>.
|
|
52
70
|
const titleEl = document.querySelector('title');
|
|
53
71
|
const titleText = (document.title || '').replace(/\s+/g, ' ').trim();
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check presentational-children-focusable-absent
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary A role whose children are presentational must not contain content in sequential focus navigation
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to elements whose semantic role is one of the WAI-ARIA roles
|
|
13
|
+
* defined as having presentational children (button, checkbox, img,
|
|
14
|
+
* menuitemcheckbox, menuitemradio, meter, option, progressbar, radio,
|
|
15
|
+
* scrollbar, separator, slider, switch, tab, plus doc-pagebreak and
|
|
16
|
+
* graphics-symbol from the DPUB-ARIA/Graphics-ARIA modules, which
|
|
17
|
+
* inherit the same trait). The role can be explicit (role="tab") or
|
|
18
|
+
* native (<button>, <meter>, <progress>, <option>).
|
|
19
|
+
* @expectation
|
|
20
|
+
* No descendant of the element is part of sequential focus navigation.
|
|
21
|
+
* The presentational-children mechanism removes every descendant from
|
|
22
|
+
* the accessibility tree, so a descendant that still takes a tab stop
|
|
23
|
+
* receives focus with no role and no name to announce.
|
|
24
|
+
* @implementation-notes
|
|
25
|
+
* - "Presentational children" is the implicit, role-driven mechanism from
|
|
26
|
+
* WAI-ARIA §5.2.7, NOT an explicit role="presentation"/"none" attribute.
|
|
27
|
+
* An element carrying that attribute has a semantic role of
|
|
28
|
+
* presentation/none, which is not in the list above, so it is out of
|
|
29
|
+
* scope here (presentation-role-conflict covers that case).
|
|
30
|
+
* - Only tab stops count, not everything focusable: a descendant with
|
|
31
|
+
* tabindex="-1" is programmatically focusable but is not part of
|
|
32
|
+
* sequential focus navigation, so it takes no tab stop and is not
|
|
33
|
+
* reported. helpers.getFocusableInfo's `tabbable` flag is exactly this
|
|
34
|
+
* distinction, and also accounts for :disabled, inert and invalid
|
|
35
|
+
* tabindex values.
|
|
36
|
+
* - Native tags are mapped only where the element can actually have
|
|
37
|
+
* descendants: <input type="checkbox">, <img> and <hr> also carry a
|
|
38
|
+
* presentational-children role, but they are void, so no descendant can
|
|
39
|
+
* ever exist to report. Including them would only inflate the applicable
|
|
40
|
+
* count.
|
|
41
|
+
* - The walk stops at each reported tab stop, and at a nested element that
|
|
42
|
+
* has a presentational-children role of its own without being a tab stop,
|
|
43
|
+
* that one owns whatever is inside it, and is examined as its own
|
|
44
|
+
* container. A tab stop therefore lands on the nearest role that dropped
|
|
45
|
+
* it from the accessibility tree, the element an author would fix, rather
|
|
46
|
+
* than on every enclosing one. A nested role that IS a tab stop
|
|
47
|
+
* (role="button" tabindex="0" inside a <button>) is reported here: focus
|
|
48
|
+
* lands inside this element.
|
|
49
|
+
* - Anything inside an aria-hidden="true" subtree is left to
|
|
50
|
+
* aria-hidden-focus, which reports that exact defect (a tab stop with no
|
|
51
|
+
* accessibility-tree node) as its own normative decision. Non-rendered
|
|
52
|
+
* descendants (display:none, hidden, visibility:hidden) take no tab stop
|
|
53
|
+
* at all and are skipped.
|
|
54
|
+
* - Browsers implement presentational children inconsistently, some
|
|
55
|
+
* expose the descendants anyway, especially when they are focusable, so
|
|
56
|
+
* the announced result varies by browser. The markup contradiction is
|
|
57
|
+
* the same in every one of them, which is what this rule reports.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
const id = 'presentational-children-focusable-absent';
|
|
61
|
+
|
|
62
|
+
const meta = {
|
|
63
|
+
title: 'Roles with presentational children must not contain focusable content',
|
|
64
|
+
description:
|
|
65
|
+
'Checks that an element whose role makes its children presentational (button, checkbox, img, option, radio, slider, switch, tab, ...) contains no descendant that takes a tab stop.',
|
|
66
|
+
i18n: {
|
|
67
|
+
titleKey: 'presentationalChildrenFocusableAbsent_title',
|
|
68
|
+
descriptionKey: 'presentationalChildrenFocusableAbsent_description'
|
|
69
|
+
},
|
|
70
|
+
helpUrl: null,
|
|
71
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'focus', 'atomic', 'automatic'],
|
|
72
|
+
wcagSc: ['4.1.2'],
|
|
73
|
+
normativeMappings: [
|
|
74
|
+
{
|
|
75
|
+
standard: 'WCAG',
|
|
76
|
+
version: '2.2',
|
|
77
|
+
requirement: '4.1.2',
|
|
78
|
+
title: 'Name, Role, Value',
|
|
79
|
+
conformanceLevel: 'A'
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
defaultSeverity: 'serious',
|
|
83
|
+
category: 'robust',
|
|
84
|
+
type: 'automatic',
|
|
85
|
+
defaultConfidence: 'high',
|
|
86
|
+
coverage: { facetsBySc: { '4.1.2': ['presentational-children-focusable-absent'] } }
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function runInPage(ctx) {
|
|
90
|
+
const { helpers, rule } = ctx;
|
|
91
|
+
|
|
92
|
+
// Declared inside runInPage, see scripts/build-core.js header
|
|
93
|
+
// ("runInPage MUST be self-contained").
|
|
94
|
+
//
|
|
95
|
+
// WAI-ARIA roles with "Children Presentational: True", plus the two
|
|
96
|
+
// module roles that inherit the trait (doc-pagebreak from DPUB-ARIA,
|
|
97
|
+
// graphics-symbol from Graphics-ARIA).
|
|
98
|
+
const PRESENTATIONAL_CHILDREN_ROLES = [
|
|
99
|
+
'button',
|
|
100
|
+
'checkbox',
|
|
101
|
+
'doc-pagebreak',
|
|
102
|
+
'graphics-symbol',
|
|
103
|
+
'img',
|
|
104
|
+
'menuitemcheckbox',
|
|
105
|
+
'menuitemradio',
|
|
106
|
+
'meter',
|
|
107
|
+
'option',
|
|
108
|
+
'progressbar',
|
|
109
|
+
'radio',
|
|
110
|
+
'scrollbar',
|
|
111
|
+
'separator',
|
|
112
|
+
'slider',
|
|
113
|
+
'switch',
|
|
114
|
+
'tab'
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// Native tags whose implicit role is in the set above and that can hold
|
|
118
|
+
// descendants, see the void-element note in the header comment.
|
|
119
|
+
const NATIVE_ROLE_BY_TAG = {
|
|
120
|
+
button: 'button',
|
|
121
|
+
meter: 'meter',
|
|
122
|
+
option: 'option',
|
|
123
|
+
progress: 'progressbar'
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
127
|
+
|
|
128
|
+
const getFocusableInfo =
|
|
129
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
130
|
+
|
|
131
|
+
const isDomVisibleEligible =
|
|
132
|
+
helpers && typeof helpers.isDomVisibleEligible === 'function'
|
|
133
|
+
? helpers.isDomVisibleEligible
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
const getEligibilityInfo =
|
|
137
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
138
|
+
|
|
139
|
+
const isAccTreeEligible =
|
|
140
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
141
|
+
|
|
142
|
+
const roleSet = new Set(PRESENTATIONAL_CHILDREN_ROLES);
|
|
143
|
+
|
|
144
|
+
function lower(v) {
|
|
145
|
+
return (v == null ? '' : String(v)).trim().toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The role attribute holds a fallback list; the first token that names a
|
|
149
|
+
// real role wins. A role="tab" resolves here, a role="figure tab" does
|
|
150
|
+
// not (figure wins and has no presentational children), and a list of
|
|
151
|
+
// nothing but unknown tokens falls back to the native role.
|
|
152
|
+
function getPresentationalChildrenRole(el) {
|
|
153
|
+
const raw = el.getAttribute ? el.getAttribute('role') : null;
|
|
154
|
+
if (raw) {
|
|
155
|
+
const tokens = lower(raw).split(/\s+/);
|
|
156
|
+
for (const token of tokens) {
|
|
157
|
+
if (!token) continue;
|
|
158
|
+
if (roleSet.has(token)) return token;
|
|
159
|
+
const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
|
|
160
|
+
if (known) return '';
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const tag = lower(el.tagName);
|
|
164
|
+
return Object.prototype.hasOwnProperty.call(NATIVE_ROLE_BY_TAG, tag)
|
|
165
|
+
? NATIVE_ROLE_BY_TAG[tag]
|
|
166
|
+
: '';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isExposed(node) {
|
|
170
|
+
if (!isAccTreeEligible) return true;
|
|
171
|
+
try {
|
|
172
|
+
const r = isAccTreeEligible(node, ctx);
|
|
173
|
+
if (typeof r === 'boolean') return r;
|
|
174
|
+
return !!(r && r.eligible);
|
|
175
|
+
} catch {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Flat-tree ancestor walk, shared with every other rule via
|
|
181
|
+
// ctx.helpers.composedParent so a shadow host is crossed the same way here
|
|
182
|
+
// as elsewhere.
|
|
183
|
+
const composedParent =
|
|
184
|
+
helpers && typeof helpers.composedParent === 'function'
|
|
185
|
+
? helpers.composedParent
|
|
186
|
+
: function (n) {
|
|
187
|
+
return n && n.parentElement ? n.parentElement : null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// isAccTreeEligible keeps an aria-hidden element that holds
|
|
191
|
+
// tabbable content in the accessibility tree (reason
|
|
192
|
+
// "ariaHiddenOverriddenTabbable", modelling the browsers that expose it
|
|
193
|
+
// anyway), which is precisely the shape aria-hidden-focus owns, so the
|
|
194
|
+
// attribute is checked on its own here rather than read off eligibility.
|
|
195
|
+
function inAriaHiddenSubtree(node) {
|
|
196
|
+
let cur = node;
|
|
197
|
+
let guard = 0;
|
|
198
|
+
while (cur && guard++ < 200) {
|
|
199
|
+
if (lower(cur.getAttribute && cur.getAttribute('aria-hidden')) === 'true') return true;
|
|
200
|
+
cur = composedParent(cur);
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isRendered(node) {
|
|
206
|
+
if (!isDomVisibleEligible) return true;
|
|
207
|
+
try {
|
|
208
|
+
const vis = isDomVisibleEligible(node, ctx, {
|
|
209
|
+
visibilityMode: 'styleOnly',
|
|
210
|
+
disableGeometry: true
|
|
211
|
+
});
|
|
212
|
+
return !(vis && vis.eligible === false);
|
|
213
|
+
} catch {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isTabStop(node) {
|
|
219
|
+
if (!getFocusableInfo) return false;
|
|
220
|
+
try {
|
|
221
|
+
const info = getFocusableInfo(node, ctx);
|
|
222
|
+
return !!(info && info.tabbable);
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Shallowest tab stops inside `root`, stopping at each one and at any
|
|
229
|
+
// nested presentational-children role, so every tab stop is attributed to
|
|
230
|
+
// the nearest role that removes it from the accessibility tree.
|
|
231
|
+
function collectTabStops(root) {
|
|
232
|
+
const out = [];
|
|
233
|
+
const top = root && root.children;
|
|
234
|
+
if (!top || !top.length) return out;
|
|
235
|
+
const stack = [];
|
|
236
|
+
for (let i = top.length - 1; i >= 0; i--) stack.push(top[i]);
|
|
237
|
+
while (stack.length) {
|
|
238
|
+
const node = stack.pop();
|
|
239
|
+
if (!node || node.nodeType !== 1) continue;
|
|
240
|
+
if (lower(node.getAttribute && node.getAttribute('aria-hidden')) === 'true') continue;
|
|
241
|
+
if (!isRendered(node)) continue;
|
|
242
|
+
if (isTabStop(node)) {
|
|
243
|
+
out.push(node);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
// A nested role with presentational children owns whatever tab stops
|
|
247
|
+
// are inside it (it is examined as its own container in the main
|
|
248
|
+
// loop). It is only a boundary when it is not itself a tab stop,
|
|
249
|
+
// a focusable one lands focus inside THIS element and belongs here.
|
|
250
|
+
if (getPresentationalChildrenRole(node)) continue;
|
|
251
|
+
const kids = node.children;
|
|
252
|
+
if (kids && kids.length) {
|
|
253
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const SELECTOR = '[role], button, meter, option, progress';
|
|
260
|
+
const nodes = helpers.queryAllSmart
|
|
261
|
+
? helpers.queryAllSmart(SELECTOR)
|
|
262
|
+
: helpers.queryAll(SELECTOR);
|
|
263
|
+
|
|
264
|
+
const occurrences = [];
|
|
265
|
+
let applicableCount = 0;
|
|
266
|
+
|
|
267
|
+
for (const el of nodes) {
|
|
268
|
+
if (!el || el.nodeType !== 1) continue;
|
|
269
|
+
|
|
270
|
+
const role = getPresentationalChildrenRole(el);
|
|
271
|
+
if (!role) continue;
|
|
272
|
+
// A container that is hidden, or sits in an aria-hidden subtree, is
|
|
273
|
+
// aria-hidden-focus's concern, see the note on inAriaHiddenSubtree.
|
|
274
|
+
if (!isExposed(el) || inAriaHiddenSubtree(el)) continue;
|
|
275
|
+
|
|
276
|
+
applicableCount += 1;
|
|
277
|
+
|
|
278
|
+
const tabStops = collectTabStops(el);
|
|
279
|
+
if (!tabStops.length) continue;
|
|
280
|
+
|
|
281
|
+
const tabStopTags = tabStops.map((n) => (n && n.tagName ? lower(n.tagName) : 'unknown'));
|
|
282
|
+
const dedupedTabStopTags = [...new Set(tabStopTags)];
|
|
283
|
+
|
|
284
|
+
const eligInfo = getEligibilityInfo
|
|
285
|
+
? (() => {
|
|
286
|
+
try {
|
|
287
|
+
return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
})()
|
|
292
|
+
: null;
|
|
293
|
+
|
|
294
|
+
occurrences.push(
|
|
295
|
+
helpers.reportOccurrence(el, {
|
|
296
|
+
summary: `This role="${role}" element makes its children presentational, but it contains content that is still part of sequential focus navigation (${dedupedTabStopTags.join(', ')}).`,
|
|
297
|
+
hint: 'Move the focusable content outside this element, or remove the role that makes the children presentational, since focus landing inside it has no role or name to announce.',
|
|
298
|
+
i18n: {
|
|
299
|
+
summaryKey: 'presentationalChildrenFocusableAbsent_summary_fail',
|
|
300
|
+
hintKey: 'presentationalChildrenFocusableAbsent_hint_fail',
|
|
301
|
+
params: { role, focusableElements: dedupedTabStopTags.join(', ') }
|
|
302
|
+
},
|
|
303
|
+
data: {
|
|
304
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
305
|
+
details: {
|
|
306
|
+
reasonCode: 'PRESENTATIONAL_CHILDREN_FOCUSABLE_CONTENT',
|
|
307
|
+
role,
|
|
308
|
+
element: lower(el.tagName),
|
|
309
|
+
focusableElements: tabStopTags
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (applicableCount === 0) {
|
|
317
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
318
|
+
}
|
|
319
|
+
if (occurrences.length) {
|
|
320
|
+
return {
|
|
321
|
+
ruleId: rule.ruleId,
|
|
322
|
+
outcome: 'fail',
|
|
323
|
+
severity: rule.defaultSeverity || 'serious',
|
|
324
|
+
occurrences
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = { id, meta, runInPage };
|