@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,13 +1,17 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check region
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Page content should be contained within a landmark region
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
|
-
* Applies to
|
|
10
|
-
* and
|
|
11
|
+
* Applies to any element under <body> that directly carries visible text
|
|
12
|
+
* (or other own content — see @implementation-notes) and is not itself a
|
|
13
|
+
* landmark, live region, dialog, button, <svg>, <iframe>/<frame>, or a
|
|
14
|
+
* resolvable skip-link.
|
|
11
15
|
* @expectation
|
|
12
16
|
* Every top-level piece of page content lives inside a landmark region
|
|
13
17
|
* (main, navigation, banner, contentinfo, complementary, region, form,
|
|
@@ -18,19 +22,59 @@
|
|
|
18
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
23
|
* header comment for the shared rationale/precedent and the landmark-
|
|
20
24
|
* detection model.
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* - Recursive tree walk, not a direct-<body>-children-only scan: a
|
|
26
|
+
* direct-children-only scope is nearly inert on the single most common
|
|
27
|
+
* real-world page shape, a modern framework's single root mount div
|
|
28
|
+
* (`<body><div id="root">...everything...</div></body>`) — that shape
|
|
29
|
+
* gives at most one candidate for the entire page and either misses
|
|
30
|
+
* every real gap inside it or collapses the whole page into one
|
|
31
|
+
* undifferentiated report.
|
|
32
|
+
* - Algorithm, using this engine's own helpers (see
|
|
33
|
+
* hasLandmarkScopingAncestor's header comment):
|
|
34
|
+
* 1. Depth-first walk from <body>'s children.
|
|
35
|
+
* 2. At each node: if it's ineligible for the accessibility tree
|
|
36
|
+
* (helpers.isAccTreeEligible), OR is itself a "stopper" (landmark,
|
|
37
|
+
* live region, dialog, button, <svg>, <iframe>/<frame>, or a
|
|
38
|
+
* resolvable skip-link), mark it and every ancestor up to <body> as
|
|
39
|
+
* "has a stopper" and don't recurse further into it (an <iframe>/
|
|
40
|
+
* <frame> is additionally reported as its own occurrence — its
|
|
41
|
+
* content is opaque to this engine, so from the outer page's
|
|
42
|
+
* perspective it IS unplaced content).
|
|
43
|
+
* 3. Otherwise, if the node has OWN content (a direct child text node,
|
|
44
|
+
* being an inherently visual element, or an aria-label) — checked
|
|
45
|
+
* non-recursively, so a plain wrapper <div> with only nested
|
|
46
|
+
* children never short-circuits the walk into its descendants —
|
|
47
|
+
* collect it as a candidate and stop recursing into it.
|
|
48
|
+
* 4. Otherwise recurse into its element children.
|
|
49
|
+
* Each collected candidate is then walked back UP through parents while
|
|
50
|
+
* the parent has no "stopper" marker and isn't <body> itself, collapsing
|
|
51
|
+
* contiguous unplaced content into one occurrence per real gap instead
|
|
52
|
+
* of reporting every individual text-bearing leaf — this is what keeps
|
|
53
|
+
* the walk from being noisy on ordinary pages that mix landmarked and
|
|
54
|
+
* stray content.
|
|
55
|
+
* - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
|
|
56
|
+
* are a deliberate scope choice, not an oversight: these
|
|
57
|
+
* are extremely common real-world patterns (floating action buttons,
|
|
58
|
+
* modal dialogs, decorative/icon SVGs, "skip to content" links) that
|
|
59
|
+
* aren't the kind of "content organization" gap this rule exists to
|
|
60
|
+
* catch, and flagging them would reintroduce the false-positive noise
|
|
61
|
+
* the original narrow scope was trying to avoid.
|
|
62
|
+
* - The "own content" check for aria-label deliberately does NOT resolve
|
|
63
|
+
* aria-labelledby — a known, narrow scope gap (an element named only via aria-labelledby, with no
|
|
64
|
+
* own text/aria-label, and no other content anywhere in its subtree,
|
|
65
|
+
* could be silently skipped) accepted to avoid a full accessible-name
|
|
66
|
+
* computation (recursive itself) inside an already-recursive structural
|
|
67
|
+
* walk.
|
|
68
|
+
* - Bounded by a node-visit budget (see MAX_VISITED_NODES) as a defensive
|
|
69
|
+
* guard against pathological pages, matching the existing pattern used
|
|
70
|
+
* by getContentNameInfo's maxContentNodes in src/core/dom-helpers.js.
|
|
27
71
|
*/
|
|
28
72
|
|
|
29
73
|
const id = 'region';
|
|
30
74
|
|
|
31
75
|
const meta = {
|
|
32
76
|
title: 'Page content should be inside a landmark region',
|
|
33
|
-
description: 'Checks that
|
|
77
|
+
description: 'Checks that content under <body> is contained within a landmark region.',
|
|
34
78
|
i18n: {
|
|
35
79
|
titleKey: 'region_title',
|
|
36
80
|
descriptionKey: 'region_description'
|
|
@@ -63,14 +107,18 @@ function runInPage(ctx) {
|
|
|
63
107
|
}
|
|
64
108
|
|
|
65
109
|
function normalizeWs(s) {
|
|
66
|
-
return String(s || '')
|
|
110
|
+
return String(s || '')
|
|
111
|
+
.replace(/\s+/g, ' ')
|
|
112
|
+
.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function lower(s) {
|
|
116
|
+
return String(s || '').toLowerCase();
|
|
67
117
|
}
|
|
68
118
|
|
|
69
119
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
70
120
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
71
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
72
|
-
// (missing title fallback) this replaced across all 7 landmark rule files that had their own
|
|
73
|
-
// copy of this logic.
|
|
121
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js.
|
|
74
122
|
function getAccessibleLandmarkName(el) {
|
|
75
123
|
try {
|
|
76
124
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -84,7 +132,7 @@ function runInPage(ctx) {
|
|
|
84
132
|
function getExplicitRoleToken(el) {
|
|
85
133
|
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
86
134
|
if (!raw) return '';
|
|
87
|
-
return raw.split(/\s+/)[0]
|
|
135
|
+
return lower(raw.split(/\s+/)[0]);
|
|
88
136
|
}
|
|
89
137
|
|
|
90
138
|
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
@@ -93,10 +141,9 @@ function runInPage(ctx) {
|
|
|
93
141
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
94
142
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
95
143
|
// rather than a local tag-only copy. See that function's header comment
|
|
96
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// rule's own former tag-only copy as a false negative.
|
|
144
|
+
// in src/core/aria-helpers.js for the full algorithm — e.g. an
|
|
145
|
+
// <aside role="dialog"> containing its own <header>, where the <header>
|
|
146
|
+
// keeps its banner role.
|
|
100
147
|
function hasSectioningAncestor(el, includeMain) {
|
|
101
148
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
102
149
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -104,17 +151,15 @@ function runInPage(ctx) {
|
|
|
104
151
|
}
|
|
105
152
|
|
|
106
153
|
function getImplicitLandmarkRole(el) {
|
|
107
|
-
const tag = el.tagName ? el.tagName
|
|
154
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
108
155
|
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
109
156
|
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
110
157
|
if (tag === 'main') return 'main';
|
|
111
158
|
if (tag === 'nav') return 'navigation';
|
|
112
159
|
if (tag === 'aside') {
|
|
113
|
-
// A named <aside> is never suppressed, even when nested
|
|
114
|
-
// landmark-unique's own verified-against-reference-engine precedent
|
|
115
|
-
// (that engine's real `aside` implicit-role function keeps
|
|
160
|
+
// A named <aside> is never suppressed, even when nested: keeps
|
|
116
161
|
// "complementary" when the element has an accessible name, even
|
|
117
|
-
// inside sectioning content
|
|
162
|
+
// inside sectioning content. See landmark-unique-manual.js.
|
|
118
163
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
119
164
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
120
165
|
}
|
|
@@ -123,7 +168,16 @@ function runInPage(ctx) {
|
|
|
123
168
|
return '';
|
|
124
169
|
}
|
|
125
170
|
|
|
126
|
-
const LANDMARK_ROLES = new Set([
|
|
171
|
+
const LANDMARK_ROLES = new Set([
|
|
172
|
+
'banner',
|
|
173
|
+
'contentinfo',
|
|
174
|
+
'main',
|
|
175
|
+
'navigation',
|
|
176
|
+
'complementary',
|
|
177
|
+
'region',
|
|
178
|
+
'form',
|
|
179
|
+
'search'
|
|
180
|
+
]);
|
|
127
181
|
|
|
128
182
|
function isLandmark(el) {
|
|
129
183
|
if (!el || !el.getAttribute) return false;
|
|
@@ -134,48 +188,176 @@ function runInPage(ctx) {
|
|
|
134
188
|
|
|
135
189
|
const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
|
|
136
190
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
191
|
+
// Roles/attributes that make an element its own self-contained
|
|
192
|
+
// announced area — not literally a WAI-ARIA landmark, but not "content
|
|
193
|
+
// that needs a landmark" either.
|
|
194
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
|
|
195
|
+
|
|
196
|
+
function isAriaLive(el) {
|
|
197
|
+
const v = lower(normalizeWs(el.getAttribute && el.getAttribute('aria-live')));
|
|
198
|
+
return v === 'polite' || v === 'assertive';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isDialogLike(el) {
|
|
202
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
203
|
+
if (tag === 'dialog') return true;
|
|
204
|
+
const role = getExplicitRoleToken(el);
|
|
205
|
+
return role === 'dialog' || role === 'alertdialog';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isButtonLike(el) {
|
|
209
|
+
const role = getExplicitRoleToken(el);
|
|
210
|
+
if (role) return role === 'button';
|
|
211
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
212
|
+
if (tag === 'button' || tag === 'summary') return true;
|
|
213
|
+
if (tag === 'input') {
|
|
214
|
+
const type = lower(normalizeWs(el.getAttribute && el.getAttribute('type')));
|
|
215
|
+
return type === 'button' || type === 'submit' || type === 'reset' || type === 'image';
|
|
216
|
+
}
|
|
140
217
|
return false;
|
|
141
218
|
}
|
|
142
219
|
|
|
143
|
-
|
|
144
|
-
|
|
220
|
+
// A "skip to content" link is deliberately placed outside the main
|
|
221
|
+
// content flow at the very top of the page — exempting it (when its
|
|
222
|
+
// fragment actually resolves to a real target, not a dead "#"
|
|
223
|
+
// placeholder) avoids flagging a helpful, common accessibility pattern
|
|
224
|
+
// as the very thing this rule is meant to catch.
|
|
225
|
+
function isResolvableSkipLink(el) {
|
|
226
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
227
|
+
if (tag !== 'a') return false;
|
|
228
|
+
const href = el.getAttribute && el.getAttribute('href');
|
|
229
|
+
if (!href || href.charAt(0) !== '#' || href.length < 2) return false;
|
|
145
230
|
try {
|
|
146
|
-
|
|
147
|
-
const parts = [];
|
|
148
|
-
let n = walker.nextNode();
|
|
149
|
-
while (n) {
|
|
150
|
-
const raw = normalizeWs(n.nodeValue || '');
|
|
151
|
-
if (raw) parts.push(raw);
|
|
152
|
-
n = walker.nextNode();
|
|
153
|
-
}
|
|
154
|
-
return normalizeWs(parts.join(' '));
|
|
231
|
+
return !!(document.getElementById && document.getElementById(href.slice(1)));
|
|
155
232
|
} catch {
|
|
156
|
-
return
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isStopper(el) {
|
|
238
|
+
if (isLandmark(el)) return true;
|
|
239
|
+
if (isAriaLive(el)) return true;
|
|
240
|
+
const role = getExplicitRoleToken(el);
|
|
241
|
+
if (role && LIVE_REGION_ROLES.has(role)) return true;
|
|
242
|
+
if (isDialogLike(el)) return true;
|
|
243
|
+
if (isButtonLike(el)) return true;
|
|
244
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
245
|
+
if (tag === 'svg' || tag === 'iframe' || tag === 'frame') return true;
|
|
246
|
+
if (isResolvableSkipLink(el)) return true;
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
|
|
251
|
+
|
|
252
|
+
// Non-recursive "does THIS element, on its own, carry content" check —
|
|
253
|
+
// deliberately mirrors only the direct-content half of getContentNameInfo,
|
|
254
|
+
// not a full name-from-content recursion: the whole point is to keep
|
|
255
|
+
// recursing through plain wrapper elements (a framework's root mount
|
|
256
|
+
// <div> included) until reaching the actual content-bearing node, rather
|
|
257
|
+
// than a coarse ancestor swallowing everything beneath it into one report.
|
|
258
|
+
function hasOwnContent(el) {
|
|
259
|
+
const kids = el.childNodes || [];
|
|
260
|
+
for (let i = 0; i < kids.length; i++) {
|
|
261
|
+
const k = kids[i];
|
|
262
|
+
if (k.nodeType === 3 && normalizeWs(k.nodeValue)) return true;
|
|
263
|
+
}
|
|
264
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
265
|
+
if (VISUAL_CONTENT_TAGS.has(tag)) return true;
|
|
266
|
+
if (
|
|
267
|
+
tag === 'input' &&
|
|
268
|
+
lower(normalizeWs(el.getAttribute && el.getAttribute('type'))) !== 'hidden'
|
|
269
|
+
)
|
|
270
|
+
return true;
|
|
271
|
+
if (normalizeWs(el.getAttribute && el.getAttribute('aria-label'))) return true;
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Defensive guard against pathological pages (mirrors the maxContentNodes
|
|
276
|
+
// pattern in src/core/dom-helpers.js's getContentNameInfo) -- a full-body
|
|
277
|
+
// structural walk visits more nodes than a single element's name
|
|
278
|
+
// computation, hence the larger budget.
|
|
279
|
+
const MAX_VISITED_NODES = 20000;
|
|
280
|
+
let visited = 0;
|
|
281
|
+
let truncated = false;
|
|
282
|
+
|
|
283
|
+
const leaves = [];
|
|
284
|
+
const stopperFlagged = new WeakSet();
|
|
285
|
+
|
|
286
|
+
function markFlaggedUpToBody(el) {
|
|
287
|
+
let cur = el;
|
|
288
|
+
while (cur) {
|
|
289
|
+
if (stopperFlagged.has(cur)) break; // everything above is already marked
|
|
290
|
+
stopperFlagged.add(cur);
|
|
291
|
+
if (cur === body) break;
|
|
292
|
+
cur = cur.parentElement;
|
|
157
293
|
}
|
|
158
294
|
}
|
|
159
295
|
|
|
160
|
-
|
|
161
|
-
|
|
296
|
+
function walk(el) {
|
|
297
|
+
if (truncated || !el || el.nodeType !== 1) return;
|
|
298
|
+
visited += 1;
|
|
299
|
+
if (visited > MAX_VISITED_NODES) {
|
|
300
|
+
truncated = true;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
305
|
+
if (SKIP_TAGS.has(tag)) return;
|
|
162
306
|
|
|
163
|
-
|
|
164
|
-
if (!
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (isLandmark(el)) continue;
|
|
307
|
+
const eligRes = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el) : { eligible: true };
|
|
308
|
+
if (!(eligRes && eligRes.eligible)) {
|
|
309
|
+
markFlaggedUpToBody(el);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
169
312
|
|
|
170
|
-
|
|
171
|
-
|
|
313
|
+
if (isStopper(el)) {
|
|
314
|
+
markFlaggedUpToBody(el);
|
|
315
|
+
if (tag === 'iframe' || tag === 'frame') leaves.push(el);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
172
318
|
|
|
173
|
-
|
|
319
|
+
if (hasOwnContent(el)) {
|
|
320
|
+
leaves.push(el);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
174
323
|
|
|
324
|
+
const kids = el.children || [];
|
|
325
|
+
for (let i = 0; i < kids.length; i++) {
|
|
326
|
+
walk(kids[i]);
|
|
327
|
+
if (truncated) return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
for (const child of body.children || []) walk(child);
|
|
332
|
+
|
|
333
|
+
// Collapse each candidate leaf upward through parents that have no OTHER
|
|
334
|
+
// stopper anywhere in their subtree, so contiguous unplaced content
|
|
335
|
+
// merges into ONE occurrence per real gap instead of one per text node --
|
|
336
|
+
// this collapsing is what keeps ordinary pages (landmarked content mixed
|
|
337
|
+
// with a little stray content) from producing noisy, one-per-leaf reports.
|
|
338
|
+
const collapsed = [];
|
|
339
|
+
const seen = new Set();
|
|
340
|
+
for (const leaf of leaves) {
|
|
341
|
+
let cur = leaf;
|
|
342
|
+
while (
|
|
343
|
+
cur.parentElement &&
|
|
344
|
+
cur.parentElement !== body &&
|
|
345
|
+
!stopperFlagged.has(cur.parentElement)
|
|
346
|
+
) {
|
|
347
|
+
cur = cur.parentElement;
|
|
348
|
+
}
|
|
349
|
+
if (!seen.has(cur)) {
|
|
350
|
+
seen.add(cur);
|
|
351
|
+
collapsed.push(cur);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const occurrences = collapsed.map((el) => {
|
|
356
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
175
357
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
176
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
358
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
177
359
|
|
|
178
|
-
|
|
360
|
+
return {
|
|
179
361
|
selector: stableSelector,
|
|
180
362
|
html,
|
|
181
363
|
summary: 'This content is not contained within a landmark region.',
|
|
@@ -188,16 +370,18 @@ function runInPage(ctx) {
|
|
|
188
370
|
data: {
|
|
189
371
|
details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
|
|
190
372
|
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
373
|
+
};
|
|
374
|
+
});
|
|
193
375
|
|
|
194
|
-
if (
|
|
376
|
+
if (occurrences.length === 0) {
|
|
195
377
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
196
378
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
379
|
+
return {
|
|
380
|
+
ruleId: rule.ruleId,
|
|
381
|
+
outcome: 'cantTell',
|
|
382
|
+
severity: rule.defaultSeverity || 'minor',
|
|
383
|
+
occurrences
|
|
384
|
+
};
|
|
201
385
|
}
|
|
202
386
|
|
|
203
387
|
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check scope-attr-valid
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The scope attribute must have a valid value
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to elements with a non-empty scope attribute.
|
|
10
12
|
* @expectation
|
|
@@ -43,7 +45,9 @@ function runInPage(ctx) {
|
|
|
43
45
|
|
|
44
46
|
const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
|
|
45
47
|
|
|
46
|
-
const nodes = helpers.queryAllSmart
|
|
48
|
+
const nodes = helpers.queryAllSmart
|
|
49
|
+
? helpers.queryAllSmart('[scope]')
|
|
50
|
+
: helpers.queryAll('[scope]');
|
|
47
51
|
|
|
48
52
|
const occurrences = [];
|
|
49
53
|
let applicableCount = 0;
|
|
@@ -58,7 +62,7 @@ function runInPage(ctx) {
|
|
|
58
62
|
if (VALID_SCOPES.has(raw.toLowerCase())) continue;
|
|
59
63
|
|
|
60
64
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
61
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
65
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
62
66
|
|
|
63
67
|
occurrences.push({
|
|
64
68
|
selector: stableSelector,
|
|
@@ -80,7 +84,12 @@ function runInPage(ctx) {
|
|
|
80
84
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
81
85
|
}
|
|
82
86
|
if (occurrences.length) {
|
|
83
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
ruleId: rule.ruleId,
|
|
89
|
+
outcome: 'cantTell',
|
|
90
|
+
severity: rule.defaultSeverity || 'minor',
|
|
91
|
+
occurrences
|
|
92
|
+
};
|
|
84
93
|
}
|
|
85
94
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
86
95
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -48,8 +50,20 @@ const meta = {
|
|
|
48
50
|
tags: ['wcag2a', 'wcag2aaa', 'wcag211', 'wcag213', 'structure', 'atomic', 'manual'],
|
|
49
51
|
wcagSc: ['2.1.1', '2.1.3'],
|
|
50
52
|
normativeMappings: [
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
+
{
|
|
54
|
+
standard: 'WCAG',
|
|
55
|
+
version: '2.2',
|
|
56
|
+
requirement: '2.1.1',
|
|
57
|
+
title: 'Keyboard',
|
|
58
|
+
conformanceLevel: 'A'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
standard: 'WCAG',
|
|
62
|
+
version: '2.2',
|
|
63
|
+
requirement: '2.1.3',
|
|
64
|
+
title: 'Keyboard (No Exception)',
|
|
65
|
+
conformanceLevel: 'AAA'
|
|
66
|
+
}
|
|
53
67
|
],
|
|
54
68
|
defaultSeverity: 'moderate',
|
|
55
69
|
category: 'operable',
|
|
@@ -64,7 +78,7 @@ const meta = {
|
|
|
64
78
|
};
|
|
65
79
|
|
|
66
80
|
function runInPage(ctx) {
|
|
67
|
-
const {
|
|
81
|
+
const { helpers, rule } = ctx;
|
|
68
82
|
|
|
69
83
|
function safeComputedStyle(el) {
|
|
70
84
|
try {
|
|
@@ -73,7 +87,8 @@ function runInPage(ctx) {
|
|
|
73
87
|
const cs = helpers.computedStyle(el);
|
|
74
88
|
if (cs) return cs;
|
|
75
89
|
}
|
|
76
|
-
const view =
|
|
90
|
+
const view =
|
|
91
|
+
el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : null;
|
|
77
92
|
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
|
|
78
93
|
} catch {}
|
|
79
94
|
return null;
|
|
@@ -81,8 +96,12 @@ function runInPage(ctx) {
|
|
|
81
96
|
|
|
82
97
|
function isScrollableOverflow(cs) {
|
|
83
98
|
if (!cs) return false;
|
|
84
|
-
const x = String(cs.overflowX || '')
|
|
85
|
-
|
|
99
|
+
const x = String(cs.overflowX || '')
|
|
100
|
+
.trim()
|
|
101
|
+
.toLowerCase();
|
|
102
|
+
const y = String(cs.overflowY || '')
|
|
103
|
+
.trim()
|
|
104
|
+
.toLowerCase();
|
|
86
105
|
return x === 'auto' || x === 'scroll' || y === 'auto' || y === 'scroll';
|
|
87
106
|
}
|
|
88
107
|
|
|
@@ -108,8 +127,11 @@ function runInPage(ctx) {
|
|
|
108
127
|
}
|
|
109
128
|
}
|
|
110
129
|
|
|
111
|
-
const CANDIDATE_SELECTOR =
|
|
112
|
-
|
|
130
|
+
const CANDIDATE_SELECTOR =
|
|
131
|
+
'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
|
|
132
|
+
const nodes = helpers.queryAllSmart
|
|
133
|
+
? helpers.queryAllSmart(CANDIDATE_SELECTOR)
|
|
134
|
+
: helpers.queryAll(CANDIDATE_SELECTOR);
|
|
113
135
|
|
|
114
136
|
const occurrences = [];
|
|
115
137
|
let applicableCount = 0;
|
|
@@ -127,12 +149,13 @@ function runInPage(ctx) {
|
|
|
127
149
|
|
|
128
150
|
const tag = (el.tagName || '').toLowerCase();
|
|
129
151
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
130
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
152
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
131
153
|
|
|
132
154
|
const baseOccurrence = {
|
|
133
155
|
selector: stableSelector,
|
|
134
156
|
html,
|
|
135
|
-
summary:
|
|
157
|
+
summary:
|
|
158
|
+
'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
|
|
136
159
|
hint: 'If this region’s content actually overflows, add tabindex="0" (and a suitable label) so keyboard users can focus it and scroll with the arrow keys.',
|
|
137
160
|
i18n: {
|
|
138
161
|
summaryKey: 'scrollableRegionFocusable_summary_cantTell',
|
|
@@ -156,7 +179,12 @@ function runInPage(ctx) {
|
|
|
156
179
|
}
|
|
157
180
|
|
|
158
181
|
if (occurrences.length) {
|
|
159
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
ruleId: rule.ruleId,
|
|
184
|
+
outcome: 'cantTell',
|
|
185
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
186
|
+
occurrences
|
|
187
|
+
};
|
|
160
188
|
}
|
|
161
189
|
|
|
162
190
|
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|