@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,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,18 +18,16 @@
|
|
|
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';
|
|
27
26
|
|
|
28
27
|
const meta = {
|
|
29
28
|
title: 'Page must not have more than one contentinfo landmark',
|
|
30
|
-
description:
|
|
29
|
+
description:
|
|
30
|
+
'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
|
|
31
31
|
i18n: {
|
|
32
32
|
titleKey: 'landmarkNoDuplicateContentinfo_title',
|
|
33
33
|
descriptionKey: 'landmarkNoDuplicateContentinfo_description'
|
|
@@ -47,14 +47,15 @@ function runInPage(ctx) {
|
|
|
47
47
|
const { document, helpers, rule } = ctx;
|
|
48
48
|
|
|
49
49
|
function normalizeWs(s) {
|
|
50
|
-
return String(s || '')
|
|
50
|
+
return String(s || '')
|
|
51
|
+
.replace(/\s+/g, ' ')
|
|
52
|
+
.trim();
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
54
56
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
55
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
56
|
-
//
|
|
57
|
-
// 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.
|
|
58
59
|
function getAccessibleLandmarkName(el) {
|
|
59
60
|
try {
|
|
60
61
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -77,10 +78,8 @@ function runInPage(ctx) {
|
|
|
77
78
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
78
79
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
79
80
|
// rather than a local tag-only copy. See that function's header comment
|
|
80
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
81
|
-
//
|
|
82
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
83
|
-
// 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>.
|
|
84
83
|
function hasSectioningAncestor(el, includeMain) {
|
|
85
84
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
86
85
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -94,11 +93,9 @@ function runInPage(ctx) {
|
|
|
94
93
|
if (tag === 'main') return 'main';
|
|
95
94
|
if (tag === 'nav') return 'navigation';
|
|
96
95
|
if (tag === 'aside') {
|
|
97
|
-
// A named <aside> is never suppressed, even when nested —
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// "complementary" when the element has an accessible name, even
|
|
101
|
-
// 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.
|
|
102
99
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
103
100
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
104
101
|
}
|
|
@@ -107,7 +104,16 @@ function runInPage(ctx) {
|
|
|
107
104
|
return '';
|
|
108
105
|
}
|
|
109
106
|
|
|
110
|
-
const LANDMARK_ROLES = new Set([
|
|
107
|
+
const LANDMARK_ROLES = new Set([
|
|
108
|
+
'banner',
|
|
109
|
+
'contentinfo',
|
|
110
|
+
'main',
|
|
111
|
+
'navigation',
|
|
112
|
+
'complementary',
|
|
113
|
+
'region',
|
|
114
|
+
'form',
|
|
115
|
+
'search'
|
|
116
|
+
]);
|
|
111
117
|
|
|
112
118
|
function getLandmarkRole(el) {
|
|
113
119
|
if (!el || !el.getAttribute) return '';
|
|
@@ -117,19 +123,20 @@ function runInPage(ctx) {
|
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
120
|
-
// landmark-unique-manual.js's header comment
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
let nodes = [];
|
|
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.
|
|
128
|
+
let nodes;
|
|
124
129
|
try {
|
|
125
|
-
nodes =
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
nodes =
|
|
131
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
132
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
133
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
128
134
|
} catch {
|
|
129
135
|
nodes = [];
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
const isAccTreeEligible =
|
|
138
|
+
const isAccTreeEligible =
|
|
139
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
133
140
|
|
|
134
141
|
function isExposedToAt(el) {
|
|
135
142
|
if (!isAccTreeEligible) return true;
|
|
@@ -157,7 +164,7 @@ function runInPage(ctx) {
|
|
|
157
164
|
|
|
158
165
|
const occurrences = contentinfos.map((el) => {
|
|
159
166
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
160
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
167
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
161
168
|
|
|
162
169
|
return {
|
|
163
170
|
selector: stableSelector,
|
|
@@ -175,7 +182,12 @@ function runInPage(ctx) {
|
|
|
175
182
|
};
|
|
176
183
|
});
|
|
177
184
|
|
|
178
|
-
return {
|
|
185
|
+
return {
|
|
186
|
+
ruleId: rule.ruleId,
|
|
187
|
+
outcome: 'cantTell',
|
|
188
|
+
severity: rule.defaultSeverity || 'minor',
|
|
189
|
+
occurrences
|
|
190
|
+
};
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
module.exports = { id, meta, runInPage };
|
|
@@ -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';
|
|
@@ -50,7 +48,9 @@ function runInPage(ctx) {
|
|
|
50
48
|
const { document, helpers, rule } = ctx;
|
|
51
49
|
|
|
52
50
|
function normalizeWs(s) {
|
|
53
|
-
return String(s || '')
|
|
51
|
+
return String(s || '')
|
|
52
|
+
.replace(/\s+/g, ' ')
|
|
53
|
+
.trim();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function getExplicitRoleToken(el) {
|
|
@@ -68,19 +68,20 @@ function runInPage(ctx) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
71
|
-
// landmark-unique-manual.js's header comment
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
let nodes = [];
|
|
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.
|
|
73
|
+
let nodes;
|
|
75
74
|
try {
|
|
76
|
-
nodes =
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
nodes =
|
|
76
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
77
|
+
? helpers.queryAllSmart('main, [role]')
|
|
78
|
+
: document.querySelectorAll('main, [role]');
|
|
79
79
|
} catch {
|
|
80
80
|
nodes = [];
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const isAccTreeEligible =
|
|
83
|
+
const isAccTreeEligible =
|
|
84
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
84
85
|
|
|
85
86
|
function isExposedToAt(el) {
|
|
86
87
|
if (!isAccTreeEligible) return true;
|
|
@@ -108,7 +109,7 @@ function runInPage(ctx) {
|
|
|
108
109
|
|
|
109
110
|
const occurrences = mains.map((el) => {
|
|
110
111
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
111
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
112
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
112
113
|
|
|
113
114
|
return {
|
|
114
115
|
selector: stableSelector,
|
|
@@ -126,7 +127,12 @@ function runInPage(ctx) {
|
|
|
126
127
|
};
|
|
127
128
|
});
|
|
128
129
|
|
|
129
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
ruleId: rule.ruleId,
|
|
132
|
+
outcome: 'cantTell',
|
|
133
|
+
severity: rule.defaultSeverity || 'minor',
|
|
134
|
+
occurrences
|
|
135
|
+
};
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
module.exports = { id, meta, runInPage };
|
|
@@ -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';
|
|
@@ -78,7 +68,7 @@ function runInPage(ctx) {
|
|
|
78
68
|
}
|
|
79
69
|
|
|
80
70
|
function getExplicitRoleToken(el) {
|
|
81
|
-
const raw = (el.getAttribute && el.getAttribute('role') || '').trim();
|
|
71
|
+
const raw = ((el.getAttribute && el.getAttribute('role')) || '').trim();
|
|
82
72
|
if (!raw) return '';
|
|
83
73
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
84
74
|
}
|
|
@@ -91,7 +81,8 @@ function runInPage(ctx) {
|
|
|
91
81
|
return tag === 'main';
|
|
92
82
|
}
|
|
93
83
|
|
|
94
|
-
const isAccTreeEligible =
|
|
84
|
+
const isAccTreeEligible =
|
|
85
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
95
86
|
|
|
96
87
|
function isExposedToAt(el) {
|
|
97
88
|
if (!isAccTreeEligible) return true;
|
|
@@ -105,14 +96,14 @@ function runInPage(ctx) {
|
|
|
105
96
|
}
|
|
106
97
|
|
|
107
98
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
108
|
-
// landmark-unique-manual.js's header comment
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
let nodes = [];
|
|
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.
|
|
101
|
+
let nodes;
|
|
112
102
|
try {
|
|
113
|
-
nodes =
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
nodes =
|
|
104
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
105
|
+
? helpers.queryAllSmart('main, [role]')
|
|
106
|
+
: document.querySelectorAll('main, [role]');
|
|
116
107
|
} catch {
|
|
117
108
|
nodes = [];
|
|
118
109
|
}
|
|
@@ -133,26 +124,30 @@ function runInPage(ctx) {
|
|
|
133
124
|
}
|
|
134
125
|
|
|
135
126
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
136
|
-
const html = helpers.getOuterHtmlSnippet
|
|
127
|
+
const html = helpers.getOuterHtmlSnippet
|
|
128
|
+
? helpers.getOuterHtmlSnippet(body)
|
|
129
|
+
: (body.outerHTML || '').slice(0, 200);
|
|
137
130
|
|
|
138
131
|
return {
|
|
139
132
|
ruleId: rule.ruleId,
|
|
140
133
|
outcome: 'cantTell',
|
|
141
134
|
severity: rule.defaultSeverity || 'minor',
|
|
142
|
-
occurrences: [
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
135
|
+
occurrences: [
|
|
136
|
+
{
|
|
137
|
+
selector: stableSelector,
|
|
138
|
+
html,
|
|
139
|
+
summary: 'This page has no main landmark.',
|
|
140
|
+
hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
|
|
141
|
+
i18n: {
|
|
142
|
+
summaryKey: 'landmarkOneMain_summary_cantTell_missing',
|
|
143
|
+
hintKey: 'landmarkOneMain_hint_cantTell_missing',
|
|
144
|
+
params: {}
|
|
145
|
+
},
|
|
146
|
+
data: {
|
|
147
|
+
details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
|
|
148
|
+
}
|
|
154
149
|
}
|
|
155
|
-
|
|
150
|
+
]
|
|
156
151
|
};
|
|
157
152
|
}
|
|
158
153
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-unique
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Landmarks sharing the same role must have unique accessible names
|
|
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 two or more landmark regions on the page share the
|
|
10
12
|
* same landmark role (banner, contentinfo, main, navigation,
|
|
@@ -31,7 +33,8 @@ const id = 'landmark-unique';
|
|
|
31
33
|
|
|
32
34
|
const meta = {
|
|
33
35
|
title: 'Landmarks with the same role must have unique names',
|
|
34
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
|
|
35
38
|
i18n: {
|
|
36
39
|
titleKey: 'landmarkUnique_title',
|
|
37
40
|
descriptionKey: 'landmarkUnique_description'
|
|
@@ -51,14 +54,14 @@ function runInPage(ctx) {
|
|
|
51
54
|
const { document, helpers, rule } = ctx;
|
|
52
55
|
|
|
53
56
|
function normalizeWs(s) {
|
|
54
|
-
return String(s || '')
|
|
57
|
+
return String(s || '')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
58
63
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
59
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
60
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
61
|
-
// copy of this logic.
|
|
64
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
62
65
|
function getAccessibleLandmarkName(el) {
|
|
63
66
|
try {
|
|
64
67
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -78,27 +81,21 @@ function runInPage(ctx) {
|
|
|
78
81
|
// Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
|
|
79
82
|
// an ancestor's bare TAG only counts when it carries no role attribute at
|
|
80
83
|
// all; an explicit role="dialog"-style override no longer suppresses —
|
|
81
|
-
// see that function's header comment in src/core/aria-helpers.js)
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// "
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// 2026-07-30 via the cross-engine comparisons project, on
|
|
97
|
-
// handsontable.com's docs-assistant side panel: an <aside role="dialog">
|
|
98
|
-
// containing its own <header> — role="dialog" isn't one of the four
|
|
99
|
-
// scoping roles, so the nested <header> keeps "banner" per spec, but a
|
|
100
|
-
// tag-only check unconditionally suppressed it just because the ancestor
|
|
101
|
-
// TAG was <aside>.
|
|
84
|
+
// see that function's header comment in src/core/aria-helpers.js), using
|
|
85
|
+
// two distinct ancestor scopes rather than one shared list: <header>/
|
|
86
|
+
// <footer> use "sectioning content PLUS <main>" (includeMain: true) to
|
|
87
|
+
// decide banner/contentinfo suppression, but <aside> uses PLAIN
|
|
88
|
+
// sectioning content only — NOT main (includeMain: false) — to decide
|
|
89
|
+
// complementary suppression. A single shared sectioning-ancestors set
|
|
90
|
+
// that includes 'main' is correct for header/footer but wrong for aside:
|
|
91
|
+
// e.g. two unnamed <aside> elements that are direct children of <main>
|
|
92
|
+
// would have their implicit "complementary" role incorrectly suppressed,
|
|
93
|
+
// hiding a real duplicate-landmark violation. The role-aware half matters
|
|
94
|
+
// too: e.g. an <aside role="dialog"> containing its own <header> —
|
|
95
|
+
// role="dialog" isn't one of the four scoping roles, so the nested
|
|
96
|
+
// <header> keeps "banner" per spec, but a tag-only (non-role-aware)
|
|
97
|
+
// check would unconditionally suppress it just because the ancestor TAG
|
|
98
|
+
// was <aside>.
|
|
102
99
|
function hasSectioningAncestor(el, includeMain) {
|
|
103
100
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
104
101
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -112,10 +109,9 @@ function runInPage(ctx) {
|
|
|
112
109
|
if (tag === 'main') return 'main';
|
|
113
110
|
if (tag === 'nav') return 'navigation';
|
|
114
111
|
if (tag === 'aside') {
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// nested.
|
|
112
|
+
// An <aside> is suppressed by a sectioning-content ancestor ONLY
|
|
113
|
+
// when it also has no accessible name — a named <aside> is never
|
|
114
|
+
// suppressed, even when nested.
|
|
119
115
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
120
116
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
121
117
|
}
|
|
@@ -124,7 +120,16 @@ function runInPage(ctx) {
|
|
|
124
120
|
return '';
|
|
125
121
|
}
|
|
126
122
|
|
|
127
|
-
const LANDMARK_ROLES = new Set([
|
|
123
|
+
const LANDMARK_ROLES = new Set([
|
|
124
|
+
'banner',
|
|
125
|
+
'contentinfo',
|
|
126
|
+
'main',
|
|
127
|
+
'navigation',
|
|
128
|
+
'complementary',
|
|
129
|
+
'region',
|
|
130
|
+
'form',
|
|
131
|
+
'search'
|
|
132
|
+
]);
|
|
128
133
|
|
|
129
134
|
function getLandmarkRole(el) {
|
|
130
135
|
if (!el || !el.getAttribute) return '';
|
|
@@ -134,15 +139,12 @@ function runInPage(ctx) {
|
|
|
134
139
|
// <form>/<section> only count as landmarks when they have an
|
|
135
140
|
// accessible name — a property of the ELEMENT, not of how the role
|
|
136
141
|
// got there. This applies whether the role is implicit (already
|
|
137
|
-
// handled in getImplicitLandmarkRole below) or explicit
|
|
138
|
-
// explicit role bypassed the check entirely before this fix. Verified
|
|
139
|
-
// against a widely-used reference engine's isLandmarkVirtual (checks
|
|
140
|
-
// nodeName === 'section' || 'form' unconditionally, regardless of role source) and the W3C
|
|
142
|
+
// handled in getImplicitLandmarkRole below) or explicit. Per the W3C
|
|
141
143
|
// ARIA-in-HTML spec ("a form is not exposed as a landmark region
|
|
142
|
-
// unless it has been provided an accessible name").
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
144
|
+
// unless it has been provided an accessible name"). Otherwise an
|
|
145
|
+
// unnamed <form role="search"> nested inside an unnamed
|
|
146
|
+
// <div role="search"> gets wrongly counted as a second distinct
|
|
147
|
+
// "search" landmark.
|
|
146
148
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
147
149
|
if (tag === 'form' || tag === 'section') {
|
|
148
150
|
return getAccessibleLandmarkName(el) ? explicit : '';
|
|
@@ -152,7 +154,8 @@ function runInPage(ctx) {
|
|
|
152
154
|
return getImplicitLandmarkRole(el);
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
const isAccTreeEligible =
|
|
157
|
+
const isAccTreeEligible =
|
|
158
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
156
159
|
|
|
157
160
|
function isExposedToAt(el) {
|
|
158
161
|
if (!isAccTreeEligible) return true;
|
|
@@ -166,30 +169,26 @@ function runInPage(ctx) {
|
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
// queryAllSmart (shadow-DOM-aware, includeShadowDom defaults true) instead of a plain
|
|
169
|
-
// document.querySelectorAll -- a
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
// false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
|
|
175
|
-
let nodes = [];
|
|
172
|
+
// document.querySelectorAll -- a third-party widget rendering its own
|
|
173
|
+
// unnamed <nav>/<footer> inside a shadow root collides with the page's
|
|
174
|
+
// own unnamed header <nav>/page <footer>, but is invisible to plain
|
|
175
|
+
// querySelectorAll's light-DOM-only reach.
|
|
176
|
+
let nodes;
|
|
176
177
|
try {
|
|
177
|
-
nodes =
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
nodes =
|
|
179
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
180
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
181
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
180
182
|
} catch {
|
|
181
183
|
nodes = [];
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
// Only landmarks actually exposed to assistive technology can collide
|
|
185
|
-
//
|
|
186
|
-
// (
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// weather.com) were wrongly flagged as duplicate landmarks, since the
|
|
191
|
-
// hidden copy is never actually reachable by AT and can't really
|
|
192
|
-
// collide with the visible one.
|
|
186
|
+
// Only landmarks actually exposed to assistive technology can collide.
|
|
187
|
+
// Without this, responsive layouts that render both a desktop and a
|
|
188
|
+
// mobile copy of the same named nav (one hidden via CSS at any given
|
|
189
|
+
// viewport) are wrongly flagged as duplicate landmarks, since the hidden
|
|
190
|
+
// copy is never reachable by AT and can't really collide with the
|
|
191
|
+
// visible one.
|
|
193
192
|
const byRole = new Map(); // role -> [{el, name}]
|
|
194
193
|
const seen = new Set();
|
|
195
194
|
for (const el of nodes) {
|
|
@@ -221,24 +220,31 @@ function runInPage(ctx) {
|
|
|
221
220
|
|
|
222
221
|
for (const { el } of group) {
|
|
223
222
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
224
|
-
const html = helpers.getOuterHtmlSnippet
|
|
223
|
+
const html = helpers.getOuterHtmlSnippet
|
|
224
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
225
|
+
: el.outerHTML || '';
|
|
225
226
|
|
|
226
227
|
occurrences.push({
|
|
227
228
|
selector: stableSelector,
|
|
228
229
|
html,
|
|
229
230
|
summary: normalizedName
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
? `This ${role} landmark shares its accessible name with another ${role} landmark.`
|
|
232
|
+
: `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
|
|
232
233
|
hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
|
|
233
234
|
i18n: {
|
|
234
235
|
summaryKey: normalizedName
|
|
235
|
-
|
|
236
|
-
|
|
236
|
+
? 'landmarkUnique_summary_cantTell_duplicateName'
|
|
237
|
+
: 'landmarkUnique_summary_cantTell_bothUnnamed',
|
|
237
238
|
hintKey: 'landmarkUnique_hint_cantTell',
|
|
238
239
|
params: { role }
|
|
239
240
|
},
|
|
240
241
|
data: {
|
|
241
|
-
details: {
|
|
242
|
+
details: {
|
|
243
|
+
reasonCode: 'LANDMARK_NOT_UNIQUE',
|
|
244
|
+
role,
|
|
245
|
+
name: normalizedName,
|
|
246
|
+
groupSize: group.length
|
|
247
|
+
}
|
|
242
248
|
}
|
|
243
249
|
});
|
|
244
250
|
}
|
|
@@ -248,7 +254,12 @@ function runInPage(ctx) {
|
|
|
248
254
|
if (!occurrences.length) {
|
|
249
255
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
250
256
|
}
|
|
251
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
ruleId: rule.ruleId,
|
|
259
|
+
outcome: 'cantTell',
|
|
260
|
+
severity: rule.defaultSeverity || 'minor',
|
|
261
|
+
occurrences
|
|
262
|
+
};
|
|
252
263
|
}
|
|
253
264
|
|
|
254
265
|
module.exports = { id, meta, runInPage };
|