@surea11y/core 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +50 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +125 -29
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +50 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +39 -9
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +117 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +26 -7
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -12
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +25 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +11 -9
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* @summary Page content should be contained within a landmark region
|
|
7
7
|
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
8
|
* @applicability
|
|
9
|
-
* Applies to
|
|
10
|
-
* and
|
|
9
|
+
* Applies to any element under <body> that directly carries visible text
|
|
10
|
+
* (or other own content — see @implementation-notes) and is not itself a
|
|
11
|
+
* landmark, live region, dialog, button, <svg>, <iframe>/<frame>, or a
|
|
12
|
+
* resolvable skip-link.
|
|
11
13
|
* @expectation
|
|
12
14
|
* Every top-level piece of page content lives inside a landmark region
|
|
13
15
|
* (main, navigation, banner, contentinfo, complementary, region, form,
|
|
@@ -18,19 +20,62 @@
|
|
|
18
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
21
|
* header comment for the shared rationale/precedent and the landmark-
|
|
20
22
|
* detection model.
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* - Recursive tree walk, not a direct-<body>-children-only scan (that was
|
|
24
|
+
* this check's original scope, replaced 2026-08-01). The direct-children
|
|
25
|
+
* scope was originally chosen to keep this check quiet, but it turned out
|
|
26
|
+
* to be nearly inert on the single most common real-world page shape: a
|
|
27
|
+
* modern framework's single root mount div
|
|
28
|
+
* (`<body><div id="root">...everything...</div></body>`, confirmed
|
|
29
|
+
* present as the ONLY direct <body> child on 37 of ~90 pages in a
|
|
30
|
+
* real-world corpus). On that shape, the old scan had at most one
|
|
31
|
+
* candidate for the entire page and either missed every real gap inside
|
|
32
|
+
* it or collapsed the whole page into one undifferentiated report.
|
|
33
|
+
* - Algorithm, using this engine's own (already more spec-correct in
|
|
34
|
+
* places — see hasLandmarkScopingAncestor's header comment) helpers:
|
|
35
|
+
* 1. Depth-first walk from <body>'s children.
|
|
36
|
+
* 2. At each node: if it's ineligible for the accessibility tree
|
|
37
|
+
* (helpers.isAccTreeEligible), OR is itself a "stopper" (landmark,
|
|
38
|
+
* live region, dialog, button, <svg>, <iframe>/<frame>, or a
|
|
39
|
+
* resolvable skip-link), mark it and every ancestor up to <body> as
|
|
40
|
+
* "has a stopper" and don't recurse further into it (an <iframe>/
|
|
41
|
+
* <frame> is additionally reported as its own occurrence — its
|
|
42
|
+
* content is opaque to this engine, so from the outer page's
|
|
43
|
+
* perspective it IS unplaced content).
|
|
44
|
+
* 3. Otherwise, if the node has OWN content (a direct child text node,
|
|
45
|
+
* being an inherently visual element, or an aria-label) — checked
|
|
46
|
+
* non-recursively, so a plain wrapper <div> with only nested
|
|
47
|
+
* children never short-circuits the walk into its descendants —
|
|
48
|
+
* collect it as a candidate and stop recursing into it.
|
|
49
|
+
* 4. Otherwise recurse into its element children.
|
|
50
|
+
* Each collected candidate is then walked back UP through parents while
|
|
51
|
+
* the parent has no "stopper" marker and isn't <body> itself, collapsing
|
|
52
|
+
* contiguous unplaced content into one occurrence per real gap instead
|
|
53
|
+
* of reporting every individual text-bearing leaf — this is what keeps
|
|
54
|
+
* the walk from being noisy on ordinary pages that mix landmarked and
|
|
55
|
+
* stray content, not the old direct-children-only restriction.
|
|
56
|
+
* - "Stopper" exemptions (button, dialog, <svg>, resolvable skip-links)
|
|
57
|
+
* are a deliberate scope choice, not an oversight: these
|
|
58
|
+
* are extremely common real-world patterns (floating action buttons,
|
|
59
|
+
* modal dialogs, decorative/icon SVGs, "skip to content" links) that
|
|
60
|
+
* aren't the kind of "content organization" gap this rule exists to
|
|
61
|
+
* catch, and flagging them would reintroduce the false-positive noise
|
|
62
|
+
* the original narrow scope was trying to avoid.
|
|
63
|
+
* - The "own content" check for aria-label deliberately does NOT resolve
|
|
64
|
+
* aria-labelledby — a known, narrow scope gap (an element named only via aria-labelledby, with no
|
|
65
|
+
* own text/aria-label, and no other content anywhere in its subtree,
|
|
66
|
+
* could be silently skipped) accepted to avoid a full accessible-name
|
|
67
|
+
* computation (recursive itself) inside an already-recursive structural
|
|
68
|
+
* walk.
|
|
69
|
+
* - Bounded by a node-visit budget (see MAX_VISITED_NODES) as a defensive
|
|
70
|
+
* guard against pathological pages, matching the existing pattern used
|
|
71
|
+
* by getContentNameInfo's maxContentNodes in src/core/dom-helpers.js.
|
|
27
72
|
*/
|
|
28
73
|
|
|
29
74
|
const id = 'region';
|
|
30
75
|
|
|
31
76
|
const meta = {
|
|
32
77
|
title: 'Page content should be inside a landmark region',
|
|
33
|
-
description: 'Checks that
|
|
78
|
+
description: 'Checks that content under <body> is contained within a landmark region.',
|
|
34
79
|
i18n: {
|
|
35
80
|
titleKey: 'region_title',
|
|
36
81
|
descriptionKey: 'region_description'
|
|
@@ -46,6 +91,14 @@ const meta = {
|
|
|
46
91
|
coverage: {}
|
|
47
92
|
};
|
|
48
93
|
|
|
94
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
95
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
96
|
+
// scoped this run narrower than the whole document, or when
|
|
97
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
98
|
+
function applicability(ctx) {
|
|
99
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
100
|
+
}
|
|
101
|
+
|
|
49
102
|
function runInPage(ctx) {
|
|
50
103
|
const { document, helpers, rule } = ctx;
|
|
51
104
|
|
|
@@ -55,7 +108,13 @@ function runInPage(ctx) {
|
|
|
55
108
|
}
|
|
56
109
|
|
|
57
110
|
function normalizeWs(s) {
|
|
58
|
-
return String(s || '')
|
|
111
|
+
return String(s || '')
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lower(s) {
|
|
117
|
+
return String(s || '').toLowerCase();
|
|
59
118
|
}
|
|
60
119
|
|
|
61
120
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
@@ -76,34 +135,55 @@ function runInPage(ctx) {
|
|
|
76
135
|
function getExplicitRoleToken(el) {
|
|
77
136
|
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
78
137
|
if (!raw) return '';
|
|
79
|
-
return raw.split(/\s+/)[0]
|
|
138
|
+
return lower(raw.split(/\s+/)[0]);
|
|
80
139
|
}
|
|
81
140
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
141
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
142
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
143
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
144
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
145
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
146
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
147
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
148
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
149
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
150
|
+
// rule's own former tag-only copy as a false negative.
|
|
151
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
152
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
153
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
154
|
+
: false;
|
|
92
155
|
}
|
|
93
156
|
|
|
94
157
|
function getImplicitLandmarkRole(el) {
|
|
95
|
-
const tag = el.tagName ? el.tagName
|
|
96
|
-
if (tag === 'header') return
|
|
97
|
-
if (tag === 'footer') return
|
|
158
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
159
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
160
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
98
161
|
if (tag === 'main') return 'main';
|
|
99
162
|
if (tag === 'nav') return 'navigation';
|
|
100
|
-
if (tag === 'aside')
|
|
163
|
+
if (tag === 'aside') {
|
|
164
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
165
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
166
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
167
|
+
// "complementary" when the element has an accessible name, even
|
|
168
|
+
// inside sectioning content); propagated here for consistency.
|
|
169
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
170
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
171
|
+
}
|
|
101
172
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
102
173
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
103
174
|
return '';
|
|
104
175
|
}
|
|
105
176
|
|
|
106
|
-
const LANDMARK_ROLES = new Set([
|
|
177
|
+
const LANDMARK_ROLES = new Set([
|
|
178
|
+
'banner',
|
|
179
|
+
'contentinfo',
|
|
180
|
+
'main',
|
|
181
|
+
'navigation',
|
|
182
|
+
'complementary',
|
|
183
|
+
'region',
|
|
184
|
+
'form',
|
|
185
|
+
'search'
|
|
186
|
+
]);
|
|
107
187
|
|
|
108
188
|
function isLandmark(el) {
|
|
109
189
|
if (!el || !el.getAttribute) return false;
|
|
@@ -114,48 +194,179 @@ function runInPage(ctx) {
|
|
|
114
194
|
|
|
115
195
|
const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'link', 'meta', 'title']);
|
|
116
196
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
197
|
+
// Roles/attributes that make an element its own self-contained
|
|
198
|
+
// announced area — not literally a WAI-ARIA landmark, but not "content
|
|
199
|
+
// that needs a landmark" either. Same live-region role list a widely-used
|
|
200
|
+
// reference engine's own region rule stops recursion at.
|
|
201
|
+
const LIVE_REGION_ROLES = new Set(['alert', 'status', 'log', 'marquee', 'timer']);
|
|
202
|
+
|
|
203
|
+
function isAriaLive(el) {
|
|
204
|
+
const v = lower(normalizeWs(el.getAttribute && el.getAttribute('aria-live')));
|
|
205
|
+
return v === 'polite' || v === 'assertive';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isDialogLike(el) {
|
|
209
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
210
|
+
if (tag === 'dialog') return true;
|
|
211
|
+
const role = getExplicitRoleToken(el);
|
|
212
|
+
return role === 'dialog' || role === 'alertdialog';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isButtonLike(el) {
|
|
216
|
+
const role = getExplicitRoleToken(el);
|
|
217
|
+
if (role) return role === 'button';
|
|
218
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
219
|
+
if (tag === 'button' || tag === 'summary') return true;
|
|
220
|
+
if (tag === 'input') {
|
|
221
|
+
const type = lower(normalizeWs(el.getAttribute && el.getAttribute('type')));
|
|
222
|
+
return type === 'button' || type === 'submit' || type === 'reset' || type === 'image';
|
|
223
|
+
}
|
|
120
224
|
return false;
|
|
121
225
|
}
|
|
122
226
|
|
|
123
|
-
|
|
124
|
-
|
|
227
|
+
// A "skip to content" link is deliberately placed outside the main
|
|
228
|
+
// content flow at the very top of the page — exempting it (when its
|
|
229
|
+
// fragment actually resolves to a real target, not a dead "#"
|
|
230
|
+
// placeholder) avoids flagging a helpful, common accessibility pattern
|
|
231
|
+
// as the very thing this rule is meant to catch.
|
|
232
|
+
function isResolvableSkipLink(el) {
|
|
233
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
234
|
+
if (tag !== 'a') return false;
|
|
235
|
+
const href = el.getAttribute && el.getAttribute('href');
|
|
236
|
+
if (!href || href.charAt(0) !== '#' || href.length < 2) return false;
|
|
125
237
|
try {
|
|
126
|
-
|
|
127
|
-
const parts = [];
|
|
128
|
-
let n = walker.nextNode();
|
|
129
|
-
while (n) {
|
|
130
|
-
const raw = normalizeWs(n.nodeValue || '');
|
|
131
|
-
if (raw) parts.push(raw);
|
|
132
|
-
n = walker.nextNode();
|
|
133
|
-
}
|
|
134
|
-
return normalizeWs(parts.join(' '));
|
|
238
|
+
return !!(document.getElementById && document.getElementById(href.slice(1)));
|
|
135
239
|
} catch {
|
|
136
|
-
return
|
|
240
|
+
return false;
|
|
137
241
|
}
|
|
138
242
|
}
|
|
139
243
|
|
|
140
|
-
|
|
141
|
-
|
|
244
|
+
function isStopper(el) {
|
|
245
|
+
if (isLandmark(el)) return true;
|
|
246
|
+
if (isAriaLive(el)) return true;
|
|
247
|
+
const role = getExplicitRoleToken(el);
|
|
248
|
+
if (role && LIVE_REGION_ROLES.has(role)) return true;
|
|
249
|
+
if (isDialogLike(el)) return true;
|
|
250
|
+
if (isButtonLike(el)) return true;
|
|
251
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
252
|
+
if (tag === 'svg' || tag === 'iframe' || tag === 'frame') return true;
|
|
253
|
+
if (isResolvableSkipLink(el)) return true;
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
142
256
|
|
|
143
|
-
|
|
144
|
-
if (!el || !el.tagName) continue;
|
|
145
|
-
const tag = el.tagName.toLowerCase();
|
|
146
|
-
if (SKIP_TAGS.has(tag)) continue;
|
|
147
|
-
if (isIneligible(el)) continue;
|
|
148
|
-
if (isLandmark(el)) continue;
|
|
257
|
+
const VISUAL_CONTENT_TAGS = new Set(['img', 'video', 'audio', 'canvas', 'object', 'embed']);
|
|
149
258
|
|
|
150
|
-
|
|
151
|
-
|
|
259
|
+
// Non-recursive "does THIS element, on its own, carry content" check —
|
|
260
|
+
// deliberately mirrors only the direct-content half of getContentNameInfo/
|
|
261
|
+
// a widely-used reference engine's own has-content check, not a full
|
|
262
|
+
// name-from-content recursion: the whole point is to keep recursing
|
|
263
|
+
// through plain wrapper elements (a framework's root mount <div> included)
|
|
264
|
+
// until reaching the actual content-bearing node, rather than a coarse
|
|
265
|
+
// ancestor swallowing everything beneath it into one report.
|
|
266
|
+
function hasOwnContent(el) {
|
|
267
|
+
const kids = el.childNodes || [];
|
|
268
|
+
for (let i = 0; i < kids.length; i++) {
|
|
269
|
+
const k = kids[i];
|
|
270
|
+
if (k.nodeType === 3 && normalizeWs(k.nodeValue)) return true;
|
|
271
|
+
}
|
|
272
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
273
|
+
if (VISUAL_CONTENT_TAGS.has(tag)) return true;
|
|
274
|
+
if (
|
|
275
|
+
tag === 'input' &&
|
|
276
|
+
lower(normalizeWs(el.getAttribute && el.getAttribute('type'))) !== 'hidden'
|
|
277
|
+
)
|
|
278
|
+
return true;
|
|
279
|
+
if (normalizeWs(el.getAttribute && el.getAttribute('aria-label'))) return true;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
152
282
|
|
|
153
|
-
|
|
283
|
+
// Defensive guard against pathological pages (mirrors the maxContentNodes
|
|
284
|
+
// pattern in src/core/dom-helpers.js's getContentNameInfo) -- a full-body
|
|
285
|
+
// structural walk visits more nodes than a single element's name
|
|
286
|
+
// computation, hence the larger budget.
|
|
287
|
+
const MAX_VISITED_NODES = 20000;
|
|
288
|
+
let visited = 0;
|
|
289
|
+
let truncated = false;
|
|
154
290
|
|
|
291
|
+
const leaves = [];
|
|
292
|
+
const stopperFlagged = new WeakSet();
|
|
293
|
+
|
|
294
|
+
function markFlaggedUpToBody(el) {
|
|
295
|
+
let cur = el;
|
|
296
|
+
while (cur) {
|
|
297
|
+
if (stopperFlagged.has(cur)) break; // everything above is already marked
|
|
298
|
+
stopperFlagged.add(cur);
|
|
299
|
+
if (cur === body) break;
|
|
300
|
+
cur = cur.parentElement;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function walk(el) {
|
|
305
|
+
if (truncated || !el || el.nodeType !== 1) return;
|
|
306
|
+
visited += 1;
|
|
307
|
+
if (visited > MAX_VISITED_NODES) {
|
|
308
|
+
truncated = true;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
313
|
+
if (SKIP_TAGS.has(tag)) return;
|
|
314
|
+
|
|
315
|
+
const eligRes = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el) : { eligible: true };
|
|
316
|
+
if (!(eligRes && eligRes.eligible)) {
|
|
317
|
+
markFlaggedUpToBody(el);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (isStopper(el)) {
|
|
322
|
+
markFlaggedUpToBody(el);
|
|
323
|
+
if (tag === 'iframe' || tag === 'frame') leaves.push(el);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (hasOwnContent(el)) {
|
|
328
|
+
leaves.push(el);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const kids = el.children || [];
|
|
333
|
+
for (let i = 0; i < kids.length; i++) {
|
|
334
|
+
walk(kids[i]);
|
|
335
|
+
if (truncated) return;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
for (const child of body.children || []) walk(child);
|
|
340
|
+
|
|
341
|
+
// Collapse each candidate leaf upward through parents that have no OTHER
|
|
342
|
+
// stopper anywhere in their subtree, so contiguous unplaced content
|
|
343
|
+
// merges into ONE occurrence per real gap instead of one per text node --
|
|
344
|
+
// this collapsing, not the old direct-children-only scope, is what keeps
|
|
345
|
+
// ordinary pages (landmarked content mixed with a little stray content)
|
|
346
|
+
// from producing noisy, one-per-leaf reports.
|
|
347
|
+
const collapsed = [];
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
for (const leaf of leaves) {
|
|
350
|
+
let cur = leaf;
|
|
351
|
+
while (
|
|
352
|
+
cur.parentElement &&
|
|
353
|
+
cur.parentElement !== body &&
|
|
354
|
+
!stopperFlagged.has(cur.parentElement)
|
|
355
|
+
) {
|
|
356
|
+
cur = cur.parentElement;
|
|
357
|
+
}
|
|
358
|
+
if (!seen.has(cur)) {
|
|
359
|
+
seen.add(cur);
|
|
360
|
+
collapsed.push(cur);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const occurrences = collapsed.map((el) => {
|
|
365
|
+
const tag = el.tagName ? lower(el.tagName) : '';
|
|
155
366
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
156
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
367
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
157
368
|
|
|
158
|
-
|
|
369
|
+
return {
|
|
159
370
|
selector: stableSelector,
|
|
160
371
|
html,
|
|
161
372
|
summary: 'This content is not contained within a landmark region.',
|
|
@@ -168,16 +379,18 @@ function runInPage(ctx) {
|
|
|
168
379
|
data: {
|
|
169
380
|
details: { reasonCode: 'CONTENT_OUTSIDE_LANDMARK', element: tag }
|
|
170
381
|
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
382
|
+
};
|
|
383
|
+
});
|
|
173
384
|
|
|
174
|
-
if (
|
|
385
|
+
if (occurrences.length === 0) {
|
|
175
386
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
176
387
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
388
|
+
return {
|
|
389
|
+
ruleId: rule.ruleId,
|
|
390
|
+
outcome: 'cantTell',
|
|
391
|
+
severity: rule.defaultSeverity || 'minor',
|
|
392
|
+
occurrences
|
|
393
|
+
};
|
|
181
394
|
}
|
|
182
395
|
|
|
183
|
-
module.exports = { id, meta, runInPage };
|
|
396
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -39,16 +39,13 @@ const meta = {
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
function runInPage(ctx) {
|
|
42
|
-
const {
|
|
42
|
+
const { helpers, rule } = ctx;
|
|
43
43
|
|
|
44
44
|
const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} catch {
|
|
50
|
-
nodes = [];
|
|
51
|
-
}
|
|
46
|
+
const nodes = helpers.queryAllSmart
|
|
47
|
+
? helpers.queryAllSmart('[scope]')
|
|
48
|
+
: helpers.queryAll('[scope]');
|
|
52
49
|
|
|
53
50
|
const occurrences = [];
|
|
54
51
|
let applicableCount = 0;
|
|
@@ -63,7 +60,7 @@ function runInPage(ctx) {
|
|
|
63
60
|
if (VALID_SCOPES.has(raw.toLowerCase())) continue;
|
|
64
61
|
|
|
65
62
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
66
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
63
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
67
64
|
|
|
68
65
|
occurrences.push({
|
|
69
66
|
selector: stableSelector,
|
|
@@ -85,7 +82,12 @@ function runInPage(ctx) {
|
|
|
85
82
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
86
83
|
}
|
|
87
84
|
if (occurrences.length) {
|
|
88
|
-
return {
|
|
85
|
+
return {
|
|
86
|
+
ruleId: rule.ruleId,
|
|
87
|
+
outcome: 'cantTell',
|
|
88
|
+
severity: rule.defaultSeverity || 'minor',
|
|
89
|
+
occurrences
|
|
90
|
+
};
|
|
89
91
|
}
|
|
90
92
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
91
93
|
}
|
|
@@ -48,8 +48,20 @@ const meta = {
|
|
|
48
48
|
tags: ['wcag2a', 'wcag2aaa', 'wcag211', 'wcag213', 'structure', 'atomic', 'manual'],
|
|
49
49
|
wcagSc: ['2.1.1', '2.1.3'],
|
|
50
50
|
normativeMappings: [
|
|
51
|
-
{
|
|
52
|
-
|
|
51
|
+
{
|
|
52
|
+
standard: 'WCAG',
|
|
53
|
+
version: '2.2',
|
|
54
|
+
requirement: '2.1.1',
|
|
55
|
+
title: 'Keyboard',
|
|
56
|
+
conformanceLevel: 'A'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
standard: 'WCAG',
|
|
60
|
+
version: '2.2',
|
|
61
|
+
requirement: '2.1.3',
|
|
62
|
+
title: 'Keyboard (No Exception)',
|
|
63
|
+
conformanceLevel: 'AAA'
|
|
64
|
+
}
|
|
53
65
|
],
|
|
54
66
|
defaultSeverity: 'moderate',
|
|
55
67
|
category: 'operable',
|
|
@@ -64,7 +76,7 @@ const meta = {
|
|
|
64
76
|
};
|
|
65
77
|
|
|
66
78
|
function runInPage(ctx) {
|
|
67
|
-
const {
|
|
79
|
+
const { helpers, rule } = ctx;
|
|
68
80
|
|
|
69
81
|
function safeComputedStyle(el) {
|
|
70
82
|
try {
|
|
@@ -73,7 +85,8 @@ function runInPage(ctx) {
|
|
|
73
85
|
const cs = helpers.computedStyle(el);
|
|
74
86
|
if (cs) return cs;
|
|
75
87
|
}
|
|
76
|
-
const view =
|
|
88
|
+
const view =
|
|
89
|
+
el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : null;
|
|
77
90
|
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
|
|
78
91
|
} catch {}
|
|
79
92
|
return null;
|
|
@@ -81,8 +94,12 @@ function runInPage(ctx) {
|
|
|
81
94
|
|
|
82
95
|
function isScrollableOverflow(cs) {
|
|
83
96
|
if (!cs) return false;
|
|
84
|
-
const x = String(cs.overflowX || '')
|
|
85
|
-
|
|
97
|
+
const x = String(cs.overflowX || '')
|
|
98
|
+
.trim()
|
|
99
|
+
.toLowerCase();
|
|
100
|
+
const y = String(cs.overflowY || '')
|
|
101
|
+
.trim()
|
|
102
|
+
.toLowerCase();
|
|
86
103
|
return x === 'auto' || x === 'scroll' || y === 'auto' || y === 'scroll';
|
|
87
104
|
}
|
|
88
105
|
|
|
@@ -108,8 +125,11 @@ function runInPage(ctx) {
|
|
|
108
125
|
}
|
|
109
126
|
}
|
|
110
127
|
|
|
111
|
-
const CANDIDATE_SELECTOR =
|
|
112
|
-
|
|
128
|
+
const CANDIDATE_SELECTOR =
|
|
129
|
+
'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
|
|
130
|
+
const nodes = helpers.queryAllSmart
|
|
131
|
+
? helpers.queryAllSmart(CANDIDATE_SELECTOR)
|
|
132
|
+
: helpers.queryAll(CANDIDATE_SELECTOR);
|
|
113
133
|
|
|
114
134
|
const occurrences = [];
|
|
115
135
|
let applicableCount = 0;
|
|
@@ -127,12 +147,13 @@ function runInPage(ctx) {
|
|
|
127
147
|
|
|
128
148
|
const tag = (el.tagName || '').toLowerCase();
|
|
129
149
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
130
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
150
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
131
151
|
|
|
132
152
|
const baseOccurrence = {
|
|
133
153
|
selector: stableSelector,
|
|
134
154
|
html,
|
|
135
|
-
summary:
|
|
155
|
+
summary:
|
|
156
|
+
'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
|
|
136
157
|
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
158
|
i18n: {
|
|
138
159
|
summaryKey: 'scrollableRegionFocusable_summary_cantTell',
|
|
@@ -156,7 +177,12 @@ function runInPage(ctx) {
|
|
|
156
177
|
}
|
|
157
178
|
|
|
158
179
|
if (occurrences.length) {
|
|
159
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
ruleId: rule.ruleId,
|
|
182
|
+
outcome: 'cantTell',
|
|
183
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
184
|
+
occurrences
|
|
185
|
+
};
|
|
160
186
|
}
|
|
161
187
|
|
|
162
188
|
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|
|
@@ -37,7 +37,8 @@ const id = 'skip-link';
|
|
|
37
37
|
|
|
38
38
|
const meta = {
|
|
39
39
|
title: 'Skip link must have a resolvable, usable target',
|
|
40
|
-
description:
|
|
40
|
+
description:
|
|
41
|
+
'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
|
|
41
42
|
i18n: {
|
|
42
43
|
titleKey: 'skipLink_title',
|
|
43
44
|
descriptionKey: 'skipLink_description'
|
|
@@ -57,7 +58,9 @@ function runInPage(ctx) {
|
|
|
57
58
|
const { document, helpers, rule } = ctx;
|
|
58
59
|
|
|
59
60
|
function normalizeWs(s) {
|
|
60
|
-
return String(s || '')
|
|
61
|
+
return String(s || '')
|
|
62
|
+
.replace(/\s+/g, ' ')
|
|
63
|
+
.trim();
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
function getAccessibleNameText(el) {
|
|
@@ -105,7 +108,9 @@ function runInPage(ctx) {
|
|
|
105
108
|
|
|
106
109
|
const geometrySupported = hasReliableGeometrySupport();
|
|
107
110
|
|
|
108
|
-
const nodes = helpers.queryAllSmart
|
|
111
|
+
const nodes = helpers.queryAllSmart
|
|
112
|
+
? helpers.queryAllSmart('a[href]')
|
|
113
|
+
: helpers.queryAll('a[href]');
|
|
109
114
|
|
|
110
115
|
const occurrences = [];
|
|
111
116
|
let applicableCount = 0;
|
|
@@ -147,18 +152,29 @@ function runInPage(ctx) {
|
|
|
147
152
|
const accEligibility = toEligibility(
|
|
148
153
|
helpers.getEligibilityInfo
|
|
149
154
|
? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
|
|
150
|
-
:
|
|
155
|
+
: helpers.isAccTreeEligible
|
|
156
|
+
? helpers.isAccTreeEligible(target, ctx)
|
|
157
|
+
: { eligible: true, reasons: [] }
|
|
151
158
|
);
|
|
152
159
|
|
|
153
160
|
let geometryEligibility = null;
|
|
154
161
|
let geometryReasonCode = null;
|
|
155
162
|
if (geometrySupported && helpers.isDomVisibleEligible) {
|
|
156
163
|
geometryEligibility = toEligibility(
|
|
157
|
-
helpers.isDomVisibleEligible(target, ctx, {
|
|
164
|
+
helpers.isDomVisibleEligible(target, ctx, {
|
|
165
|
+
visibilityMode: 'styleAndGeometry',
|
|
166
|
+
ignoreOpacity: true
|
|
167
|
+
})
|
|
158
168
|
);
|
|
159
|
-
if (
|
|
169
|
+
if (
|
|
170
|
+
!geometryEligibility.eligible &&
|
|
171
|
+
geometryEligibility.reasons.includes('noClientRects')
|
|
172
|
+
) {
|
|
160
173
|
geometryReasonCode = 'NO_CLIENT_RECTS';
|
|
161
|
-
} else if (
|
|
174
|
+
} else if (
|
|
175
|
+
!geometryEligibility.eligible &&
|
|
176
|
+
geometryEligibility.reasons.includes('zeroArea')
|
|
177
|
+
) {
|
|
162
178
|
geometryReasonCode = 'ZERO_AREA_TARGET';
|
|
163
179
|
}
|
|
164
180
|
}
|
|
@@ -168,7 +184,9 @@ function runInPage(ctx) {
|
|
|
168
184
|
if (!unusableByAcc && !unusableByGeometry) continue;
|
|
169
185
|
|
|
170
186
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
171
|
-
const html = helpers.getOuterHtmlSnippet
|
|
187
|
+
const html = helpers.getOuterHtmlSnippet
|
|
188
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
189
|
+
: el.outerHTML || '';
|
|
172
190
|
|
|
173
191
|
occurrences.push({
|
|
174
192
|
selector: stableSelector,
|
|
@@ -202,13 +220,13 @@ function runInPage(ctx) {
|
|
|
202
220
|
}
|
|
203
221
|
|
|
204
222
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
205
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
223
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
206
224
|
|
|
207
225
|
occurrences.push({
|
|
208
226
|
selector: stableSelector,
|
|
209
227
|
html,
|
|
210
|
-
summary:
|
|
211
|
-
hint:
|
|
228
|
+
summary: "This skip link's target does not exist.",
|
|
229
|
+
hint: "Point the skip link's href at an id that exists in the document, or add the missing target element.",
|
|
212
230
|
i18n: {
|
|
213
231
|
summaryKey: 'skipLink_summary_cantTell',
|
|
214
232
|
hintKey: 'skipLink_hint_cantTell',
|
|
@@ -224,7 +242,12 @@ function runInPage(ctx) {
|
|
|
224
242
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
225
243
|
}
|
|
226
244
|
if (occurrences.length) {
|
|
227
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
ruleId: rule.ruleId,
|
|
247
|
+
outcome: 'cantTell',
|
|
248
|
+
severity: rule.defaultSeverity || 'minor',
|
|
249
|
+
occurrences
|
|
250
|
+
};
|
|
228
251
|
}
|
|
229
252
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
230
253
|
}
|