@surea11y/core 1.1.2 → 1.3.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 +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +50 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +125 -29
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +50 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +39 -9
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +117 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +26 -7
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -12
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +25 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +11 -9
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -6,12 +6,11 @@
|
|
|
6
6
|
* @summary The banner landmark must not be nested inside another landmark
|
|
7
7
|
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
8
|
* @applicability
|
|
9
|
-
* Applies whenever the page contains at least one banner
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* implementation notes).
|
|
9
|
+
* Applies whenever the page contains at least one banner candidate:
|
|
10
|
+
* explicit role="banner", OR a <header> with NO role attribute at all
|
|
11
|
+
* (regardless of nesting — see implementation notes' 2026-08-01 fix).
|
|
13
12
|
* @expectation
|
|
14
|
-
* No banner
|
|
13
|
+
* No banner candidate has an ancestor that is itself any landmark
|
|
15
14
|
* region. A banner nested inside another landmark is not a top-level,
|
|
16
15
|
* whole-page banner and confuses landmark-based navigation for
|
|
17
16
|
* assistive technology users.
|
|
@@ -29,13 +28,35 @@
|
|
|
29
28
|
* region/form only when accessibly named), not a byte-for-byte port
|
|
30
29
|
* of a widely-used reference engine's internal algorithm — verify against upstream if exact
|
|
31
30
|
* parity is ever required.
|
|
31
|
+
* - **Fixed 2026-08-01, a self-defeating applicability bug found via the
|
|
32
|
+
* cross-engine comparisons project (verified live on TurboTax's real
|
|
33
|
+
* homepage, `<header>` nested inside a `<div id="main" role="main">`
|
|
34
|
+
* two levels up):** candidate selection used to run the *same*
|
|
35
|
+
* HTML-AAM sectioning-ancestor suppression used for the violation
|
|
36
|
+
* check itself (`getImplicitLandmarkRole`'s `hasSectioningAncestor`
|
|
37
|
+
* gate) — so the moment a `<header>` was nested inside another
|
|
38
|
+
* landmark, that same nesting made it stop counting as a banner
|
|
39
|
+
* candidate in the first place, and the rule could never flag the one
|
|
40
|
+
* case it exists to catch. A widely-used reference engine's own
|
|
41
|
+
* `landmark-banner-is-top-level` avoids this: its selector
|
|
42
|
+
* (`header:not([role]), [role=banner]`) is unconditional — it doesn't
|
|
43
|
+
* care whether the header *currently* carries the banner role, only
|
|
44
|
+
* whether it's a `<header>`/`role="banner"` with a landmark ancestor
|
|
45
|
+
* above it (verified by reading that engine's real
|
|
46
|
+
* `landmark-is-top-level-evaluate` source, not guessed). Candidate
|
|
47
|
+
* selection (`isBannerCandidate` below) now matches that unconditional
|
|
48
|
+
* selector shape; the ancestor walk (`hasLandmarkAncestor`) still uses
|
|
49
|
+
* the full suppression-aware `getLandmarkRole` for each ancestor,
|
|
50
|
+
* which is correct and unchanged — an ancestor genuinely needs its own
|
|
51
|
+
* real role to count as blocking.
|
|
32
52
|
*/
|
|
33
53
|
|
|
34
54
|
const id = 'landmark-banner-is-top-level';
|
|
35
55
|
|
|
36
56
|
const meta = {
|
|
37
57
|
title: 'Banner landmark must be top-level',
|
|
38
|
-
description:
|
|
58
|
+
description:
|
|
59
|
+
'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
|
|
39
60
|
i18n: {
|
|
40
61
|
titleKey: 'landmarkBannerIsTopLevel_title',
|
|
41
62
|
descriptionKey: 'landmarkBannerIsTopLevel_description'
|
|
@@ -52,12 +73,14 @@ const meta = {
|
|
|
52
73
|
};
|
|
53
74
|
|
|
54
75
|
function runInPage(ctx) {
|
|
55
|
-
const { document, helpers, rule } = ctx;
|
|
76
|
+
const { document, root, helpers, rule } = ctx;
|
|
56
77
|
|
|
57
78
|
// Declared inside runInPage — see scripts/build-core.js header
|
|
58
79
|
// ("runInPage MUST be self-contained").
|
|
59
80
|
function normalizeWs(s) {
|
|
60
|
-
return String(s || '')
|
|
81
|
+
return String(s || '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -81,31 +104,52 @@ function runInPage(ctx) {
|
|
|
81
104
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
82
105
|
}
|
|
83
106
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
108
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
109
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
110
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
111
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
112
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
113
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
114
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
115
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
116
|
+
// rule's own former tag-only copy as a false negative.
|
|
117
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
118
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
119
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
120
|
+
: false;
|
|
94
121
|
}
|
|
95
122
|
|
|
96
123
|
function getImplicitLandmarkRole(el) {
|
|
97
124
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
98
|
-
if (tag === 'header') return
|
|
99
|
-
if (tag === 'footer') return
|
|
125
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
126
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
100
127
|
if (tag === 'main') return 'main';
|
|
101
128
|
if (tag === 'nav') return 'navigation';
|
|
102
|
-
if (tag === 'aside')
|
|
129
|
+
if (tag === 'aside') {
|
|
130
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
131
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
132
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
133
|
+
// "complementary" when the element has an accessible name, even
|
|
134
|
+
// inside sectioning content); propagated here for consistency.
|
|
135
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
136
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
137
|
+
}
|
|
103
138
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
104
139
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
105
140
|
return '';
|
|
106
141
|
}
|
|
107
142
|
|
|
108
|
-
const LANDMARK_ROLES = new Set([
|
|
143
|
+
const LANDMARK_ROLES = new Set([
|
|
144
|
+
'banner',
|
|
145
|
+
'contentinfo',
|
|
146
|
+
'main',
|
|
147
|
+
'navigation',
|
|
148
|
+
'complementary',
|
|
149
|
+
'region',
|
|
150
|
+
'form',
|
|
151
|
+
'search'
|
|
152
|
+
]);
|
|
109
153
|
|
|
110
154
|
function getLandmarkRole(el) {
|
|
111
155
|
if (!el || !el.getAttribute) return '';
|
|
@@ -114,10 +158,26 @@ function runInPage(ctx) {
|
|
|
114
158
|
return getImplicitLandmarkRole(el);
|
|
115
159
|
}
|
|
116
160
|
|
|
161
|
+
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
162
|
+
// === 'banner' — see the 2026-08-01 fix note above. A <header> is a
|
|
163
|
+
// candidate purely by tag + absence of any role attribute, independent
|
|
164
|
+
// of whether sectioning-ancestor nesting would currently suppress its
|
|
165
|
+
// implicit role; an explicit role="banner" is always a candidate too.
|
|
166
|
+
function isBannerCandidate(el) {
|
|
167
|
+
if (!el || !el.getAttribute) return false;
|
|
168
|
+
const explicit = getExplicitRoleToken(el);
|
|
169
|
+
if (explicit) return explicit === 'banner';
|
|
170
|
+
return !!(el.tagName && el.tagName.toLowerCase() === 'header');
|
|
171
|
+
}
|
|
172
|
+
|
|
117
173
|
function hasLandmarkAncestor(el) {
|
|
174
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
118
175
|
let p = el.parentElement;
|
|
119
176
|
while (p) {
|
|
120
177
|
if (getLandmarkRole(p)) return true;
|
|
178
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
179
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
180
|
+
if (scopeRoots.includes(p)) break;
|
|
121
181
|
p = p.parentElement;
|
|
122
182
|
}
|
|
123
183
|
return false;
|
|
@@ -127,11 +187,12 @@ function runInPage(ctx) {
|
|
|
127
187
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
128
188
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
129
189
|
// invisible to a light-DOM-only query.
|
|
130
|
-
let nodes
|
|
190
|
+
let nodes;
|
|
131
191
|
try {
|
|
132
|
-
nodes =
|
|
133
|
-
|
|
134
|
-
|
|
192
|
+
nodes =
|
|
193
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
194
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
195
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
135
196
|
} catch {
|
|
136
197
|
nodes = [];
|
|
137
198
|
}
|
|
@@ -141,7 +202,7 @@ function runInPage(ctx) {
|
|
|
141
202
|
for (const el of nodes) {
|
|
142
203
|
if (!el || seen.has(el)) continue;
|
|
143
204
|
seen.add(el);
|
|
144
|
-
if (
|
|
205
|
+
if (isBannerCandidate(el)) banners.push(el);
|
|
145
206
|
}
|
|
146
207
|
|
|
147
208
|
if (banners.length === 0) {
|
|
@@ -153,7 +214,7 @@ function runInPage(ctx) {
|
|
|
153
214
|
if (!hasLandmarkAncestor(el)) continue;
|
|
154
215
|
|
|
155
216
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
156
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
217
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
157
218
|
|
|
158
219
|
occurrences.push({
|
|
159
220
|
selector: stableSelector,
|
|
@@ -172,7 +233,12 @@ function runInPage(ctx) {
|
|
|
172
233
|
}
|
|
173
234
|
|
|
174
235
|
if (occurrences.length) {
|
|
175
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
ruleId: rule.ruleId,
|
|
238
|
+
outcome: 'cantTell',
|
|
239
|
+
severity: rule.defaultSeverity || 'minor',
|
|
240
|
+
occurrences
|
|
241
|
+
};
|
|
176
242
|
}
|
|
177
243
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
178
244
|
}
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* @summary The contentinfo landmark must not be nested inside another landmark
|
|
7
7
|
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
8
|
* @applicability
|
|
9
|
-
* Applies whenever the page contains at least one contentinfo
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Applies whenever the page contains at least one contentinfo
|
|
10
|
+
* candidate: explicit role="contentinfo", OR a <footer> with NO role
|
|
11
|
+
* attribute at all (regardless of nesting — see implementation notes'
|
|
12
|
+
* 2026-08-01 fix).
|
|
13
13
|
* @expectation
|
|
14
|
-
* No contentinfo
|
|
14
|
+
* No contentinfo candidate has an ancestor that is itself any landmark
|
|
15
15
|
* region. A contentinfo nested inside another landmark is not a
|
|
16
16
|
* top-level, whole-page footer region and confuses landmark-based
|
|
17
17
|
* navigation for assistive technology users.
|
|
@@ -20,13 +20,21 @@
|
|
|
20
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
21
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
22
22
|
* its structure with contentinfo/footer in place of banner/header).
|
|
23
|
+
* - **Fixed 2026-08-01, same self-defeating applicability bug as
|
|
24
|
+
* landmark-banner-is-top-level (see that file's header comment for the
|
|
25
|
+
* full root cause and the TurboTax evidence) — candidate selection
|
|
26
|
+
* (`isContentinfoCandidate` below) now matches a widely-used reference
|
|
27
|
+
* engine's unconditional `footer:not([role]), [role=contentinfo]`
|
|
28
|
+
* selector shape instead of reusing the sectioning-ancestor
|
|
29
|
+
* suppression that the violation check itself depends on.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
const id = 'landmark-contentinfo-is-top-level';
|
|
26
33
|
|
|
27
34
|
const meta = {
|
|
28
35
|
title: 'Contentinfo landmark must be top-level',
|
|
29
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Checks that the contentinfo landmark (role="contentinfo" or a non-nested <footer>) is not nested inside another landmark region.',
|
|
30
38
|
i18n: {
|
|
31
39
|
titleKey: 'landmarkContentinfoIsTopLevel_title',
|
|
32
40
|
descriptionKey: 'landmarkContentinfoIsTopLevel_description'
|
|
@@ -43,10 +51,12 @@ const meta = {
|
|
|
43
51
|
};
|
|
44
52
|
|
|
45
53
|
function runInPage(ctx) {
|
|
46
|
-
const { document, helpers, rule } = ctx;
|
|
54
|
+
const { document, root, helpers, rule } = ctx;
|
|
47
55
|
|
|
48
56
|
function normalizeWs(s) {
|
|
49
|
-
return String(s || '')
|
|
57
|
+
return String(s || '')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -70,31 +80,52 @@ function runInPage(ctx) {
|
|
|
70
80
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
84
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
85
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
86
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
87
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
88
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
89
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
90
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
91
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
92
|
+
// rule's own former tag-only copy as a false negative.
|
|
93
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
94
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
95
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
96
|
+
: false;
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
function getImplicitLandmarkRole(el) {
|
|
86
100
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
87
|
-
if (tag === 'header') return
|
|
88
|
-
if (tag === 'footer') return
|
|
101
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
102
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
89
103
|
if (tag === 'main') return 'main';
|
|
90
104
|
if (tag === 'nav') return 'navigation';
|
|
91
|
-
if (tag === 'aside')
|
|
105
|
+
if (tag === 'aside') {
|
|
106
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
107
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
108
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
109
|
+
// "complementary" when the element has an accessible name, even
|
|
110
|
+
// inside sectioning content); propagated here for consistency.
|
|
111
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
112
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
113
|
+
}
|
|
92
114
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
93
115
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
94
116
|
return '';
|
|
95
117
|
}
|
|
96
118
|
|
|
97
|
-
const LANDMARK_ROLES = new Set([
|
|
119
|
+
const LANDMARK_ROLES = new Set([
|
|
120
|
+
'banner',
|
|
121
|
+
'contentinfo',
|
|
122
|
+
'main',
|
|
123
|
+
'navigation',
|
|
124
|
+
'complementary',
|
|
125
|
+
'region',
|
|
126
|
+
'form',
|
|
127
|
+
'search'
|
|
128
|
+
]);
|
|
98
129
|
|
|
99
130
|
function getLandmarkRole(el) {
|
|
100
131
|
if (!el || !el.getAttribute) return '';
|
|
@@ -103,10 +134,26 @@ function runInPage(ctx) {
|
|
|
103
134
|
return getImplicitLandmarkRole(el);
|
|
104
135
|
}
|
|
105
136
|
|
|
137
|
+
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
138
|
+
// === 'contentinfo' — see the 2026-08-01 fix note above. A <footer> is
|
|
139
|
+
// a candidate purely by tag + absence of any role attribute, independent
|
|
140
|
+
// of whether sectioning-ancestor nesting would currently suppress its
|
|
141
|
+
// implicit role; an explicit role="contentinfo" is always a candidate too.
|
|
142
|
+
function isContentinfoCandidate(el) {
|
|
143
|
+
if (!el || !el.getAttribute) return false;
|
|
144
|
+
const explicit = getExplicitRoleToken(el);
|
|
145
|
+
if (explicit) return explicit === 'contentinfo';
|
|
146
|
+
return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
|
|
147
|
+
}
|
|
148
|
+
|
|
106
149
|
function hasLandmarkAncestor(el) {
|
|
150
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
107
151
|
let p = el.parentElement;
|
|
108
152
|
while (p) {
|
|
109
153
|
if (getLandmarkRole(p)) return true;
|
|
154
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
155
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
156
|
+
if (scopeRoots.includes(p)) break;
|
|
110
157
|
p = p.parentElement;
|
|
111
158
|
}
|
|
112
159
|
return false;
|
|
@@ -116,11 +163,12 @@ function runInPage(ctx) {
|
|
|
116
163
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
117
164
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
118
165
|
// invisible to a light-DOM-only query.
|
|
119
|
-
let nodes
|
|
166
|
+
let nodes;
|
|
120
167
|
try {
|
|
121
|
-
nodes =
|
|
122
|
-
|
|
123
|
-
|
|
168
|
+
nodes =
|
|
169
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
170
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
171
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
124
172
|
} catch {
|
|
125
173
|
nodes = [];
|
|
126
174
|
}
|
|
@@ -130,7 +178,7 @@ function runInPage(ctx) {
|
|
|
130
178
|
for (const el of nodes) {
|
|
131
179
|
if (!el || seen.has(el)) continue;
|
|
132
180
|
seen.add(el);
|
|
133
|
-
if (
|
|
181
|
+
if (isContentinfoCandidate(el)) contentinfos.push(el);
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
if (contentinfos.length === 0) {
|
|
@@ -142,7 +190,7 @@ function runInPage(ctx) {
|
|
|
142
190
|
if (!hasLandmarkAncestor(el)) continue;
|
|
143
191
|
|
|
144
192
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
145
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
193
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
146
194
|
|
|
147
195
|
occurrences.push({
|
|
148
196
|
selector: stableSelector,
|
|
@@ -161,7 +209,12 @@ function runInPage(ctx) {
|
|
|
161
209
|
}
|
|
162
210
|
|
|
163
211
|
if (occurrences.length) {
|
|
164
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
ruleId: rule.ruleId,
|
|
214
|
+
outcome: 'cantTell',
|
|
215
|
+
severity: rule.defaultSeverity || 'minor',
|
|
216
|
+
occurrences
|
|
217
|
+
};
|
|
165
218
|
}
|
|
166
219
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
167
220
|
}
|
|
@@ -18,13 +18,21 @@
|
|
|
18
18
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
19
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
20
20
|
* its structure with main in place of banner/header).
|
|
21
|
+
* - Did NOT need the 2026-08-01 fix applied to
|
|
22
|
+
* landmark-banner-is-top-level/landmark-contentinfo-is-top-level (see
|
|
23
|
+
* that file's header comment): `<main>`'s implicit role is
|
|
24
|
+
* unconditional per HTML-AAM — unlike `<header>`/`<footer>`, nesting
|
|
25
|
+
* never suppresses it — so `getImplicitLandmarkRole`'s `main` branch
|
|
26
|
+
* was never subject to the same self-defeating candidate-selection
|
|
27
|
+
* bug. Confirmed by inspection, not just by absence of a bug report.
|
|
21
28
|
*/
|
|
22
29
|
|
|
23
30
|
const id = 'landmark-main-is-top-level';
|
|
24
31
|
|
|
25
32
|
const meta = {
|
|
26
33
|
title: 'Main landmark must be top-level',
|
|
27
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
'Checks that the main landmark (role="main" or <main>) is not nested inside another landmark region.',
|
|
28
36
|
i18n: {
|
|
29
37
|
titleKey: 'landmarkMainIsTopLevel_title',
|
|
30
38
|
descriptionKey: 'landmarkMainIsTopLevel_description'
|
|
@@ -41,10 +49,12 @@ const meta = {
|
|
|
41
49
|
};
|
|
42
50
|
|
|
43
51
|
function runInPage(ctx) {
|
|
44
|
-
const { document, helpers, rule } = ctx;
|
|
52
|
+
const { document, root, helpers, rule } = ctx;
|
|
45
53
|
|
|
46
54
|
function normalizeWs(s) {
|
|
47
|
-
return String(s || '')
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -68,31 +78,52 @@ function runInPage(ctx) {
|
|
|
68
78
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
82
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
83
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
84
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
85
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
86
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
87
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
88
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
89
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
90
|
+
// rule's own former tag-only copy as a false negative.
|
|
91
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
92
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
93
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
94
|
+
: false;
|
|
81
95
|
}
|
|
82
96
|
|
|
83
97
|
function getImplicitLandmarkRole(el) {
|
|
84
98
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
85
|
-
if (tag === 'header') return
|
|
86
|
-
if (tag === 'footer') return
|
|
99
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
100
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
87
101
|
if (tag === 'main') return 'main';
|
|
88
102
|
if (tag === 'nav') return 'navigation';
|
|
89
|
-
if (tag === 'aside')
|
|
103
|
+
if (tag === 'aside') {
|
|
104
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
105
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
106
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
107
|
+
// "complementary" when the element has an accessible name, even
|
|
108
|
+
// inside sectioning content); propagated here for consistency.
|
|
109
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
110
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
111
|
+
}
|
|
90
112
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
91
113
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
92
114
|
return '';
|
|
93
115
|
}
|
|
94
116
|
|
|
95
|
-
const LANDMARK_ROLES = new Set([
|
|
117
|
+
const LANDMARK_ROLES = new Set([
|
|
118
|
+
'banner',
|
|
119
|
+
'contentinfo',
|
|
120
|
+
'main',
|
|
121
|
+
'navigation',
|
|
122
|
+
'complementary',
|
|
123
|
+
'region',
|
|
124
|
+
'form',
|
|
125
|
+
'search'
|
|
126
|
+
]);
|
|
96
127
|
|
|
97
128
|
function getLandmarkRole(el) {
|
|
98
129
|
if (!el || !el.getAttribute) return '';
|
|
@@ -102,9 +133,13 @@ function runInPage(ctx) {
|
|
|
102
133
|
}
|
|
103
134
|
|
|
104
135
|
function hasLandmarkAncestor(el) {
|
|
136
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
105
137
|
let p = el.parentElement;
|
|
106
138
|
while (p) {
|
|
107
139
|
if (getLandmarkRole(p)) return true;
|
|
140
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
141
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
142
|
+
if (scopeRoots.includes(p)) break;
|
|
108
143
|
p = p.parentElement;
|
|
109
144
|
}
|
|
110
145
|
return false;
|
|
@@ -114,11 +149,12 @@ function runInPage(ctx) {
|
|
|
114
149
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
115
150
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
116
151
|
// invisible to a light-DOM-only query.
|
|
117
|
-
let nodes
|
|
152
|
+
let nodes;
|
|
118
153
|
try {
|
|
119
|
-
nodes =
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
nodes =
|
|
155
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
156
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
157
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
122
158
|
} catch {
|
|
123
159
|
nodes = [];
|
|
124
160
|
}
|
|
@@ -140,7 +176,7 @@ function runInPage(ctx) {
|
|
|
140
176
|
if (!hasLandmarkAncestor(el)) continue;
|
|
141
177
|
|
|
142
178
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
143
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
179
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
144
180
|
|
|
145
181
|
occurrences.push({
|
|
146
182
|
selector: stableSelector,
|
|
@@ -159,7 +195,12 @@ function runInPage(ctx) {
|
|
|
159
195
|
}
|
|
160
196
|
|
|
161
197
|
if (occurrences.length) {
|
|
162
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
ruleId: rule.ruleId,
|
|
200
|
+
outcome: 'cantTell',
|
|
201
|
+
severity: rule.defaultSeverity || 'minor',
|
|
202
|
+
occurrences
|
|
203
|
+
};
|
|
163
204
|
}
|
|
164
205
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
165
206
|
}
|