@surea11y/core 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/CI_INTEGRATIONS.md +7 -7
- package/docs/ENGINE_OPTIONS.md +1 -1
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +52 -16
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +4 -6
- package/src/checks/automatic/aria-allowed-attr.js +663 -134
- package/src/checks/automatic/aria-allowed-role.js +2 -0
- package/src/checks/automatic/aria-braille-equivalent.js +2 -0
- package/src/checks/automatic/aria-conditional-attr.js +8 -7
- package/src/checks/automatic/aria-deprecated-role.js +107 -38
- package/src/checks/automatic/aria-hidden-body.js +6 -4
- package/src/checks/automatic/aria-hidden-focus.js +12 -13
- package/src/checks/automatic/aria-prohibited-attr.js +98 -105
- package/src/checks/automatic/aria-prohibited-children.js +56 -87
- package/src/checks/automatic/aria-required-attr.js +6 -7
- package/src/checks/automatic/aria-required-children.js +7 -10
- package/src/checks/automatic/aria-required-parent.js +20 -25
- package/src/checks/automatic/aria-role-name-present.js +2 -0
- package/src/checks/automatic/aria-roles-valid.js +33 -6
- package/src/checks/automatic/aria-valid-attr-value.js +18 -15
- package/src/checks/automatic/aria-valid-attr.js +2 -0
- package/src/checks/automatic/autocomplete-valid.js +39 -1
- package/src/checks/automatic/avoid-inline-spacing.js +181 -20
- package/src/checks/automatic/binary-control-name-present.js +13 -3
- package/src/checks/automatic/button-name-present.js +54 -21
- package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
- package/src/checks/automatic/combobox-name-present.js +10 -1
- package/src/checks/automatic/contrast-computable.js +2 -0
- package/src/checks/automatic/contrast-enhanced.js +2 -0
- package/src/checks/automatic/contrast-minimum.js +2 -0
- package/src/checks/automatic/css-orientation-lock.js +21 -27
- package/src/checks/automatic/definition-list-children-valid.js +6 -6
- package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
- package/src/checks/automatic/dialog-name-present.js +18 -11
- package/src/checks/automatic/dlitem-parent-valid.js +2 -0
- package/src/checks/automatic/duplicate-id-aria.js +4 -3
- package/src/checks/automatic/embed-text-alternative-present.js +2 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
- package/src/checks/automatic/form-control-single-label.js +110 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
- package/src/checks/automatic/iframe-focusable-content.js +246 -18
- package/src/checks/automatic/iframe-name-present.js +2 -0
- package/src/checks/automatic/iframe-title-unique.js +3 -1
- package/src/checks/automatic/img-alt-present.js +23 -18
- package/src/checks/automatic/input-image-alt-present.js +101 -52
- package/src/checks/automatic/label-in-name.js +97 -27
- package/src/checks/automatic/language-page-present.js +7 -1
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +52 -17
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +10 -1
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +10 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
- package/src/checks/automatic/meter-name-present.js +12 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
- package/src/checks/automatic/object-text-alternative-present.js +16 -7
- package/src/checks/automatic/option-name-present.js +10 -1
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +16 -11
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +10 -1
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +13 -2
- package/src/checks/automatic/spinbutton-name-present.js +10 -1
- package/src/checks/automatic/summary-name-present.js +10 -1
- package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
- package/src/checks/automatic/svg-text-alternative-present.js +17 -5
- package/src/checks/automatic/tab-name-present.js +10 -1
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +69 -6
- package/src/checks/automatic/target-size-minimum.js +5 -0
- package/src/checks/automatic/td-has-header.js +24 -1
- package/src/checks/automatic/textbox-name-present.js +10 -1
- package/src/checks/automatic/tooltip-name-present.js +10 -1
- package/src/checks/automatic/treeitem-name-present.js +10 -1
- package/src/checks/automatic/valid-lang.js +18 -3
- package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
- package/src/checks/manual/accesskeys-manual.js +3 -1
- package/src/checks/manual/area-alt-decorative-manual.js +2 -0
- package/src/checks/manual/area-alt-quality-manual.js +2 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
- package/src/checks/manual/aria-text-manual.js +6 -5
- package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/css-hidden-focus.js +184 -9
- package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
- package/src/checks/manual/empty-heading-manual.js +17 -17
- package/src/checks/manual/empty-table-header-manual.js +52 -25
- package/src/checks/manual/focus-order-semantics-manual.js +16 -4
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
- package/src/checks/manual/heading-order-manual.js +28 -1
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
- package/src/checks/manual/image-redundant-alt-manual.js +21 -1
- package/src/checks/manual/img-alt-decorative-manual.js +2 -0
- package/src/checks/manual/img-alt-quality-manual.js +2 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
- package/src/checks/manual/label-title-only-manual.js +29 -22
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
- package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
- package/src/checks/manual/landmark-one-main-manual.js +12 -23
- package/src/checks/manual/landmark-unique-manual.js +37 -52
- package/src/checks/manual/link-name-quality-manual.js +2 -0
- package/src/checks/manual/media-transcript-present-manual.js +2 -0
- package/src/checks/manual/meta-viewport-large-manual.js +3 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/p-as-heading-manual.js +2 -0
- package/src/checks/manual/page-has-heading-one-manual.js +12 -11
- package/src/checks/manual/page-title-patterns-manual.js +2 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
- package/src/checks/manual/region-manual.js +27 -36
- package/src/checks/manual/scope-attr-valid-manual.js +3 -1
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
- package/src/checks/manual/skip-link-manual.js +7 -6
- package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/tabindex-manual.js +3 -1
- package/src/checks/manual/table-duplicate-name-manual.js +5 -4
- package/src/checks/manual/table-fake-caption-manual.js +24 -3
- package/src/checks/manual/video-caption-manual.js +2 -0
- package/src/checks/manual-review.js +2 -0
- package/src/core.js +11820 -3317
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +20 -5
- package/surea11y.browser.js +4943 -1388
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- package/src/checks/automatic/bypass-blocks-present.js +0 -215
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -1211
- package/src/core/contrast-helpers.js +0 -1302
- package/src/core/dom-helpers.js +0 -4493
- package/src/core/dom-runner.js +0 -787
- package/src/core/frame-messaging.js +0 -261
- package/src/core/frame-scan.js +0 -190
- package/src/core/rollup-composites.js +0 -127
- package/src/core/rule-meta.js +0 -176
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -1228
- package/src/i18n/fr.js +0 -1185
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -59
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check bypass-blocks-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary The page must provide at least one mechanism to bypass repeated blocks of content
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 2.4.1
|
|
11
|
+
* @applicability
|
|
12
|
+
* Always applicable to any HTML document with a <body> element —
|
|
13
|
+
* "bypass blocks" is a whole-page concern, matching
|
|
14
|
+
* aria-hidden-body / page-title-present's pattern of
|
|
15
|
+
* evaluating the document directly rather than a scoped root.
|
|
16
|
+
* @expectation
|
|
17
|
+
* At least one of the following recognized WCAG 2.4.1 techniques is
|
|
18
|
+
* present:
|
|
19
|
+
* (a) a main landmark (<main> or [role="main"]) — technique ARIA11: a
|
|
20
|
+
* screen reader user can jump straight to it, bypassing everything
|
|
21
|
+
* before it (nav, header, repeated blocks) in one step;
|
|
22
|
+
* (b) a working same-page anchor link — technique G1/G123: an
|
|
23
|
+
* <a href="#id"> (or legacy <a name="id">) whose target resolves to
|
|
24
|
+
* a real element in the link's own tree (light DOM or the same shadow
|
|
25
|
+
* root). Deliberately NOT required to be positioned before a <nav> or
|
|
26
|
+
* be keyboard-focus-order-first — see implementation notes;
|
|
27
|
+
* (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
|
|
28
|
+
* H69: heading navigation is itself a standards-recognized bypass
|
|
29
|
+
* mechanism (e.g. a screen reader's "jump by heading" command).
|
|
30
|
+
* @implementation-notes
|
|
31
|
+
* - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
|
|
32
|
+
* `fail`). When a recognized mechanism is found the page has nothing to
|
|
33
|
+
* review here → `notApplicable` (matching page-has-heading-one-manual /
|
|
34
|
+
* skip-link-manual's "nothing to flag" convention). When none is found we
|
|
35
|
+
* return `cantTell` — "we could not detect a bypass mechanism, please
|
|
36
|
+
* verify" — rather than a hard `fail`. The absence of a *detectable*
|
|
37
|
+
* mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
|
|
38
|
+
* several reasons the engine cannot resolve from a single static snapshot:
|
|
39
|
+
* • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
|
|
40
|
+
* content "repeated on multiple Web pages"; whether any block is
|
|
41
|
+
* actually repeated across the site is not knowable from one document,
|
|
42
|
+
* so a page that legitimately needs no bypass mechanism would be
|
|
43
|
+
* indistinguishable from one that omits a required one.
|
|
44
|
+
* • Transient accessibility-tree state. When a modal dialog is open the
|
|
45
|
+
* rest of the page is routinely made `inert` or `aria-hidden="true"`,
|
|
46
|
+
* so the page's real <main>/headings are (correctly) filtered out by
|
|
47
|
+
* isAccTreeEligible for the duration of that state and only the dialog
|
|
48
|
+
* is exposed — a snapshot taken then would see "no mechanism" though
|
|
49
|
+
* the page has one once the dialog closes. The same applies to content
|
|
50
|
+
* that is display:none until revealed by script (tabs, accordions, an
|
|
51
|
+
* unmounted SPA view).
|
|
52
|
+
* Both cases would produce false positives under a hard `fail`, which this
|
|
53
|
+
* engine reserves for high-confidence violations; `cantTell` routes them to
|
|
54
|
+
* human review instead. This mirrors how other tools treat 2.4.1 (e.g. axe
|
|
55
|
+
* marks the no-mechanism case "needs review" via reviewOnFail rather than
|
|
56
|
+
* failing it), and why no ACT rule hard-fails 2.4.1 by presence alone.
|
|
57
|
+
* - This rule intentionally checks presence, not position, for the
|
|
58
|
+
* same-page-anchor condition (b): a full bypass algorithm is heuristic
|
|
59
|
+
* (see ROADMAP.md's Tier 1a note on why this rule was
|
|
60
|
+
* deferred from the rest of that batch), and getting DOM-order /
|
|
61
|
+
* keyboard-focus-order positioning exactly right without introducing
|
|
62
|
+
* false positives is materially harder than the rest of Tier 1a. Being
|
|
63
|
+
* lenient about condition (b) can only make us *miss* a review prompt
|
|
64
|
+
* (a page whose only anchor link isn't a real skip mechanism, e.g. a
|
|
65
|
+
* "back to top" link) — never raise a spurious one.
|
|
66
|
+
* - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
|
|
67
|
+
* shadow-DOM-aware (when the run enables includeShadowDom) and applies the
|
|
68
|
+
* engine's hidden-content policy. The same-page-anchor target is resolved
|
|
69
|
+
* in the link's own root (`getRootNode()` — the document, or the shadow
|
|
70
|
+
* root the link lives in) before falling back to the document, so a skip
|
|
71
|
+
* link encapsulated in a web component is credited the same as one in the
|
|
72
|
+
* light DOM. (Previously the anchor path used raw
|
|
73
|
+
* `document.querySelectorAll`/`getElementById`, which never pierced shadow
|
|
74
|
+
* roots — a genuine gap now closed.)
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
const id = 'bypass-blocks-present';
|
|
78
|
+
|
|
79
|
+
const meta = {
|
|
80
|
+
title: 'Page must provide a way to bypass repeated blocks',
|
|
81
|
+
description:
|
|
82
|
+
'Checks that the page has at least one recognized WCAG 2.4.1 bypass-blocks mechanism: a main landmark, a working same-page anchor link, or a heading.',
|
|
83
|
+
i18n: {
|
|
84
|
+
titleKey: 'bypassBlocksPresent_title',
|
|
85
|
+
descriptionKey: 'bypassBlocksPresent_description'
|
|
86
|
+
},
|
|
87
|
+
helpUrl: null,
|
|
88
|
+
tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'manual'],
|
|
89
|
+
wcagSc: ['2.4.1'],
|
|
90
|
+
normativeMappings: [
|
|
91
|
+
{
|
|
92
|
+
standard: 'WCAG',
|
|
93
|
+
version: '2.2',
|
|
94
|
+
requirement: '2.4.1',
|
|
95
|
+
title: 'Bypass Blocks',
|
|
96
|
+
conformanceLevel: 'A'
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
defaultSeverity: 'moderate',
|
|
100
|
+
category: 'operable',
|
|
101
|
+
type: 'manual',
|
|
102
|
+
defaultConfidence: 'medium',
|
|
103
|
+
coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
107
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
108
|
+
// scoped this run narrower than the whole document, or when
|
|
109
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
110
|
+
function applicability(ctx) {
|
|
111
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runInPage(ctx) {
|
|
115
|
+
const { document, helpers, rule } = ctx;
|
|
116
|
+
|
|
117
|
+
const body = document && document.body ? document.body : null;
|
|
118
|
+
if (!body) {
|
|
119
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const isAccTreeEligible =
|
|
123
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
124
|
+
|
|
125
|
+
function isExposedToAt(el) {
|
|
126
|
+
if (!isAccTreeEligible) return true;
|
|
127
|
+
try {
|
|
128
|
+
const r = isAccTreeEligible(el, ctx);
|
|
129
|
+
if (typeof r === 'boolean') return r;
|
|
130
|
+
return !!(r && r.eligible);
|
|
131
|
+
} catch {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function queryAll(selector) {
|
|
137
|
+
try {
|
|
138
|
+
return helpers && typeof helpers.queryAllSmart === 'function'
|
|
139
|
+
? helpers.queryAllSmart(selector)
|
|
140
|
+
: document.querySelectorAll(selector);
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Filters through isAccTreeEligible (hidden/aria-hidden/display:none/inert
|
|
147
|
+
// don't count as "a bypass mechanism is present") -- see
|
|
148
|
+
// page-has-heading-one-manual.js for the identical real-world trigger
|
|
149
|
+
// (e.g. a page whose only <h1> sits inside a display:none ancestor,
|
|
150
|
+
// unreachable by sighted and screen reader users alike). A fully
|
|
151
|
+
// non-rendered <main>/heading must not be credited here, since that would
|
|
152
|
+
// wrongly treat a page with zero currently-exposed bypass mechanisms as
|
|
153
|
+
// having one.
|
|
154
|
+
function hasMainLandmark() {
|
|
155
|
+
for (const el of queryAll('main, [role="main"]')) {
|
|
156
|
+
if (el && isExposedToAt(el)) return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Resolve a fragment id (or legacy <a name>) inside a specific root node
|
|
162
|
+
// (a Document or a ShadowRoot). Both expose getElementById; querySelector
|
|
163
|
+
// is used for the legacy anchor-name fallback.
|
|
164
|
+
function resolveInRoot(root, fragment) {
|
|
165
|
+
if (!root) return null;
|
|
166
|
+
let target;
|
|
167
|
+
try {
|
|
168
|
+
target = typeof root.getElementById === 'function' ? root.getElementById(fragment) : null;
|
|
169
|
+
} catch {
|
|
170
|
+
target = null;
|
|
171
|
+
}
|
|
172
|
+
if (target) return target;
|
|
173
|
+
try {
|
|
174
|
+
target =
|
|
175
|
+
typeof root.querySelector === 'function'
|
|
176
|
+
? root.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]')
|
|
177
|
+
: null;
|
|
178
|
+
} catch {
|
|
179
|
+
target = null;
|
|
180
|
+
}
|
|
181
|
+
return target;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Shadow-DOM-aware: gather anchors via queryAllSmart (pierces shadow roots
|
|
185
|
+
// when includeShadowDom is enabled, and drops hard-hidden links), and
|
|
186
|
+
// resolve each fragment in the link's own root before falling back to the
|
|
187
|
+
// document. This credits a skip link encapsulated in a web component the
|
|
188
|
+
// same way as one authored in the light DOM.
|
|
189
|
+
function hasWorkingAnchorLink() {
|
|
190
|
+
let links;
|
|
191
|
+
try {
|
|
192
|
+
links =
|
|
193
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
194
|
+
? helpers.queryAllSmart('a[href]')
|
|
195
|
+
: document.querySelectorAll('a[href]');
|
|
196
|
+
} catch {
|
|
197
|
+
links = [];
|
|
198
|
+
}
|
|
199
|
+
for (const a of links) {
|
|
200
|
+
if (!a || !a.getAttribute) continue;
|
|
201
|
+
const href = String(a.getAttribute('href') || '').trim();
|
|
202
|
+
if (href.length < 2 || href.charAt(0) !== '#') continue;
|
|
203
|
+
let fragment = href.slice(1);
|
|
204
|
+
try {
|
|
205
|
+
fragment = decodeURIComponent(fragment);
|
|
206
|
+
} catch {
|
|
207
|
+
// keep raw fragment if it isn't a valid percent-encoded string
|
|
208
|
+
}
|
|
209
|
+
fragment = fragment.trim();
|
|
210
|
+
if (!fragment) continue;
|
|
211
|
+
|
|
212
|
+
let root = document;
|
|
213
|
+
try {
|
|
214
|
+
if (typeof a.getRootNode === 'function') {
|
|
215
|
+
const r = a.getRootNode();
|
|
216
|
+
if (r) root = r;
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
root = document;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let target = resolveInRoot(root, fragment);
|
|
223
|
+
if (!target && root !== document) {
|
|
224
|
+
target = resolveInRoot(document, fragment);
|
|
225
|
+
}
|
|
226
|
+
if (target) return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function hasHeading() {
|
|
232
|
+
for (const el of queryAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
|
|
233
|
+
if (el && isExposedToAt(el)) return true;
|
|
234
|
+
}
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const mainLandmark = hasMainLandmark();
|
|
239
|
+
const anchorLink = mainLandmark ? false : hasWorkingAnchorLink();
|
|
240
|
+
const heading = mainLandmark || anchorLink ? false : hasHeading();
|
|
241
|
+
|
|
242
|
+
// A recognized mechanism is present -> nothing to review on this page.
|
|
243
|
+
if (mainLandmark || anchorLink || heading) {
|
|
244
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
248
|
+
const html = helpers.getOuterHtmlSnippet
|
|
249
|
+
? helpers.getOuterHtmlSnippet(body)
|
|
250
|
+
: (body.outerHTML || '').slice(0, 200);
|
|
251
|
+
|
|
252
|
+
const occurrences = [
|
|
253
|
+
{
|
|
254
|
+
selector: stableSelector,
|
|
255
|
+
html,
|
|
256
|
+
summary:
|
|
257
|
+
'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
|
|
258
|
+
hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
|
|
259
|
+
i18n: {
|
|
260
|
+
summaryKey: 'bypassBlocksPresent_summary_cantTell',
|
|
261
|
+
hintKey: 'bypassBlocksPresent_hint_cantTell',
|
|
262
|
+
params: {}
|
|
263
|
+
},
|
|
264
|
+
data: {
|
|
265
|
+
details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
|
|
266
|
+
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
ruleId: rule.ruleId,
|
|
273
|
+
outcome: 'cantTell',
|
|
274
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
275
|
+
occurrences
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -194,6 +196,163 @@ function runInPage(ctx) {
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
function getDeepActiveElement(docRef) {
|
|
200
|
+
let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
|
|
201
|
+
let guard = 0;
|
|
202
|
+
while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
|
|
203
|
+
cur = cur.shadowRoot.activeElement;
|
|
204
|
+
}
|
|
205
|
+
return cur;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function focusElementSafe(el) {
|
|
209
|
+
if (!el || typeof el.focus !== 'function') return false;
|
|
210
|
+
try {
|
|
211
|
+
el.focus({ preventScroll: true });
|
|
212
|
+
return true;
|
|
213
|
+
} catch {
|
|
214
|
+
try {
|
|
215
|
+
el.focus();
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function runFocusObservationWindow(docRef, fn) {
|
|
224
|
+
const w =
|
|
225
|
+
docRef && docRef.defaultView
|
|
226
|
+
? docRef.defaultView
|
|
227
|
+
: typeof window !== 'undefined'
|
|
228
|
+
? window
|
|
229
|
+
: null;
|
|
230
|
+
if (!w || typeof fn !== 'function') return;
|
|
231
|
+
|
|
232
|
+
const originalSetTimeout = typeof w.setTimeout === 'function' ? w.setTimeout.bind(w) : null;
|
|
233
|
+
const originalRequestAnimationFrame =
|
|
234
|
+
typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
|
|
235
|
+
const originalQueueMicrotask =
|
|
236
|
+
typeof w.queueMicrotask === 'function' ? w.queueMicrotask.bind(w) : null;
|
|
237
|
+
|
|
238
|
+
const queuedMicrotasks = [];
|
|
239
|
+
const queuedRaf = [];
|
|
240
|
+
const queuedTimers = [];
|
|
241
|
+
let fakeTimerId = 1;
|
|
242
|
+
|
|
243
|
+
const patchedSetTimeout = function (cb, delay) {
|
|
244
|
+
const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
|
|
245
|
+
if (typeof cb === 'function' && d <= 200) {
|
|
246
|
+
const args = [];
|
|
247
|
+
for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
|
|
248
|
+
queuedTimers.push({ delay: d, cb: () => cb.apply(w, args) });
|
|
249
|
+
return fakeTimerId++;
|
|
250
|
+
}
|
|
251
|
+
if (originalSetTimeout) return originalSetTimeout.apply(w, arguments);
|
|
252
|
+
return fakeTimerId++;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const patchedRaf = function (cb) {
|
|
256
|
+
if (typeof cb === 'function') {
|
|
257
|
+
queuedRaf.push(cb);
|
|
258
|
+
return fakeTimerId++;
|
|
259
|
+
}
|
|
260
|
+
if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(w, arguments);
|
|
261
|
+
return fakeTimerId++;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const patchedQueueMicrotask = function (cb) {
|
|
265
|
+
if (typeof cb === 'function') queuedMicrotasks.push(cb);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
if (originalSetTimeout) w.setTimeout = patchedSetTimeout;
|
|
270
|
+
if (originalRequestAnimationFrame) w.requestAnimationFrame = patchedRaf;
|
|
271
|
+
if (originalQueueMicrotask) w.queueMicrotask = patchedQueueMicrotask;
|
|
272
|
+
fn();
|
|
273
|
+
|
|
274
|
+
let guard = 0;
|
|
275
|
+
while (
|
|
276
|
+
(queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
|
|
277
|
+
guard++ < 100
|
|
278
|
+
) {
|
|
279
|
+
while (queuedMicrotasks.length) {
|
|
280
|
+
const mt = queuedMicrotasks.shift();
|
|
281
|
+
try {
|
|
282
|
+
mt();
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
while (queuedRaf.length) {
|
|
286
|
+
const rf = queuedRaf.shift();
|
|
287
|
+
try {
|
|
288
|
+
rf(16);
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
if (queuedTimers.length) {
|
|
292
|
+
queuedTimers.sort((a, b) => a.delay - b.delay);
|
|
293
|
+
const tt = queuedTimers.shift();
|
|
294
|
+
try {
|
|
295
|
+
tt.cb();
|
|
296
|
+
} catch {}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
if (originalSetTimeout) w.setTimeout = originalSetTimeout;
|
|
301
|
+
if (originalRequestAnimationFrame) w.requestAnimationFrame = originalRequestAnimationFrame;
|
|
302
|
+
if (originalQueueMicrotask) w.queueMicrotask = originalQueueMicrotask;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function probeImmediateFocusRedirect(candidate) {
|
|
307
|
+
if (!candidate || typeof candidate.addEventListener !== 'function') return null;
|
|
308
|
+
|
|
309
|
+
let focusedByEvent = false;
|
|
310
|
+
const onFocusCapture = () => {
|
|
311
|
+
focusedByEvent = true;
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
candidate.addEventListener('focus', onFocusCapture, true);
|
|
315
|
+
} catch {}
|
|
316
|
+
|
|
317
|
+
const before = getDeepActiveElement(document);
|
|
318
|
+
let focused = false;
|
|
319
|
+
runFocusObservationWindow(document, () => {
|
|
320
|
+
focused = focusElementSafe(candidate);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
candidate.removeEventListener('focus', onFocusCapture, true);
|
|
325
|
+
} catch {}
|
|
326
|
+
if (!focused || !focusedByEvent) return null;
|
|
327
|
+
|
|
328
|
+
const after = getDeepActiveElement(document);
|
|
329
|
+
|
|
330
|
+
if (before && before !== after) {
|
|
331
|
+
focusElementSafe(before);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!after || after === candidate) return null;
|
|
335
|
+
const redirectedTag = (() => {
|
|
336
|
+
try {
|
|
337
|
+
return lower(after.tagName || '');
|
|
338
|
+
} catch {
|
|
339
|
+
return '';
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
342
|
+
const redirectedId = (() => {
|
|
343
|
+
try {
|
|
344
|
+
return trim(after.getAttribute && after.getAttribute('id'));
|
|
345
|
+
} catch {
|
|
346
|
+
return '';
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
return {
|
|
350
|
+
redirected: true,
|
|
351
|
+
redirectedToTag: redirectedTag || null,
|
|
352
|
+
redirectedToId: redirectedId || null
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
197
356
|
function isTabbable(el, info) {
|
|
198
357
|
const f = info || getFocusableInfoSafe(el);
|
|
199
358
|
return !!(f && f.focusable && f.tabbable);
|
|
@@ -226,6 +385,8 @@ function runInPage(ctx) {
|
|
|
226
385
|
}
|
|
227
386
|
|
|
228
387
|
const occurrences = [];
|
|
388
|
+
const maxRuntimeProbeCount = 3;
|
|
389
|
+
let runtimeProbeCount = 0;
|
|
229
390
|
|
|
230
391
|
for (const el of candidates) {
|
|
231
392
|
if (!el || !el.getAttribute) continue;
|
|
@@ -251,18 +412,32 @@ function runInPage(ctx) {
|
|
|
251
412
|
const hintsArr = [];
|
|
252
413
|
for (const k of hintOrder) if (hints.includes(k)) hintsArr.push(k);
|
|
253
414
|
|
|
415
|
+
const runtimeProbe =
|
|
416
|
+
runtimeProbeCount < maxRuntimeProbeCount ? probeImmediateFocusRedirect(el) : null;
|
|
417
|
+
if (runtimeProbeCount < maxRuntimeProbeCount) runtimeProbeCount += 1;
|
|
418
|
+
const downgradedToRedirectReview = !!(runtimeProbe && runtimeProbe.redirected);
|
|
419
|
+
|
|
254
420
|
const baseOccurrence = {
|
|
255
|
-
summary:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
421
|
+
summary: downgradedToRedirectReview
|
|
422
|
+
? `Focusable ${tagName} appears visually hidden but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
|
|
423
|
+
: `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
|
|
424
|
+
hint: downgradedToRedirectReview
|
|
425
|
+
? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on visually hidden focus targets.'
|
|
426
|
+
: 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
|
|
427
|
+
i18n: downgradedToRedirectReview
|
|
428
|
+
? null
|
|
429
|
+
: {
|
|
430
|
+
summaryKey: 'cssHidden_focus_summary_cantTell',
|
|
431
|
+
hintKey: 'cssHidden_focus_hint_cantTell',
|
|
432
|
+
params: { element: tagName, visibilityHints: hintsArr.join(',') }
|
|
433
|
+
},
|
|
262
434
|
data: {
|
|
263
435
|
details: {
|
|
264
|
-
reasonCode:
|
|
265
|
-
|
|
436
|
+
reasonCode: downgradedToRedirectReview
|
|
437
|
+
? 'cssHiddenTabbable_runtimeRedirect_needsReview'
|
|
438
|
+
: 'cssHiddenTabbable_needsFocusStateVerification',
|
|
439
|
+
metrics: { visibilityHints: hintsArr.slice(0) },
|
|
440
|
+
runtimeProbe: runtimeProbe || null
|
|
266
441
|
}
|
|
267
442
|
}
|
|
268
443
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -154,23 +156,26 @@ function runInPage(ctx) {
|
|
|
154
156
|
|
|
155
157
|
// Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
|
|
156
158
|
let labelledByText = '';
|
|
157
|
-
let hasLabelledByMechanism = false;
|
|
158
|
-
|
|
159
|
-
if (!ariaLabel && ariaLabelledBy) {
|
|
160
|
-
hasLabelledByMechanism = true;
|
|
161
159
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
labelledByText = '';
|
|
169
|
-
}
|
|
160
|
+
if (!ariaLabel && ariaLabelledBy && getTextFromIdRefs) {
|
|
161
|
+
try {
|
|
162
|
+
const t = getTextFromIdRefs(ariaLabelledBy, ctx);
|
|
163
|
+
labelledByText = trim(t && t.text);
|
|
164
|
+
} catch {
|
|
165
|
+
labelledByText = '';
|
|
170
166
|
}
|
|
171
167
|
}
|
|
172
168
|
|
|
173
|
-
|
|
169
|
+
// A broken/empty-resolving aria-labelledby (e.g. pointing at a
|
|
170
|
+
// nonexistent id) is not a "detected" text alternative to review the
|
|
171
|
+
// QUALITY of -- there's no text here at all, and this element's
|
|
172
|
+
// sibling automatic rule (embed-text-alternative-present) already
|
|
173
|
+
// reports it as a fail (no accessible name). Like object-/svg-/
|
|
174
|
+
// canvas-text-alternative-quality-manual, require aria-labelledby to
|
|
175
|
+
// actually resolve to non-empty text rather than merely being present
|
|
176
|
+
// as an attribute; a present-but-broken aria-labelledby has no text
|
|
177
|
+
// alternative to review.
|
|
178
|
+
const hasNameMechanism = !!(ariaLabel || title || labelledByText);
|
|
174
179
|
if (!hasNameMechanism) continue;
|
|
175
180
|
|
|
176
181
|
const details = {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check empty-heading
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Heading elements must not be empty
|
|
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 heading role: native <h1>-<h6>, or any
|
|
10
12
|
* element with explicit role="heading" (unless overridden by another
|
|
@@ -19,23 +21,21 @@
|
|
|
19
21
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
20
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
23
|
* header comment for the shared rationale/precedent.
|
|
22
|
-
* - This is also the reconciliation point for the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* reach was still flagged.
|
|
31
|
-
* - 2026-07-21: the subtree-text walker's own hand-rolled descendant
|
|
32
|
-
* handling (aria-label/aria-labelledby only) never checked an `<img>`
|
|
33
|
-
* descendant's `alt` text — found via Party City's
|
|
34
|
-
* `<h1><a><div><img alt="..."></div></a></h1>` logo header, a false
|
|
35
|
-
* "empty heading" cantTell. Replaced with the shared, accname-aligned
|
|
24
|
+
* - This is also the reconciliation point for the ACT-rules
|
|
25
|
+
* "heading-name-present" requirement (see ROADMAP.md "Tier 5
|
|
26
|
+
* candidates"): already covered by this pre-existing rule under a
|
|
27
|
+
* different name, not a separate gap. `title` is accepted as a naming
|
|
28
|
+
* fallback, and hidden/aria-hidden/display:none headings are excluded
|
|
29
|
+
* (gated on `isAccTreeEligible`), so an empty heading no AT user could
|
|
30
|
+
* ever reach is not flagged.
|
|
31
|
+
* - Descendant name resolution uses the shared, accname-aligned
|
|
36
32
|
* `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
|
|
37
|
-
* from content" implementation the 19 `-name-present` rules already
|
|
38
|
-
* —
|
|
33
|
+
* from content" implementation the 19 `-name-present` rules already
|
|
34
|
+
* use — rather than a narrower hand-rolled walker, so an `<img alt="...">`
|
|
35
|
+
* descendant's alt text (e.g. a
|
|
36
|
+
* `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
|
|
37
|
+
* picked up as the heading's name instead of producing a false "empty
|
|
38
|
+
* heading" cantTell.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
const id = 'empty-heading';
|