@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
|
@@ -35,7 +35,8 @@ const id = 'landmark-no-duplicate-banner';
|
|
|
35
35
|
|
|
36
36
|
const meta = {
|
|
37
37
|
title: 'Page must not have more than one banner landmark',
|
|
38
|
-
description:
|
|
38
|
+
description:
|
|
39
|
+
'Checks that at most one banner landmark (role="banner" or a non-nested <header>) exists on the page.',
|
|
39
40
|
i18n: {
|
|
40
41
|
titleKey: 'landmarkNoDuplicateBanner_title',
|
|
41
42
|
descriptionKey: 'landmarkNoDuplicateBanner_description'
|
|
@@ -55,7 +56,9 @@ function runInPage(ctx) {
|
|
|
55
56
|
const { document, helpers, rule } = ctx;
|
|
56
57
|
|
|
57
58
|
function normalizeWs(s) {
|
|
58
|
-
return String(s || '')
|
|
59
|
+
return String(s || '')
|
|
60
|
+
.replace(/\s+/g, ' ')
|
|
61
|
+
.trim();
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -79,31 +82,52 @@ function runInPage(ctx) {
|
|
|
79
82
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
85
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
86
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
87
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
88
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
89
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
90
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
91
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
92
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
93
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
94
|
+
// rule's own former tag-only copy as a false negative.
|
|
95
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
96
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
97
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
98
|
+
: false;
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
function getImplicitLandmarkRole(el) {
|
|
95
102
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
96
|
-
if (tag === 'header') return
|
|
97
|
-
if (tag === 'footer') return
|
|
103
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
104
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
98
105
|
if (tag === 'main') return 'main';
|
|
99
106
|
if (tag === 'nav') return 'navigation';
|
|
100
|
-
if (tag === 'aside')
|
|
107
|
+
if (tag === 'aside') {
|
|
108
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
109
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
110
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
111
|
+
// "complementary" when the element has an accessible name, even
|
|
112
|
+
// inside sectioning content); propagated here for consistency.
|
|
113
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
114
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
115
|
+
}
|
|
101
116
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
102
117
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
103
118
|
return '';
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
const LANDMARK_ROLES = new Set([
|
|
121
|
+
const LANDMARK_ROLES = new Set([
|
|
122
|
+
'banner',
|
|
123
|
+
'contentinfo',
|
|
124
|
+
'main',
|
|
125
|
+
'navigation',
|
|
126
|
+
'complementary',
|
|
127
|
+
'region',
|
|
128
|
+
'form',
|
|
129
|
+
'search'
|
|
130
|
+
]);
|
|
107
131
|
|
|
108
132
|
function getLandmarkRole(el) {
|
|
109
133
|
if (!el || !el.getAttribute) return '';
|
|
@@ -116,16 +140,18 @@ function runInPage(ctx) {
|
|
|
116
140
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
117
141
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
118
142
|
// invisible to a light-DOM-only query.
|
|
119
|
-
let nodes
|
|
143
|
+
let nodes;
|
|
120
144
|
try {
|
|
121
|
-
nodes =
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
nodes =
|
|
146
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
147
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
148
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
124
149
|
} catch {
|
|
125
150
|
nodes = [];
|
|
126
151
|
}
|
|
127
152
|
|
|
128
|
-
const isAccTreeEligible =
|
|
153
|
+
const isAccTreeEligible =
|
|
154
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
129
155
|
|
|
130
156
|
function isExposedToAt(el) {
|
|
131
157
|
if (!isAccTreeEligible) return true;
|
|
@@ -153,7 +179,7 @@ function runInPage(ctx) {
|
|
|
153
179
|
|
|
154
180
|
const occurrences = banners.map((el) => {
|
|
155
181
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
156
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
182
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
157
183
|
|
|
158
184
|
return {
|
|
159
185
|
selector: stableSelector,
|
|
@@ -171,7 +197,12 @@ function runInPage(ctx) {
|
|
|
171
197
|
};
|
|
172
198
|
});
|
|
173
199
|
|
|
174
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
ruleId: rule.ruleId,
|
|
202
|
+
outcome: 'cantTell',
|
|
203
|
+
severity: rule.defaultSeverity || 'minor',
|
|
204
|
+
occurrences
|
|
205
|
+
};
|
|
175
206
|
}
|
|
176
207
|
|
|
177
208
|
module.exports = { id, meta, runInPage };
|
|
@@ -27,7 +27,8 @@ const id = 'landmark-no-duplicate-contentinfo';
|
|
|
27
27
|
|
|
28
28
|
const meta = {
|
|
29
29
|
title: 'Page must not have more than one contentinfo landmark',
|
|
30
|
-
description:
|
|
30
|
+
description:
|
|
31
|
+
'Checks that at most one contentinfo landmark (role="contentinfo" or a non-nested <footer>) exists on the page.',
|
|
31
32
|
i18n: {
|
|
32
33
|
titleKey: 'landmarkNoDuplicateContentinfo_title',
|
|
33
34
|
descriptionKey: 'landmarkNoDuplicateContentinfo_description'
|
|
@@ -47,7 +48,9 @@ function runInPage(ctx) {
|
|
|
47
48
|
const { document, helpers, rule } = ctx;
|
|
48
49
|
|
|
49
50
|
function normalizeWs(s) {
|
|
50
|
-
return String(s || '')
|
|
51
|
+
return String(s || '')
|
|
52
|
+
.replace(/\s+/g, ' ')
|
|
53
|
+
.trim();
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -71,31 +74,52 @@ function runInPage(ctx) {
|
|
|
71
74
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
78
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
79
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
80
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
81
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
82
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
83
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
84
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
85
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
86
|
+
// rule's own former tag-only copy as a false negative.
|
|
87
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
88
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
89
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
90
|
+
: false;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
function getImplicitLandmarkRole(el) {
|
|
87
94
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
88
|
-
if (tag === 'header') return
|
|
89
|
-
if (tag === 'footer') return
|
|
95
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
96
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
90
97
|
if (tag === 'main') return 'main';
|
|
91
98
|
if (tag === 'nav') return 'navigation';
|
|
92
|
-
if (tag === 'aside')
|
|
99
|
+
if (tag === 'aside') {
|
|
100
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
101
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
102
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
103
|
+
// "complementary" when the element has an accessible name, even
|
|
104
|
+
// inside sectioning content); propagated here for consistency.
|
|
105
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
106
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
107
|
+
}
|
|
93
108
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
94
109
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
95
110
|
return '';
|
|
96
111
|
}
|
|
97
112
|
|
|
98
|
-
const LANDMARK_ROLES = new Set([
|
|
113
|
+
const LANDMARK_ROLES = new Set([
|
|
114
|
+
'banner',
|
|
115
|
+
'contentinfo',
|
|
116
|
+
'main',
|
|
117
|
+
'navigation',
|
|
118
|
+
'complementary',
|
|
119
|
+
'region',
|
|
120
|
+
'form',
|
|
121
|
+
'search'
|
|
122
|
+
]);
|
|
99
123
|
|
|
100
124
|
function getLandmarkRole(el) {
|
|
101
125
|
if (!el || !el.getAttribute) return '';
|
|
@@ -108,16 +132,18 @@ function runInPage(ctx) {
|
|
|
108
132
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
109
133
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
110
134
|
// invisible to a light-DOM-only query.
|
|
111
|
-
let nodes
|
|
135
|
+
let nodes;
|
|
112
136
|
try {
|
|
113
|
-
nodes =
|
|
114
|
-
|
|
115
|
-
|
|
137
|
+
nodes =
|
|
138
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
139
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
140
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
116
141
|
} catch {
|
|
117
142
|
nodes = [];
|
|
118
143
|
}
|
|
119
144
|
|
|
120
|
-
const isAccTreeEligible =
|
|
145
|
+
const isAccTreeEligible =
|
|
146
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
121
147
|
|
|
122
148
|
function isExposedToAt(el) {
|
|
123
149
|
if (!isAccTreeEligible) return true;
|
|
@@ -145,7 +171,7 @@ function runInPage(ctx) {
|
|
|
145
171
|
|
|
146
172
|
const occurrences = contentinfos.map((el) => {
|
|
147
173
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
148
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
174
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
149
175
|
|
|
150
176
|
return {
|
|
151
177
|
selector: stableSelector,
|
|
@@ -163,7 +189,12 @@ function runInPage(ctx) {
|
|
|
163
189
|
};
|
|
164
190
|
});
|
|
165
191
|
|
|
166
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
ruleId: rule.ruleId,
|
|
194
|
+
outcome: 'cantTell',
|
|
195
|
+
severity: rule.defaultSeverity || 'minor',
|
|
196
|
+
occurrences
|
|
197
|
+
};
|
|
167
198
|
}
|
|
168
199
|
|
|
169
200
|
module.exports = { id, meta, runInPage };
|
|
@@ -50,7 +50,9 @@ function runInPage(ctx) {
|
|
|
50
50
|
const { document, helpers, rule } = ctx;
|
|
51
51
|
|
|
52
52
|
function normalizeWs(s) {
|
|
53
|
-
return String(s || '')
|
|
53
|
+
return String(s || '')
|
|
54
|
+
.replace(/\s+/g, ' ')
|
|
55
|
+
.trim();
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function getExplicitRoleToken(el) {
|
|
@@ -71,16 +73,18 @@ function runInPage(ctx) {
|
|
|
71
73
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
72
74
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
73
75
|
// invisible to a light-DOM-only query.
|
|
74
|
-
let nodes
|
|
76
|
+
let nodes;
|
|
75
77
|
try {
|
|
76
|
-
nodes =
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
nodes =
|
|
79
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
80
|
+
? helpers.queryAllSmart('main, [role]')
|
|
81
|
+
: document.querySelectorAll('main, [role]');
|
|
79
82
|
} catch {
|
|
80
83
|
nodes = [];
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
const isAccTreeEligible =
|
|
86
|
+
const isAccTreeEligible =
|
|
87
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
84
88
|
|
|
85
89
|
function isExposedToAt(el) {
|
|
86
90
|
if (!isAccTreeEligible) return true;
|
|
@@ -108,7 +112,7 @@ function runInPage(ctx) {
|
|
|
108
112
|
|
|
109
113
|
const occurrences = mains.map((el) => {
|
|
110
114
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
111
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
115
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
112
116
|
|
|
113
117
|
return {
|
|
114
118
|
selector: stableSelector,
|
|
@@ -126,7 +130,12 @@ function runInPage(ctx) {
|
|
|
126
130
|
};
|
|
127
131
|
});
|
|
128
132
|
|
|
129
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
ruleId: rule.ruleId,
|
|
135
|
+
outcome: 'cantTell',
|
|
136
|
+
severity: rule.defaultSeverity || 'minor',
|
|
137
|
+
occurrences
|
|
138
|
+
};
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
module.exports = { id, meta, runInPage };
|
|
@@ -61,6 +61,14 @@ const meta = {
|
|
|
61
61
|
coverage: {}
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
65
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
66
|
+
// scoped this run narrower than the whole document, or when
|
|
67
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
68
|
+
function applicability(ctx) {
|
|
69
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
function runInPage(ctx) {
|
|
65
73
|
const { document, helpers, rule } = ctx;
|
|
66
74
|
|
|
@@ -70,7 +78,7 @@ function runInPage(ctx) {
|
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
function getExplicitRoleToken(el) {
|
|
73
|
-
const raw = (el.getAttribute && el.getAttribute('role') || '').trim();
|
|
81
|
+
const raw = ((el.getAttribute && el.getAttribute('role')) || '').trim();
|
|
74
82
|
if (!raw) return '';
|
|
75
83
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
76
84
|
}
|
|
@@ -83,7 +91,8 @@ function runInPage(ctx) {
|
|
|
83
91
|
return tag === 'main';
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
const isAccTreeEligible =
|
|
94
|
+
const isAccTreeEligible =
|
|
95
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
87
96
|
|
|
88
97
|
function isExposedToAt(el) {
|
|
89
98
|
if (!isAccTreeEligible) return true;
|
|
@@ -100,11 +109,12 @@ function runInPage(ctx) {
|
|
|
100
109
|
// landmark-unique-manual.js's header comment for the real page (Airtable, 2026-07-23)
|
|
101
110
|
// that surfaced this gap: a third-party shadow-DOM-hosted widget's own landmark is
|
|
102
111
|
// invisible to a light-DOM-only query.
|
|
103
|
-
let nodes
|
|
112
|
+
let nodes;
|
|
104
113
|
try {
|
|
105
|
-
nodes =
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
nodes =
|
|
115
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
116
|
+
? helpers.queryAllSmart('main, [role]')
|
|
117
|
+
: document.querySelectorAll('main, [role]');
|
|
108
118
|
} catch {
|
|
109
119
|
nodes = [];
|
|
110
120
|
}
|
|
@@ -125,27 +135,31 @@ function runInPage(ctx) {
|
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
128
|
-
const html = helpers.getOuterHtmlSnippet
|
|
138
|
+
const html = helpers.getOuterHtmlSnippet
|
|
139
|
+
? helpers.getOuterHtmlSnippet(body)
|
|
140
|
+
: (body.outerHTML || '').slice(0, 200);
|
|
129
141
|
|
|
130
142
|
return {
|
|
131
143
|
ruleId: rule.ruleId,
|
|
132
144
|
outcome: 'cantTell',
|
|
133
145
|
severity: rule.defaultSeverity || 'minor',
|
|
134
|
-
occurrences: [
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
occurrences: [
|
|
147
|
+
{
|
|
148
|
+
selector: stableSelector,
|
|
149
|
+
html,
|
|
150
|
+
summary: 'This page has no main landmark.',
|
|
151
|
+
hint: 'Add a main landmark (<main> or role="main") around the page\'s primary content.',
|
|
152
|
+
i18n: {
|
|
153
|
+
summaryKey: 'landmarkOneMain_summary_cantTell_missing',
|
|
154
|
+
hintKey: 'landmarkOneMain_hint_cantTell_missing',
|
|
155
|
+
params: {}
|
|
156
|
+
},
|
|
157
|
+
data: {
|
|
158
|
+
details: { reasonCode: 'LANDMARK_MAIN_MISSING' }
|
|
159
|
+
}
|
|
146
160
|
}
|
|
147
|
-
|
|
161
|
+
]
|
|
148
162
|
};
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
module.exports = { id, meta, runInPage };
|
|
165
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -31,7 +31,8 @@ const id = 'landmark-unique';
|
|
|
31
31
|
|
|
32
32
|
const meta = {
|
|
33
33
|
title: 'Landmarks with the same role must have unique names',
|
|
34
|
-
description:
|
|
34
|
+
description:
|
|
35
|
+
'Checks that when two or more landmarks share the same role, each has a distinct accessible name.',
|
|
35
36
|
i18n: {
|
|
36
37
|
titleKey: 'landmarkUnique_title',
|
|
37
38
|
descriptionKey: 'landmarkUnique_description'
|
|
@@ -51,7 +52,9 @@ function runInPage(ctx) {
|
|
|
51
52
|
const { document, helpers, rule } = ctx;
|
|
52
53
|
|
|
53
54
|
function normalizeWs(s) {
|
|
54
|
-
return String(s || '')
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -75,46 +78,48 @@ function runInPage(ctx) {
|
|
|
75
78
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// <aside
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
81
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
|
|
82
|
+
// an ancestor's bare TAG only counts when it carries no role attribute at
|
|
83
|
+
// all; an explicit role="dialog"-style override no longer suppresses —
|
|
84
|
+
// see that function's header comment in src/core/aria-helpers.js) rather
|
|
85
|
+
// than the two local tag-only Sets this file used to carry. Two distinct
|
|
86
|
+
// ancestor scopes, verified 2026-07-20 against a widely-used reference
|
|
87
|
+
// engine's own implicit-role functions directly rather than assumed from
|
|
88
|
+
// one shared list: <header>/<footer> use "sectioning content PLUS <main>"
|
|
89
|
+
// (includeMain: true) to decide banner/contentinfo suppression, but
|
|
90
|
+
// <aside> uses PLAIN sectioning content only — NOT main (includeMain:
|
|
91
|
+
// false) — to decide complementary suppression. The old single
|
|
92
|
+
// SECTIONING_ANCESTORS set (which included 'main') was correct for
|
|
93
|
+
// header/footer but wrong for aside — found via a real page: Know Your
|
|
94
|
+
// Meme's two unnamed <aside class="extra-large-only"> elements are direct
|
|
95
|
+
// children of <main>, which incorrectly suppressed their implicit
|
|
96
|
+
// "complementary" role entirely, hiding a real duplicate-landmark
|
|
97
|
+
// violation that reference engine correctly flags. The tag-only
|
|
98
|
+
// (non-role-aware) half of this bug was separately found and fixed
|
|
99
|
+
// 2026-07-30 via the cross-engine comparisons project, on
|
|
100
|
+
// handsontable.com's docs-assistant side panel: an <aside role="dialog">
|
|
101
|
+
// containing its own <header> — role="dialog" isn't one of the four
|
|
102
|
+
// scoping roles, so the nested <header> keeps "banner" per spec, but a
|
|
103
|
+
// tag-only check unconditionally suppressed it just because the ancestor
|
|
104
|
+
// TAG was <aside>.
|
|
105
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
106
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
107
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
108
|
+
: false;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
function getImplicitLandmarkRole(el) {
|
|
105
112
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
106
|
-
if (tag === 'header') return
|
|
107
|
-
if (tag === 'footer') return
|
|
113
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
114
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
108
115
|
if (tag === 'main') return 'main';
|
|
109
116
|
if (tag === 'nav') return 'navigation';
|
|
110
117
|
if (tag === 'aside') {
|
|
111
118
|
// Per a widely-used reference engine's own `aside` implicit-role function: suppressed by a
|
|
112
119
|
// sectioning-content ancestor ONLY when the <aside> also has no
|
|
113
120
|
// accessible name — a named <aside> is never suppressed, even when
|
|
114
|
-
// nested.
|
|
115
|
-
|
|
116
|
-
// leaving a known partial fix in place.
|
|
117
|
-
if (!hasSectioningAncestorFrom(el, SECTIONING_ANCESTORS)) return 'complementary';
|
|
121
|
+
// nested.
|
|
122
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
118
123
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
119
124
|
}
|
|
120
125
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
@@ -122,7 +127,16 @@ function runInPage(ctx) {
|
|
|
122
127
|
return '';
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
const LANDMARK_ROLES = new Set([
|
|
130
|
+
const LANDMARK_ROLES = new Set([
|
|
131
|
+
'banner',
|
|
132
|
+
'contentinfo',
|
|
133
|
+
'main',
|
|
134
|
+
'navigation',
|
|
135
|
+
'complementary',
|
|
136
|
+
'region',
|
|
137
|
+
'form',
|
|
138
|
+
'search'
|
|
139
|
+
]);
|
|
126
140
|
|
|
127
141
|
function getLandmarkRole(el) {
|
|
128
142
|
if (!el || !el.getAttribute) return '';
|
|
@@ -150,7 +164,8 @@ function runInPage(ctx) {
|
|
|
150
164
|
return getImplicitLandmarkRole(el);
|
|
151
165
|
}
|
|
152
166
|
|
|
153
|
-
const isAccTreeEligible =
|
|
167
|
+
const isAccTreeEligible =
|
|
168
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
154
169
|
|
|
155
170
|
function isExposedToAt(el) {
|
|
156
171
|
if (!isAccTreeEligible) return true;
|
|
@@ -170,11 +185,12 @@ function runInPage(ctx) {
|
|
|
170
185
|
// own landmark-unique (a real browser DOM, shadow roots included by design) correctly sees as colliding
|
|
171
186
|
// with the page's own unnamed header <nav>/page <footer> -- a real, confirmed surea11y
|
|
172
187
|
// false-negative miss, invisible to plain querySelectorAll's light-DOM-only reach.
|
|
173
|
-
let nodes
|
|
188
|
+
let nodes;
|
|
174
189
|
try {
|
|
175
|
-
nodes =
|
|
176
|
-
|
|
177
|
-
|
|
190
|
+
nodes =
|
|
191
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
192
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
193
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
178
194
|
} catch {
|
|
179
195
|
nodes = [];
|
|
180
196
|
}
|
|
@@ -219,24 +235,31 @@ function runInPage(ctx) {
|
|
|
219
235
|
|
|
220
236
|
for (const { el } of group) {
|
|
221
237
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
222
|
-
const html = helpers.getOuterHtmlSnippet
|
|
238
|
+
const html = helpers.getOuterHtmlSnippet
|
|
239
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
240
|
+
: el.outerHTML || '';
|
|
223
241
|
|
|
224
242
|
occurrences.push({
|
|
225
243
|
selector: stableSelector,
|
|
226
244
|
html,
|
|
227
245
|
summary: normalizedName
|
|
228
|
-
|
|
229
|
-
|
|
246
|
+
? `This ${role} landmark shares its accessible name with another ${role} landmark.`
|
|
247
|
+
: `This ${role} landmark has no accessible name, and more than one unnamed ${role} landmark exists on this page.`,
|
|
230
248
|
hint: `Give each ${role} landmark a distinct name via aria-label or aria-labelledby.`,
|
|
231
249
|
i18n: {
|
|
232
250
|
summaryKey: normalizedName
|
|
233
|
-
|
|
234
|
-
|
|
251
|
+
? 'landmarkUnique_summary_cantTell_duplicateName'
|
|
252
|
+
: 'landmarkUnique_summary_cantTell_bothUnnamed',
|
|
235
253
|
hintKey: 'landmarkUnique_hint_cantTell',
|
|
236
254
|
params: { role }
|
|
237
255
|
},
|
|
238
256
|
data: {
|
|
239
|
-
details: {
|
|
257
|
+
details: {
|
|
258
|
+
reasonCode: 'LANDMARK_NOT_UNIQUE',
|
|
259
|
+
role,
|
|
260
|
+
name: normalizedName,
|
|
261
|
+
groupSize: group.length
|
|
262
|
+
}
|
|
240
263
|
}
|
|
241
264
|
});
|
|
242
265
|
}
|
|
@@ -246,7 +269,12 @@ function runInPage(ctx) {
|
|
|
246
269
|
if (!occurrences.length) {
|
|
247
270
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
248
271
|
}
|
|
249
|
-
return {
|
|
272
|
+
return {
|
|
273
|
+
ruleId: rule.ruleId,
|
|
274
|
+
outcome: 'cantTell',
|
|
275
|
+
severity: rule.defaultSeverity || 'minor',
|
|
276
|
+
occurrences
|
|
277
|
+
};
|
|
250
278
|
}
|
|
251
279
|
|
|
252
280
|
module.exports = { id, meta, runInPage };
|