@surea11y/core 1.3.0 → 1.4.1
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 +87 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/CI_INTEGRATIONS.md +7 -7
- package/docs/ENGINE_OPTIONS.md +1 -1
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +52 -16
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +4 -6
- package/src/checks/automatic/aria-allowed-attr.js +663 -134
- package/src/checks/automatic/aria-allowed-role.js +2 -0
- package/src/checks/automatic/aria-braille-equivalent.js +2 -0
- package/src/checks/automatic/aria-conditional-attr.js +8 -7
- package/src/checks/automatic/aria-deprecated-role.js +107 -38
- package/src/checks/automatic/aria-hidden-body.js +6 -4
- package/src/checks/automatic/aria-hidden-focus.js +12 -13
- package/src/checks/automatic/aria-prohibited-attr.js +98 -105
- package/src/checks/automatic/aria-prohibited-children.js +56 -87
- package/src/checks/automatic/aria-required-attr.js +6 -7
- package/src/checks/automatic/aria-required-children.js +7 -10
- package/src/checks/automatic/aria-required-parent.js +20 -25
- package/src/checks/automatic/aria-role-name-present.js +2 -0
- package/src/checks/automatic/aria-roles-valid.js +33 -6
- package/src/checks/automatic/aria-valid-attr-value.js +18 -15
- package/src/checks/automatic/aria-valid-attr.js +2 -0
- package/src/checks/automatic/autocomplete-valid.js +39 -1
- package/src/checks/automatic/avoid-inline-spacing.js +181 -20
- package/src/checks/automatic/binary-control-name-present.js +13 -3
- package/src/checks/automatic/button-name-present.js +54 -21
- package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
- package/src/checks/automatic/combobox-name-present.js +10 -1
- package/src/checks/automatic/contrast-computable.js +2 -0
- package/src/checks/automatic/contrast-enhanced.js +2 -0
- package/src/checks/automatic/contrast-minimum.js +2 -0
- package/src/checks/automatic/css-orientation-lock.js +21 -27
- package/src/checks/automatic/definition-list-children-valid.js +6 -6
- package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
- package/src/checks/automatic/dialog-name-present.js +18 -11
- package/src/checks/automatic/dlitem-parent-valid.js +2 -0
- package/src/checks/automatic/duplicate-id-aria.js +4 -3
- package/src/checks/automatic/embed-text-alternative-present.js +2 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
- package/src/checks/automatic/form-control-single-label.js +110 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
- package/src/checks/automatic/iframe-focusable-content.js +246 -18
- package/src/checks/automatic/iframe-name-present.js +2 -0
- package/src/checks/automatic/iframe-title-unique.js +3 -1
- package/src/checks/automatic/img-alt-present.js +23 -18
- package/src/checks/automatic/input-image-alt-present.js +101 -52
- package/src/checks/automatic/label-in-name.js +97 -27
- package/src/checks/automatic/language-page-present.js +7 -1
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +52 -17
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +10 -1
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +10 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
- package/src/checks/automatic/meter-name-present.js +12 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
- package/src/checks/automatic/object-text-alternative-present.js +16 -7
- package/src/checks/automatic/option-name-present.js +10 -1
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +16 -11
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +10 -1
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +13 -2
- package/src/checks/automatic/spinbutton-name-present.js +10 -1
- package/src/checks/automatic/summary-name-present.js +10 -1
- package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
- package/src/checks/automatic/svg-text-alternative-present.js +17 -5
- package/src/checks/automatic/tab-name-present.js +10 -1
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +69 -6
- package/src/checks/automatic/target-size-minimum.js +5 -0
- package/src/checks/automatic/td-has-header.js +24 -1
- package/src/checks/automatic/textbox-name-present.js +10 -1
- package/src/checks/automatic/tooltip-name-present.js +10 -1
- package/src/checks/automatic/treeitem-name-present.js +10 -1
- package/src/checks/automatic/valid-lang.js +18 -3
- package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
- package/src/checks/manual/accesskeys-manual.js +3 -1
- package/src/checks/manual/area-alt-decorative-manual.js +2 -0
- package/src/checks/manual/area-alt-quality-manual.js +2 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
- package/src/checks/manual/aria-text-manual.js +6 -5
- package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/css-hidden-focus.js +184 -9
- package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
- package/src/checks/manual/empty-heading-manual.js +17 -17
- package/src/checks/manual/empty-table-header-manual.js +52 -25
- package/src/checks/manual/focus-order-semantics-manual.js +16 -4
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
- package/src/checks/manual/heading-order-manual.js +28 -1
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
- package/src/checks/manual/image-redundant-alt-manual.js +21 -1
- package/src/checks/manual/img-alt-decorative-manual.js +2 -0
- package/src/checks/manual/img-alt-quality-manual.js +2 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
- package/src/checks/manual/label-title-only-manual.js +29 -22
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
- package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
- package/src/checks/manual/landmark-one-main-manual.js +12 -23
- package/src/checks/manual/landmark-unique-manual.js +37 -52
- package/src/checks/manual/link-name-quality-manual.js +2 -0
- package/src/checks/manual/media-transcript-present-manual.js +2 -0
- package/src/checks/manual/meta-viewport-large-manual.js +3 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/p-as-heading-manual.js +2 -0
- package/src/checks/manual/page-has-heading-one-manual.js +12 -11
- package/src/checks/manual/page-title-patterns-manual.js +2 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
- package/src/checks/manual/region-manual.js +27 -36
- package/src/checks/manual/scope-attr-valid-manual.js +3 -1
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
- package/src/checks/manual/skip-link-manual.js +7 -6
- package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/tabindex-manual.js +3 -1
- package/src/checks/manual/table-duplicate-name-manual.js +5 -4
- package/src/checks/manual/table-fake-caption-manual.js +24 -3
- package/src/checks/manual/video-caption-manual.js +2 -0
- package/src/checks/manual-review.js +2 -0
- package/src/core.js +11820 -3317
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +20 -5
- package/surea11y.browser.js +4943 -1388
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- package/src/checks/automatic/bypass-blocks-present.js +0 -215
- 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 -1211
- package/src/core/contrast-helpers.js +0 -1302
- package/src/core/dom-helpers.js +0 -4493
- package/src/core/dom-runner.js +0 -787
- package/src/core/frame-messaging.js +0 -261
- package/src/core/frame-scan.js +0 -190
- package/src/core/rollup-composites.js +0 -127
- package/src/core/rule-meta.js +0 -176
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -1228
- package/src/i18n/fr.js +0 -1185
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -59
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,15 +1,17 @@
|
|
|
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
11
|
* Applies whenever the page contains at least one contentinfo
|
|
10
12
|
* candidate: explicit role="contentinfo", OR a <footer> with NO role
|
|
11
|
-
* attribute at all
|
|
12
|
-
*
|
|
13
|
+
* attribute at all, regardless of nesting (see implementation notes on
|
|
14
|
+
* why candidate selection is deliberately unconditional).
|
|
13
15
|
* @expectation
|
|
14
16
|
* No contentinfo candidate has an ancestor that is itself any landmark
|
|
15
17
|
* region. A contentinfo nested inside another landmark is not a
|
|
@@ -20,13 +22,9 @@
|
|
|
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).
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
25
|
+
* - Candidate selection (`isContentinfoCandidate`) requires the element to
|
|
26
|
+
* really carry the contentinfo role, via the suppression-aware
|
|
27
|
+
* `getLandmarkRole` — same reasoning as landmark-banner-is-top-level.
|
|
30
28
|
*/
|
|
31
29
|
|
|
32
30
|
const id = 'landmark-contentinfo-is-top-level';
|
|
@@ -61,9 +59,8 @@ function runInPage(ctx) {
|
|
|
61
59
|
|
|
62
60
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
63
61
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
64
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
65
|
-
//
|
|
66
|
-
// copy of this logic.
|
|
62
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
63
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
67
64
|
function getAccessibleLandmarkName(el) {
|
|
68
65
|
try {
|
|
69
66
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -86,10 +83,8 @@ function runInPage(ctx) {
|
|
|
86
83
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
87
84
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
88
85
|
// rather than a local tag-only copy. See that function's header comment
|
|
89
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
90
|
-
//
|
|
91
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
92
|
-
// rule's own former tag-only copy as a false negative.
|
|
86
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
87
|
+
// <aside role="dialog"> containing its own <header>.
|
|
93
88
|
function hasSectioningAncestor(el, includeMain) {
|
|
94
89
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
95
90
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -103,11 +98,9 @@ function runInPage(ctx) {
|
|
|
103
98
|
if (tag === 'main') return 'main';
|
|
104
99
|
if (tag === 'nav') return 'navigation';
|
|
105
100
|
if (tag === 'aside') {
|
|
106
|
-
// A named <aside> is never suppressed, even when nested —
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
// "complementary" when the element has an accessible name, even
|
|
110
|
-
// inside sectioning content); propagated here for consistency.
|
|
101
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
102
|
+
// "complementary" when it has an accessible name, even inside
|
|
103
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
111
104
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
112
105
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
113
106
|
}
|
|
@@ -135,15 +128,15 @@ function runInPage(ctx) {
|
|
|
135
128
|
}
|
|
136
129
|
|
|
137
130
|
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
138
|
-
// === 'contentinfo' — see the
|
|
131
|
+
// === 'contentinfo' — see the header comment above. A <footer> is
|
|
139
132
|
// a candidate purely by tag + absence of any role attribute, independent
|
|
140
133
|
// of whether sectioning-ancestor nesting would currently suppress its
|
|
141
134
|
// implicit role; an explicit role="contentinfo" is always a candidate too.
|
|
135
|
+
// A candidate must actually have the contentinfo role — a <footer> inside
|
|
136
|
+
// article/aside/main/nav/section is not one, so flagging it as nested
|
|
137
|
+
// would report a landmark that does not exist.
|
|
142
138
|
function isContentinfoCandidate(el) {
|
|
143
|
-
|
|
144
|
-
const explicit = getExplicitRoleToken(el);
|
|
145
|
-
if (explicit) return explicit === 'contentinfo';
|
|
146
|
-
return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
|
|
139
|
+
return getLandmarkRole(el) === 'contentinfo';
|
|
147
140
|
}
|
|
148
141
|
|
|
149
142
|
function hasLandmarkAncestor(el) {
|
|
@@ -160,9 +153,8 @@ function runInPage(ctx) {
|
|
|
160
153
|
}
|
|
161
154
|
|
|
162
155
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
163
|
-
// landmark-unique-manual.js's header comment
|
|
164
|
-
//
|
|
165
|
-
// invisible to a light-DOM-only query.
|
|
156
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
157
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
166
158
|
let nodes;
|
|
167
159
|
try {
|
|
168
160
|
nodes =
|
|
@@ -178,7 +170,28 @@ function runInPage(ctx) {
|
|
|
178
170
|
for (const el of nodes) {
|
|
179
171
|
if (!el || seen.has(el)) continue;
|
|
180
172
|
seen.add(el);
|
|
181
|
-
if (isContentinfoCandidate(el))
|
|
173
|
+
if (!isContentinfoCandidate(el)) continue;
|
|
174
|
+
|
|
175
|
+
// An aria-hidden contentinfo candidate is removed from the
|
|
176
|
+
// accessibility tree entirely -- it isn't part of the landmark
|
|
177
|
+
// structure assistive technology users navigate at all, so it
|
|
178
|
+
// shouldn't be flagged as "nested inside another landmark" (there's
|
|
179
|
+
// no real landmark there to begin with, from AT's perspective).
|
|
180
|
+
// queryAllSmart's default hidden-content policy only excludes "hard"
|
|
181
|
+
// CSS-based hiding (display:none, etc.), not the softer aria-hidden
|
|
182
|
+
// exclusion, so this needs its own check.
|
|
183
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
184
|
+
const elig = (() => {
|
|
185
|
+
try {
|
|
186
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
187
|
+
} catch {
|
|
188
|
+
return { eligible: true, reasons: [] };
|
|
189
|
+
}
|
|
190
|
+
})();
|
|
191
|
+
if (elig && elig.eligible === false) continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
contentinfos.push(el);
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
if (contentinfos.length === 0) {
|
|
@@ -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,13 @@
|
|
|
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).
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
const id = 'landmark-main-is-top-level';
|
|
@@ -59,9 +61,8 @@ function runInPage(ctx) {
|
|
|
59
61
|
|
|
60
62
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
61
63
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
62
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
63
|
-
//
|
|
64
|
-
// 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.
|
|
65
66
|
function getAccessibleLandmarkName(el) {
|
|
66
67
|
try {
|
|
67
68
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -84,10 +85,8 @@ function runInPage(ctx) {
|
|
|
84
85
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
85
86
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
86
87
|
// rather than a local tag-only copy. See that function's header comment
|
|
87
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
88
|
-
//
|
|
89
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
90
|
-
// 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>.
|
|
91
90
|
function hasSectioningAncestor(el, includeMain) {
|
|
92
91
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
93
92
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -101,11 +100,9 @@ function runInPage(ctx) {
|
|
|
101
100
|
if (tag === 'main') return 'main';
|
|
102
101
|
if (tag === 'nav') return 'navigation';
|
|
103
102
|
if (tag === 'aside') {
|
|
104
|
-
// A named <aside> is never suppressed, even when nested —
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
// "complementary" when the element has an accessible name, even
|
|
108
|
-
// 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.
|
|
109
106
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
110
107
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
111
108
|
}
|
|
@@ -146,9 +143,8 @@ function runInPage(ctx) {
|
|
|
146
143
|
}
|
|
147
144
|
|
|
148
145
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
149
|
-
// landmark-unique-manual.js's header comment
|
|
150
|
-
//
|
|
151
|
-
// invisible to a light-DOM-only query.
|
|
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.
|
|
152
148
|
let nodes;
|
|
153
149
|
try {
|
|
154
150
|
nodes =
|
|
@@ -164,7 +160,28 @@ function runInPage(ctx) {
|
|
|
164
160
|
for (const el of nodes) {
|
|
165
161
|
if (!el || seen.has(el)) continue;
|
|
166
162
|
seen.add(el);
|
|
167
|
-
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);
|
|
168
185
|
}
|
|
169
186
|
|
|
170
187
|
if (mains.length === 0) {
|
|
@@ -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,14 +23,11 @@
|
|
|
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';
|
|
@@ -63,9 +62,7 @@ function runInPage(ctx) {
|
|
|
63
62
|
|
|
64
63
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
65
64
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
66
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
67
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
68
|
-
// copy of this logic.
|
|
65
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
69
66
|
function getAccessibleLandmarkName(el) {
|
|
70
67
|
try {
|
|
71
68
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -88,10 +85,9 @@ function runInPage(ctx) {
|
|
|
88
85
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
89
86
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
90
87
|
// rather than a local tag-only copy. See that function's header comment
|
|
91
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// 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.
|
|
95
91
|
function hasSectioningAncestor(el, includeMain) {
|
|
96
92
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
97
93
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -105,11 +101,9 @@ function runInPage(ctx) {
|
|
|
105
101
|
if (tag === 'main') return 'main';
|
|
106
102
|
if (tag === 'nav') return 'navigation';
|
|
107
103
|
if (tag === 'aside') {
|
|
108
|
-
// A named <aside> is never suppressed, even when nested
|
|
109
|
-
// landmark-unique's own verified-against-reference-engine precedent
|
|
110
|
-
// (that engine's real `aside` implicit-role function keeps
|
|
104
|
+
// A named <aside> is never suppressed, even when nested: keeps
|
|
111
105
|
// "complementary" when the element has an accessible name, even
|
|
112
|
-
// inside sectioning content
|
|
106
|
+
// inside sectioning content. See landmark-unique-manual.js.
|
|
113
107
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
114
108
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
115
109
|
}
|
|
@@ -137,9 +131,8 @@ function runInPage(ctx) {
|
|
|
137
131
|
}
|
|
138
132
|
|
|
139
133
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
140
|
-
// landmark-unique-manual.js's header comment
|
|
141
|
-
//
|
|
142
|
-
// invisible to a light-DOM-only query.
|
|
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.
|
|
143
136
|
let nodes;
|
|
144
137
|
try {
|
|
145
138
|
nodes =
|
|
@@ -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-contentinfo
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A page must not have more than one contentinfo 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 contentinfo landmark
|
|
10
12
|
* (explicit role="contentinfo", or an implicit, non-nested <footer>).
|
|
@@ -16,11 +18,8 @@
|
|
|
16
18
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
17
19
|
* header comment for the shared rationale/precedent.
|
|
18
20
|
* - Only landmarks actually exposed to assistive technology can collide —
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* _isVisibleToScreenReaders(elm))`) — same fix applied to the sibling
|
|
22
|
-
* banner/main rules after finding real hidden-duplicate false positives
|
|
23
|
-
* on Trello and Zoom.
|
|
21
|
+
* same as the sibling banner/main rules, avoiding hidden-duplicate false
|
|
22
|
+
* positives.
|
|
24
23
|
*/
|
|
25
24
|
|
|
26
25
|
const id = 'landmark-no-duplicate-contentinfo';
|
|
@@ -55,9 +54,8 @@ function runInPage(ctx) {
|
|
|
55
54
|
|
|
56
55
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
57
56
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
58
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
59
|
-
//
|
|
60
|
-
// copy of this logic.
|
|
57
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
58
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
61
59
|
function getAccessibleLandmarkName(el) {
|
|
62
60
|
try {
|
|
63
61
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -80,10 +78,8 @@ function runInPage(ctx) {
|
|
|
80
78
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
81
79
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
82
80
|
// rather than a local tag-only copy. See that function's header comment
|
|
83
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
84
|
-
//
|
|
85
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
86
|
-
// rule's own former tag-only copy as a false negative.
|
|
81
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
82
|
+
// <aside role="dialog"> containing its own <header>.
|
|
87
83
|
function hasSectioningAncestor(el, includeMain) {
|
|
88
84
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
89
85
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -97,11 +93,9 @@ function runInPage(ctx) {
|
|
|
97
93
|
if (tag === 'main') return 'main';
|
|
98
94
|
if (tag === 'nav') return 'navigation';
|
|
99
95
|
if (tag === 'aside') {
|
|
100
|
-
// A named <aside> is never suppressed, even when nested —
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// "complementary" when the element has an accessible name, even
|
|
104
|
-
// inside sectioning content); propagated here for consistency.
|
|
96
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
97
|
+
// "complementary" when it has an accessible name, even inside
|
|
98
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
105
99
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
106
100
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
107
101
|
}
|
|
@@ -129,9 +123,8 @@ function runInPage(ctx) {
|
|
|
129
123
|
}
|
|
130
124
|
|
|
131
125
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
132
|
-
// landmark-unique-manual.js's header comment
|
|
133
|
-
//
|
|
134
|
-
// invisible to a light-DOM-only query.
|
|
126
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
127
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
135
128
|
let nodes;
|
|
136
129
|
try {
|
|
137
130
|
nodes =
|
|
@@ -1,29 +1,27 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-no-duplicate-main
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary A page must not have more than one main 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>).
|
|
11
13
|
* @expectation
|
|
12
14
|
* At most one main landmark exists on the page. Distinct, atomic
|
|
13
15
|
* decision from landmark-one-main (that rule flags zero
|
|
14
|
-
* mains too; this one only flags more than one)
|
|
15
|
-
* reference engine shipping both as separate rules with some overlap by design.
|
|
16
|
+
* mains too; this one only flags more than one).
|
|
16
17
|
* @implementation-notes
|
|
17
18
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
18
19
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
20
|
* header comment for the shared rationale/precedent.
|
|
20
|
-
* - Only landmarks actually exposed to assistive technology can collide
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* rendering both a visible and a CSS-hidden duplicate `<main>` (found on
|
|
25
|
-
* a real site — Zoom's homepage) was wrongly flagged as a duplicate
|
|
26
|
-
* landmark; the hidden copy is never actually reachable by AT.
|
|
21
|
+
* - Only landmarks actually exposed to assistive technology can collide.
|
|
22
|
+
* Without this, a responsive layout rendering both a visible and a
|
|
23
|
+
* CSS-hidden duplicate `<main>` is flagged as a duplicate landmark even
|
|
24
|
+
* though the hidden copy is never reachable by AT.
|
|
27
25
|
*/
|
|
28
26
|
|
|
29
27
|
const id = 'landmark-no-duplicate-main';
|
|
@@ -70,9 +68,8 @@ function runInPage(ctx) {
|
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
73
|
-
// landmark-unique-manual.js's header comment
|
|
74
|
-
//
|
|
75
|
-
// invisible to a light-DOM-only query.
|
|
71
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
72
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
76
73
|
let nodes;
|
|
77
74
|
try {
|
|
78
75
|
nodes =
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-one-main
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The page should have a main landmark
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Always applicable to any HTML document with a <body> element —
|
|
10
12
|
* "does the page have a main landmark" is a whole-page concern,
|
|
@@ -18,27 +20,15 @@
|
|
|
18
20
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
19
21
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
20
22
|
* header comment for the shared rationale/precedent.
|
|
21
|
-
* - Presence-only
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* ships that as a fully separate rule, `landmark-no-duplicate-main` /
|
|
28
|
-
* `page-no-duplicate-main`, already correctly implemented here as
|
|
29
|
-
* `landmark-no-duplicate-main`) and missing that sibling rule's
|
|
30
|
-
* accessibility-tree visibility filter, so it double-flagged cases the
|
|
31
|
-
* sibling rule already handles correctly — found via a real page
|
|
32
|
-
* (2026-07-22, live-DOM corpus): Resy's and DuckDuckGo's homepages each
|
|
33
|
-
* genuinely have two visible `<main>` elements, which that reference
|
|
34
|
-
* engine's `landmark-one-main` doesn't flag at all (out of its scope) but
|
|
35
|
-
* this rule wrongly did, disagreeing with it for a
|
|
36
|
-
* reason that wasn't a real coverage gap on either side — just a
|
|
37
|
-
* redundant, incorrectly-scoped extra branch here.
|
|
23
|
+
* - Presence-only: a plain descendant-exists test. It does NOT flag more
|
|
24
|
+
* than one main. "More than one main" is a separate rule,
|
|
25
|
+
* `landmark-no-duplicate-main`, already implemented — deliberately not
|
|
26
|
+
* duplicated here, since a page can genuinely have two visible `<main>`
|
|
27
|
+
* elements, which is out of scope for "does a main landmark exist," not
|
|
28
|
+
* a violation this rule should report.
|
|
38
29
|
* - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
|
|
39
30
|
* display:none/inert elements don't count as "a main landmark exists"),
|
|
40
|
-
* matching `landmark-no-duplicate-main`'s own precedent
|
|
41
|
-
* that reference engine's own accessibility-tree-scoped matching.
|
|
31
|
+
* matching `landmark-no-duplicate-main`'s own precedent.
|
|
42
32
|
*/
|
|
43
33
|
|
|
44
34
|
const id = 'landmark-one-main';
|
|
@@ -106,9 +96,8 @@ function runInPage(ctx) {
|
|
|
106
96
|
}
|
|
107
97
|
|
|
108
98
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
109
|
-
// landmark-unique-manual.js's header comment
|
|
110
|
-
//
|
|
111
|
-
// invisible to a light-DOM-only query.
|
|
99
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
100
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
112
101
|
let nodes;
|
|
113
102
|
try {
|
|
114
103
|
nodes =
|