@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,17 +1,19 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-contentinfo-is-top-level
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The contentinfo landmark must not be nested inside another landmark
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
|
-
* Applies whenever the page contains at least one contentinfo
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Applies whenever the page contains at least one contentinfo
|
|
12
|
+
* candidate: explicit role="contentinfo", OR a <footer> with NO role
|
|
13
|
+
* attribute at all, regardless of nesting (see implementation notes on
|
|
14
|
+
* why candidate selection is deliberately unconditional).
|
|
13
15
|
* @expectation
|
|
14
|
-
* No contentinfo
|
|
16
|
+
* No contentinfo candidate has an ancestor that is itself any landmark
|
|
15
17
|
* region. A contentinfo nested inside another landmark is not a
|
|
16
18
|
* top-level, whole-page footer region and confuses landmark-based
|
|
17
19
|
* navigation for assistive technology users.
|
|
@@ -20,13 +22,20 @@
|
|
|
20
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
23
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
22
24
|
* its structure with contentinfo/footer in place of banner/header).
|
|
25
|
+
* - Candidate selection (`isContentinfoCandidate` below) is deliberately
|
|
26
|
+
* unconditional, instead of reusing the sectioning-ancestor suppression
|
|
27
|
+
* that the violation check itself depends on — same
|
|
28
|
+
* self-defeating-candidate-selection reasoning as
|
|
29
|
+
* landmark-banner-is-top-level (see that file's header comment for the
|
|
30
|
+
* full root cause).
|
|
23
31
|
*/
|
|
24
32
|
|
|
25
33
|
const id = 'landmark-contentinfo-is-top-level';
|
|
26
34
|
|
|
27
35
|
const meta = {
|
|
28
36
|
title: 'Contentinfo landmark must be top-level',
|
|
29
|
-
description:
|
|
37
|
+
description:
|
|
38
|
+
'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
|
|
30
39
|
i18n: {
|
|
31
40
|
titleKey: 'landmarkContentinfoIsTopLevel_title',
|
|
32
41
|
descriptionKey: 'landmarkContentinfoIsTopLevel_description'
|
|
@@ -46,14 +55,15 @@ function runInPage(ctx) {
|
|
|
46
55
|
const { document, root, helpers, rule } = ctx;
|
|
47
56
|
|
|
48
57
|
function normalizeWs(s) {
|
|
49
|
-
return String(s || '')
|
|
58
|
+
return String(s || '')
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim();
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
53
64
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
54
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
55
|
-
//
|
|
56
|
-
// copy of this logic.
|
|
65
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
66
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
57
67
|
function getAccessibleLandmarkName(el) {
|
|
58
68
|
try {
|
|
59
69
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -76,10 +86,8 @@ function runInPage(ctx) {
|
|
|
76
86
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
77
87
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
78
88
|
// rather than a local tag-only copy. See that function's header comment
|
|
79
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
80
|
-
//
|
|
81
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
82
|
-
// rule's own former tag-only copy as a false negative.
|
|
89
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
90
|
+
// <aside role="dialog"> containing its own <header>.
|
|
83
91
|
function hasSectioningAncestor(el, includeMain) {
|
|
84
92
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
85
93
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -93,11 +101,9 @@ function runInPage(ctx) {
|
|
|
93
101
|
if (tag === 'main') return 'main';
|
|
94
102
|
if (tag === 'nav') return 'navigation';
|
|
95
103
|
if (tag === 'aside') {
|
|
96
|
-
// A named <aside> is never suppressed, even when nested —
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// "complementary" when the element has an accessible name, even
|
|
100
|
-
// inside sectioning content); propagated here for consistency.
|
|
104
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
105
|
+
// "complementary" when it has an accessible name, even inside
|
|
106
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
101
107
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
102
108
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
103
109
|
}
|
|
@@ -106,7 +112,16 @@ function runInPage(ctx) {
|
|
|
106
112
|
return '';
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
const LANDMARK_ROLES = new Set([
|
|
115
|
+
const LANDMARK_ROLES = new Set([
|
|
116
|
+
'banner',
|
|
117
|
+
'contentinfo',
|
|
118
|
+
'main',
|
|
119
|
+
'navigation',
|
|
120
|
+
'complementary',
|
|
121
|
+
'region',
|
|
122
|
+
'form',
|
|
123
|
+
'search'
|
|
124
|
+
]);
|
|
110
125
|
|
|
111
126
|
function getLandmarkRole(el) {
|
|
112
127
|
if (!el || !el.getAttribute) return '';
|
|
@@ -115,8 +130,20 @@ function runInPage(ctx) {
|
|
|
115
130
|
return getImplicitLandmarkRole(el);
|
|
116
131
|
}
|
|
117
132
|
|
|
133
|
+
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
134
|
+
// === 'contentinfo' — see the header comment above. A <footer> is
|
|
135
|
+
// a candidate purely by tag + absence of any role attribute, independent
|
|
136
|
+
// of whether sectioning-ancestor nesting would currently suppress its
|
|
137
|
+
// implicit role; an explicit role="contentinfo" is always a candidate too.
|
|
138
|
+
function isContentinfoCandidate(el) {
|
|
139
|
+
if (!el || !el.getAttribute) return false;
|
|
140
|
+
const explicit = getExplicitRoleToken(el);
|
|
141
|
+
if (explicit) return explicit === 'contentinfo';
|
|
142
|
+
return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
function hasLandmarkAncestor(el) {
|
|
119
|
-
const scopeRoots = Array.isArray(root) ? root :
|
|
146
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
120
147
|
let p = el.parentElement;
|
|
121
148
|
while (p) {
|
|
122
149
|
if (getLandmarkRole(p)) return true;
|
|
@@ -129,14 +156,14 @@ function runInPage(ctx) {
|
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
132
|
-
// landmark-unique-manual.js's header comment
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
let nodes = [];
|
|
159
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
160
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
161
|
+
let nodes;
|
|
136
162
|
try {
|
|
137
|
-
nodes =
|
|
138
|
-
|
|
139
|
-
|
|
163
|
+
nodes =
|
|
164
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
165
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
166
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
140
167
|
} catch {
|
|
141
168
|
nodes = [];
|
|
142
169
|
}
|
|
@@ -146,7 +173,28 @@ function runInPage(ctx) {
|
|
|
146
173
|
for (const el of nodes) {
|
|
147
174
|
if (!el || seen.has(el)) continue;
|
|
148
175
|
seen.add(el);
|
|
149
|
-
if (
|
|
176
|
+
if (!isContentinfoCandidate(el)) continue;
|
|
177
|
+
|
|
178
|
+
// An aria-hidden contentinfo candidate is removed from the
|
|
179
|
+
// accessibility tree entirely -- it isn't part of the landmark
|
|
180
|
+
// structure assistive technology users navigate at all, so it
|
|
181
|
+
// shouldn't be flagged as "nested inside another landmark" (there's
|
|
182
|
+
// no real landmark there to begin with, from AT's perspective).
|
|
183
|
+
// queryAllSmart's default hidden-content policy only excludes "hard"
|
|
184
|
+
// CSS-based hiding (display:none, etc.), not the softer aria-hidden
|
|
185
|
+
// exclusion, so this needs its own check.
|
|
186
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
187
|
+
const elig = (() => {
|
|
188
|
+
try {
|
|
189
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
190
|
+
} catch {
|
|
191
|
+
return { eligible: true, reasons: [] };
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
if (elig && elig.eligible === false) continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
contentinfos.push(el);
|
|
150
198
|
}
|
|
151
199
|
|
|
152
200
|
if (contentinfos.length === 0) {
|
|
@@ -158,7 +206,7 @@ function runInPage(ctx) {
|
|
|
158
206
|
if (!hasLandmarkAncestor(el)) continue;
|
|
159
207
|
|
|
160
208
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
161
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
209
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
162
210
|
|
|
163
211
|
occurrences.push({
|
|
164
212
|
selector: stableSelector,
|
|
@@ -177,7 +225,12 @@ function runInPage(ctx) {
|
|
|
177
225
|
}
|
|
178
226
|
|
|
179
227
|
if (occurrences.length) {
|
|
180
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
ruleId: rule.ruleId,
|
|
230
|
+
outcome: 'cantTell',
|
|
231
|
+
severity: rule.defaultSeverity || 'minor',
|
|
232
|
+
occurrences
|
|
233
|
+
};
|
|
181
234
|
}
|
|
182
235
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
183
236
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-main-is-top-level
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The main landmark must not be nested inside another landmark
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies whenever the page contains at least one main landmark
|
|
10
12
|
* (explicit role="main", or an implicit <main> element).
|
|
@@ -18,13 +20,21 @@
|
|
|
18
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
21
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
20
22
|
* its structure with main in place of banner/header).
|
|
23
|
+
* - Unlike landmark-banner-is-top-level/landmark-contentinfo-is-top-level
|
|
24
|
+
* (see that file's header comment), candidate selection here doesn't
|
|
25
|
+
* need to be unconditional: `<main>`'s implicit role is unconditional
|
|
26
|
+
* per HTML-AAM — unlike `<header>`/`<footer>`, nesting never suppresses
|
|
27
|
+
* it — so `getImplicitLandmarkRole`'s `main` branch is never subject to
|
|
28
|
+
* the self-defeating candidate-selection problem those two rules guard
|
|
29
|
+
* against.
|
|
21
30
|
*/
|
|
22
31
|
|
|
23
32
|
const id = 'landmark-main-is-top-level';
|
|
24
33
|
|
|
25
34
|
const meta = {
|
|
26
35
|
title: 'Main landmark must be top-level',
|
|
27
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
|
|
28
38
|
i18n: {
|
|
29
39
|
titleKey: 'landmarkMainIsTopLevel_title',
|
|
30
40
|
descriptionKey: 'landmarkMainIsTopLevel_description'
|
|
@@ -44,14 +54,15 @@ function runInPage(ctx) {
|
|
|
44
54
|
const { document, root, helpers, rule } = ctx;
|
|
45
55
|
|
|
46
56
|
function normalizeWs(s) {
|
|
47
|
-
return String(s || '')
|
|
57
|
+
return String(s || '')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
51
63
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
52
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
53
|
-
//
|
|
54
|
-
// copy of this logic.
|
|
64
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
65
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
55
66
|
function getAccessibleLandmarkName(el) {
|
|
56
67
|
try {
|
|
57
68
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -74,10 +85,8 @@ function runInPage(ctx) {
|
|
|
74
85
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
75
86
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
76
87
|
// rather than a local tag-only copy. See that function's header comment
|
|
77
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
78
|
-
//
|
|
79
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
80
|
-
// rule's own former tag-only copy as a false negative.
|
|
88
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
89
|
+
// <aside role="dialog"> containing its own <header>.
|
|
81
90
|
function hasSectioningAncestor(el, includeMain) {
|
|
82
91
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
83
92
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -91,11 +100,9 @@ function runInPage(ctx) {
|
|
|
91
100
|
if (tag === 'main') return 'main';
|
|
92
101
|
if (tag === 'nav') return 'navigation';
|
|
93
102
|
if (tag === 'aside') {
|
|
94
|
-
// A named <aside> is never suppressed, even when nested —
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// "complementary" when the element has an accessible name, even
|
|
98
|
-
// inside sectioning content); propagated here for consistency.
|
|
103
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
104
|
+
// "complementary" when it has an accessible name, even inside
|
|
105
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
99
106
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
100
107
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
101
108
|
}
|
|
@@ -104,7 +111,16 @@ function runInPage(ctx) {
|
|
|
104
111
|
return '';
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
const LANDMARK_ROLES = new Set([
|
|
114
|
+
const LANDMARK_ROLES = new Set([
|
|
115
|
+
'banner',
|
|
116
|
+
'contentinfo',
|
|
117
|
+
'main',
|
|
118
|
+
'navigation',
|
|
119
|
+
'complementary',
|
|
120
|
+
'region',
|
|
121
|
+
'form',
|
|
122
|
+
'search'
|
|
123
|
+
]);
|
|
108
124
|
|
|
109
125
|
function getLandmarkRole(el) {
|
|
110
126
|
if (!el || !el.getAttribute) return '';
|
|
@@ -114,7 +130,7 @@ function runInPage(ctx) {
|
|
|
114
130
|
}
|
|
115
131
|
|
|
116
132
|
function hasLandmarkAncestor(el) {
|
|
117
|
-
const scopeRoots = Array.isArray(root) ? root :
|
|
133
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
118
134
|
let p = el.parentElement;
|
|
119
135
|
while (p) {
|
|
120
136
|
if (getLandmarkRole(p)) return true;
|
|
@@ -127,14 +143,14 @@ function runInPage(ctx) {
|
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
130
|
-
// landmark-unique-manual.js's header comment
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
let nodes = [];
|
|
146
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
147
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
148
|
+
let nodes;
|
|
134
149
|
try {
|
|
135
|
-
nodes =
|
|
136
|
-
|
|
137
|
-
|
|
150
|
+
nodes =
|
|
151
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
152
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
153
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
138
154
|
} catch {
|
|
139
155
|
nodes = [];
|
|
140
156
|
}
|
|
@@ -144,7 +160,28 @@ function runInPage(ctx) {
|
|
|
144
160
|
for (const el of nodes) {
|
|
145
161
|
if (!el || seen.has(el)) continue;
|
|
146
162
|
seen.add(el);
|
|
147
|
-
if (getLandmarkRole(el)
|
|
163
|
+
if (getLandmarkRole(el) !== 'main') continue;
|
|
164
|
+
|
|
165
|
+
// An aria-hidden main candidate is removed from the accessibility
|
|
166
|
+
// tree entirely -- it isn't part of the landmark structure assistive
|
|
167
|
+
// technology users navigate at all, so it shouldn't be flagged as
|
|
168
|
+
// "nested inside another landmark" (there's no real landmark there to
|
|
169
|
+
// begin with, from AT's perspective). queryAllSmart's default hidden-
|
|
170
|
+
// content policy only excludes "hard" CSS-based hiding (display:none,
|
|
171
|
+
// etc.), not the softer aria-hidden exclusion, so this needs its own
|
|
172
|
+
// check.
|
|
173
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
174
|
+
const elig = (() => {
|
|
175
|
+
try {
|
|
176
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
177
|
+
} catch {
|
|
178
|
+
return { eligible: true, reasons: [] };
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
if (elig && elig.eligible === false) continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
mains.push(el);
|
|
148
185
|
}
|
|
149
186
|
|
|
150
187
|
if (mains.length === 0) {
|
|
@@ -156,7 +193,7 @@ function runInPage(ctx) {
|
|
|
156
193
|
if (!hasLandmarkAncestor(el)) continue;
|
|
157
194
|
|
|
158
195
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
159
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
196
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
160
197
|
|
|
161
198
|
occurrences.push({
|
|
162
199
|
selector: stableSelector,
|
|
@@ -175,7 +212,12 @@ function runInPage(ctx) {
|
|
|
175
212
|
}
|
|
176
213
|
|
|
177
214
|
if (occurrences.length) {
|
|
178
|
-
return {
|
|
215
|
+
return {
|
|
216
|
+
ruleId: rule.ruleId,
|
|
217
|
+
outcome: 'cantTell',
|
|
218
|
+
severity: rule.defaultSeverity || 'minor',
|
|
219
|
+
occurrences
|
|
220
|
+
};
|
|
179
221
|
}
|
|
180
222
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
181
223
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-no-duplicate-banner
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A page must not have more than one banner landmark
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies whenever the page contains at least one banner landmark
|
|
10
12
|
* (explicit role="banner", or an implicit, non-nested <header> — see
|
|
@@ -21,21 +23,19 @@
|
|
|
21
23
|
* header comment for the shared rationale/precedent.
|
|
22
24
|
* - Flags every banner instance (not just the "extra" ones) when more
|
|
23
25
|
* than one exists, since which instance is "correct" is ambiguous.
|
|
24
|
-
* - Only landmarks actually exposed to assistive technology can collide
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* on a real site — Trello's homepage, a desktop/mobile header pair) was
|
|
30
|
-
* wrongly flagged as a duplicate landmark; the hidden copy is never
|
|
31
|
-
* actually reachable by AT.
|
|
26
|
+
* - Only landmarks actually exposed to assistive technology can collide.
|
|
27
|
+
* Without this, a responsive layout rendering both a visible and a
|
|
28
|
+
* CSS-hidden duplicate `<header>` (a desktop/mobile header pair) is
|
|
29
|
+
* flagged as a duplicate landmark even though the hidden copy is never
|
|
30
|
+
* reachable by AT.
|
|
32
31
|
*/
|
|
33
32
|
|
|
34
33
|
const id = 'landmark-no-duplicate-banner';
|
|
35
34
|
|
|
36
35
|
const meta = {
|
|
37
36
|
title: 'Page must not have more than one banner landmark',
|
|
38
|
-
description:
|
|
37
|
+
description:
|
|
38
|
+
'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
|
|
39
39
|
i18n: {
|
|
40
40
|
titleKey: 'landmarkNoDuplicateBanner_title',
|
|
41
41
|
descriptionKey: 'landmarkNoDuplicateBanner_description'
|
|
@@ -55,14 +55,14 @@ function runInPage(ctx) {
|
|
|
55
55
|
const { document, helpers, rule } = ctx;
|
|
56
56
|
|
|
57
57
|
function normalizeWs(s) {
|
|
58
|
-
return String(s || '')
|
|
58
|
+
return String(s || '')
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim();
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
62
64
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
63
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
64
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
65
|
-
// copy of this logic.
|
|
65
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
66
66
|
function getAccessibleLandmarkName(el) {
|
|
67
67
|
try {
|
|
68
68
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -85,10 +85,9 @@ function runInPage(ctx) {
|
|
|
85
85
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
86
86
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
87
87
|
// rather than a local tag-only copy. See that function's header comment
|
|
88
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// rule's own former tag-only copy as a false negative.
|
|
88
|
+
// in src/core/aria-helpers.js for the full algorithm — e.g. an
|
|
89
|
+
// <aside role="dialog"> containing its own <header>, where the <header>
|
|
90
|
+
// keeps its banner role.
|
|
92
91
|
function hasSectioningAncestor(el, includeMain) {
|
|
93
92
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
94
93
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -102,11 +101,9 @@ function runInPage(ctx) {
|
|
|
102
101
|
if (tag === 'main') return 'main';
|
|
103
102
|
if (tag === 'nav') return 'navigation';
|
|
104
103
|
if (tag === 'aside') {
|
|
105
|
-
// A named <aside> is never suppressed, even when nested
|
|
106
|
-
// landmark-unique's own verified-against-reference-engine precedent
|
|
107
|
-
// (that engine's real `aside` implicit-role function keeps
|
|
104
|
+
// A named <aside> is never suppressed, even when nested: keeps
|
|
108
105
|
// "complementary" when the element has an accessible name, even
|
|
109
|
-
// inside sectioning content
|
|
106
|
+
// inside sectioning content. See landmark-unique-manual.js.
|
|
110
107
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
111
108
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
112
109
|
}
|
|
@@ -115,7 +112,16 @@ function runInPage(ctx) {
|
|
|
115
112
|
return '';
|
|
116
113
|
}
|
|
117
114
|
|
|
118
|
-
const LANDMARK_ROLES = new Set([
|
|
115
|
+
const LANDMARK_ROLES = new Set([
|
|
116
|
+
'banner',
|
|
117
|
+
'contentinfo',
|
|
118
|
+
'main',
|
|
119
|
+
'navigation',
|
|
120
|
+
'complementary',
|
|
121
|
+
'region',
|
|
122
|
+
'form',
|
|
123
|
+
'search'
|
|
124
|
+
]);
|
|
119
125
|
|
|
120
126
|
function getLandmarkRole(el) {
|
|
121
127
|
if (!el || !el.getAttribute) return '';
|
|
@@ -125,19 +131,20 @@ function runInPage(ctx) {
|
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
128
|
-
// landmark-unique-manual.js's header comment
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
let nodes = [];
|
|
134
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
135
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
136
|
+
let nodes;
|
|
132
137
|
try {
|
|
133
|
-
nodes =
|
|
134
|
-
|
|
135
|
-
|
|
138
|
+
nodes =
|
|
139
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
140
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
141
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
136
142
|
} catch {
|
|
137
143
|
nodes = [];
|
|
138
144
|
}
|
|
139
145
|
|
|
140
|
-
const isAccTreeEligible =
|
|
146
|
+
const isAccTreeEligible =
|
|
147
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
141
148
|
|
|
142
149
|
function isExposedToAt(el) {
|
|
143
150
|
if (!isAccTreeEligible) return true;
|
|
@@ -165,7 +172,7 @@ function runInPage(ctx) {
|
|
|
165
172
|
|
|
166
173
|
const occurrences = banners.map((el) => {
|
|
167
174
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
168
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
175
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
169
176
|
|
|
170
177
|
return {
|
|
171
178
|
selector: stableSelector,
|
|
@@ -183,7 +190,12 @@ function runInPage(ctx) {
|
|
|
183
190
|
};
|
|
184
191
|
});
|
|
185
192
|
|
|
186
|
-
return {
|
|
193
|
+
return {
|
|
194
|
+
ruleId: rule.ruleId,
|
|
195
|
+
outcome: 'cantTell',
|
|
196
|
+
severity: rule.defaultSeverity || 'minor',
|
|
197
|
+
occurrences
|
|
198
|
+
};
|
|
187
199
|
}
|
|
188
200
|
|
|
189
201
|
module.exports = { id, meta, runInPage };
|