@surea11y/core 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -131,23 +131,20 @@ function runInPage(ctx) {
|
|
|
131
131
|
|
|
132
132
|
if (!reasons.length) continue;
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
details: { reasonCode: 'VIEWPORT_ZOOM_RESTRICTED', reasons }
|
|
149
|
-
}
|
|
150
|
-
});
|
|
134
|
+
occurrences.push(
|
|
135
|
+
helpers.reportOccurrence(el, {
|
|
136
|
+
summary: 'This viewport meta tag restricts the user’s ability to zoom.',
|
|
137
|
+
hint: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta content.',
|
|
138
|
+
i18n: {
|
|
139
|
+
summaryKey: 'metaViewportZoomEnabled_summary_fail',
|
|
140
|
+
hintKey: 'metaViewportZoomEnabled_hint_fail',
|
|
141
|
+
params: { reasons: reasons.join(', ') }
|
|
142
|
+
},
|
|
143
|
+
data: {
|
|
144
|
+
details: { reasonCode: 'VIEWPORT_ZOOM_RESTRICTED', reasons }
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
);
|
|
151
148
|
}
|
|
152
149
|
|
|
153
150
|
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
|
}
|
|
@@ -133,24 +152,22 @@ function runInPage(ctx) {
|
|
|
133
152
|
}
|
|
134
153
|
})()
|
|
135
154
|
: null;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
});
|
|
155
|
+
|
|
156
|
+
occurrences.push(
|
|
157
|
+
helpers.reportOccurrence(el, {
|
|
158
|
+
summary: 'This meter has no accessible name.',
|
|
159
|
+
hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this meter's accessible name.",
|
|
160
|
+
i18n: {
|
|
161
|
+
summaryKey: 'meterNamePresent_summary_fail',
|
|
162
|
+
hintKey: 'meterNamePresent_hint_fail',
|
|
163
|
+
params: {}
|
|
164
|
+
},
|
|
165
|
+
data: {
|
|
166
|
+
details: { reasonCode: 'name_missing', controlType: 'meter', methodTried: res.method },
|
|
167
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
);
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
if (applicableCount === 0) {
|
|
@@ -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.
|
|
@@ -267,27 +267,25 @@ function runInPage(ctx) {
|
|
|
267
267
|
const dedupedNestedTags = [...new Set(nestedTags)];
|
|
268
268
|
|
|
269
269
|
const tag = el.tagName.toLowerCase();
|
|
270
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
271
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
272
270
|
|
|
273
|
-
occurrences.push(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
271
|
+
occurrences.push(
|
|
272
|
+
helpers.reportOccurrence(el, {
|
|
273
|
+
summary: 'This interactive control contains one or more other interactive controls.',
|
|
274
|
+
hint: 'Move the nested interactive control(s) outside this element; nested interactive controls are not reliably operable via assistive technology.',
|
|
275
|
+
i18n: {
|
|
276
|
+
summaryKey: 'nestedInteractiveControlsAbsent_summary_fail',
|
|
277
|
+
hintKey: 'nestedInteractiveControlsAbsent_hint_fail',
|
|
278
|
+
params: { element: tag, nestedElements: dedupedNestedTags.join(', ') }
|
|
279
|
+
},
|
|
280
|
+
data: {
|
|
281
|
+
details: {
|
|
282
|
+
reasonCode: 'NESTED_INTERACTIVE_CONTROL',
|
|
283
|
+
element: tag,
|
|
284
|
+
nestedElements: nestedTags
|
|
285
|
+
}
|
|
288
286
|
}
|
|
289
|
-
}
|
|
290
|
-
|
|
287
|
+
})
|
|
288
|
+
);
|
|
291
289
|
}
|
|
292
290
|
|
|
293
291
|
if (applicableCount === 0) {
|
|
@@ -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).
|
|
@@ -145,24 +164,22 @@ function runInPage(ctx) {
|
|
|
145
164
|
}
|
|
146
165
|
})()
|
|
147
166
|
: null;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
});
|
|
167
|
+
|
|
168
|
+
occurrences.push(
|
|
169
|
+
helpers.reportOccurrence(el, {
|
|
170
|
+
summary: 'This element has no accessible name.',
|
|
171
|
+
hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
|
|
172
|
+
i18n: {
|
|
173
|
+
summaryKey: 'optionNamePresent_summary_fail',
|
|
174
|
+
hintKey: 'optionNamePresent_hint_fail',
|
|
175
|
+
params: { controlType: 'option' }
|
|
176
|
+
},
|
|
177
|
+
data: {
|
|
178
|
+
details: { reasonCode: 'name_missing', controlType: 'option', methodTried: res.method },
|
|
179
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
);
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
if (applicableCount === 0) {
|
|
@@ -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 };
|