@surea11y/core 1.1.1 → 1.1.2
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 +15 -0
- package/package.json +1 -1
- package/src/checks/automatic/aria-allowed-attr.js +2 -3
- package/src/checks/automatic/aria-allowed-role.js +2 -3
- package/src/checks/automatic/aria-braille-equivalent.js +3 -4
- package/src/checks/automatic/aria-conditional-attr.js +3 -4
- package/src/checks/automatic/aria-deprecated-role.js +2 -3
- package/src/checks/automatic/aria-prohibited-attr.js +2 -3
- package/src/checks/automatic/aria-prohibited-children.js +2 -3
- package/src/checks/automatic/aria-required-attr.js +2 -3
- package/src/checks/automatic/aria-required-children.js +2 -3
- package/src/checks/automatic/aria-required-parent.js +3 -4
- package/src/checks/automatic/aria-roles-valid.js +2 -3
- package/src/checks/automatic/aria-valid-attr-value.js +2 -3
- package/src/checks/automatic/aria-valid-attr.js +2 -3
- package/src/checks/automatic/autocomplete-valid.js +2 -3
- package/src/checks/automatic/avoid-inline-spacing.js +2 -3
- package/src/checks/automatic/binary-control-name-present.js +2 -3
- package/src/checks/automatic/button-name-present.js +14 -4
- package/src/checks/automatic/bypass-blocks-present.js +35 -7
- package/src/checks/automatic/combobox-name-present.js +2 -3
- package/src/checks/automatic/definition-list-children-valid.js +2 -3
- package/src/checks/automatic/deprecated-elements-not-used.js +2 -3
- package/src/checks/automatic/dialog-name-present.js +2 -3
- package/src/checks/automatic/dlitem-parent-valid.js +2 -3
- package/src/checks/automatic/form-control-single-label.js +2 -3
- package/src/checks/automatic/iframe-focusable-content.js +2 -3
- package/src/checks/automatic/iframe-name-present.js +2 -3
- package/src/checks/automatic/iframe-title-unique.js +2 -3
- package/src/checks/automatic/label-in-name.js +7 -8
- package/src/checks/automatic/link-in-text-block.js +2 -3
- package/src/checks/automatic/link-name-present.js +16 -4
- package/src/checks/automatic/list-children-valid.js +2 -3
- package/src/checks/automatic/listbox-name-present.js +2 -3
- package/src/checks/automatic/listitem-parent-valid.js +2 -3
- package/src/checks/automatic/menuitem-name-present.js +2 -3
- package/src/checks/automatic/meter-name-present.js +2 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
- package/src/checks/automatic/option-name-present.js +2 -3
- package/src/checks/automatic/progressbar-name-present.js +2 -3
- package/src/checks/automatic/searchbox-name-present.js +2 -3
- package/src/checks/automatic/server-side-image-map-absent.js +2 -3
- package/src/checks/automatic/slider-name-present.js +2 -3
- package/src/checks/automatic/spinbutton-name-present.js +2 -3
- package/src/checks/automatic/summary-name-present.js +2 -3
- package/src/checks/automatic/tab-name-present.js +2 -3
- package/src/checks/automatic/table-headers-attr-valid.js +2 -3
- package/src/checks/automatic/table-th-has-data-cells.js +2 -3
- package/src/checks/automatic/td-has-header.js +2 -3
- package/src/checks/automatic/textbox-name-present.js +2 -3
- package/src/checks/automatic/tooltip-name-present.js +2 -3
- package/src/checks/automatic/treeitem-name-present.js +2 -3
- package/src/checks/automatic/valid-lang.js +2 -3
- package/src/checks/manual/accesskeys-manual.js +1 -7
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +3 -4
- package/src/checks/manual/aria-text-manual.js +2 -3
- package/src/checks/manual/focus-order-semantics-manual.js +2 -3
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
- package/src/checks/manual/image-redundant-alt-manual.js +2 -3
- package/src/checks/manual/label-title-only-manual.js +2 -3
- package/src/checks/manual/link-name-quality-manual.js +2 -3
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
- package/src/checks/manual/p-as-heading-manual.js +2 -3
- package/src/checks/manual/page-has-heading-one-manual.js +31 -2
- package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
- package/src/checks/manual/skip-link-manual.js +91 -9
- package/src/checks/manual/table-duplicate-name-manual.js +2 -3
- package/src/checks/manual/table-fake-caption-manual.js +2 -3
- package/src/checks/manual/video-caption-manual.js +2 -3
- package/src/core/dom-helpers.js +56 -3
- package/src/core/dom-runner.js +15 -0
- package/src/core.js +851 -481
- package/src/i18n/en.js +4 -2
- package/src/i18n/fr.js +4 -2
|
@@ -60,8 +60,7 @@ const meta = {
|
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
function runInPage(ctx) {
|
|
63
|
-
const { document,
|
|
64
|
-
const safeRoot = root || document;
|
|
63
|
+
const { document, helpers, rule } = ctx;
|
|
65
64
|
|
|
66
65
|
const GENERIC_LINK_TEXT = new Set([
|
|
67
66
|
'click here', 'here', 'click', 'more', 'more info', 'more information',
|
|
@@ -80,7 +79,7 @@ function runInPage(ctx) {
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
const selector = 'a[href]';
|
|
83
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector
|
|
82
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
84
83
|
|
|
85
84
|
const occurrences = [];
|
|
86
85
|
let applicableCount = 0;
|
|
@@ -67,8 +67,7 @@ const meta = {
|
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
function runInPage(ctx) {
|
|
70
|
-
const { document,
|
|
71
|
-
const safeRoot = root || document;
|
|
70
|
+
const { document, helpers, rule } = ctx;
|
|
72
71
|
|
|
73
72
|
const MOUSE_ONLY_ATTRS = [
|
|
74
73
|
'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup',
|
|
@@ -79,7 +78,7 @@ function runInPage(ctx) {
|
|
|
79
78
|
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
80
79
|
|
|
81
80
|
const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
|
|
82
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector
|
|
81
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
83
82
|
|
|
84
83
|
const occurrences = [];
|
|
85
84
|
let applicableCount = 0;
|
|
@@ -58,10 +58,9 @@ const meta = {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
function runInPage(ctx) {
|
|
61
|
-
const { document,
|
|
62
|
-
const safeRoot = root || document;
|
|
61
|
+
const { document, helpers, rule } = ctx;
|
|
63
62
|
|
|
64
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]'
|
|
63
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]') : helpers.queryAll('audio[autoplay], video[autoplay]');
|
|
65
64
|
|
|
66
65
|
const occurrences = [];
|
|
67
66
|
let applicableCount = 0;
|
|
@@ -53,8 +53,7 @@ const meta = {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
function runInPage(ctx) {
|
|
56
|
-
const { document,
|
|
57
|
-
const safeRoot = root || document;
|
|
56
|
+
const { document, helpers, rule } = ctx;
|
|
58
57
|
|
|
59
58
|
const MAX_HEADING_LIKE_CHARS = 120;
|
|
60
59
|
const MIN_FONT_SIZE_PX = 18;
|
|
@@ -106,7 +105,7 @@ function runInPage(ctx) {
|
|
|
106
105
|
return Number.isFinite(px) ? px : 0;
|
|
107
106
|
}
|
|
108
107
|
|
|
109
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p'
|
|
108
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p') : helpers.queryAll('p');
|
|
110
109
|
|
|
111
110
|
const occurrences = [];
|
|
112
111
|
let applicableCount = 0;
|
|
@@ -19,6 +19,18 @@
|
|
|
19
19
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
20
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
21
|
* header comment for the shared rationale/precedent.
|
|
22
|
+
* - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
|
|
23
|
+
* display:none/inert elements don't count as "the page has a heading
|
|
24
|
+
* one"), matching `landmark-one-main`'s own precedent. Found via a real
|
|
25
|
+
* page (CDC's flu page, 2026-07-30): its only `<h1>` sits inside a
|
|
26
|
+
* `display:none` ancestor — genuinely unreachable by sighted and screen
|
|
27
|
+
* reader users alike — and a raw `document.querySelectorAll` credited it
|
|
28
|
+
* anyway, reporting `notApplicable` where axe-core correctly fails. This
|
|
29
|
+
* does NOT regress purely-visually-clipped-but-AT-exposed headings (e.g.
|
|
30
|
+
* eBay's homepage `<h1>` hidden via clip-path/off-screen positioning,
|
|
31
|
+
* `visibility:visible`, no `aria-hidden`) — `isAccTreeEligible` only
|
|
32
|
+
* excludes elements actually removed from the accessibility tree, not
|
|
33
|
+
* ones merely clipped from the visual viewport.
|
|
22
34
|
*/
|
|
23
35
|
|
|
24
36
|
const id = 'page-has-heading-one';
|
|
@@ -70,14 +82,31 @@ function runInPage(ctx) {
|
|
|
70
82
|
return tag === 'h1';
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
86
|
+
|
|
87
|
+
function isExposedToAt(el) {
|
|
88
|
+
if (!isAccTreeEligible) return true;
|
|
89
|
+
try {
|
|
90
|
+
const r = isAccTreeEligible(el, ctx);
|
|
91
|
+
if (typeof r === 'boolean') return r;
|
|
92
|
+
return !!(r && r.eligible);
|
|
93
|
+
} catch {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
99
|
+
// landmark-one-main-manual.js's identical precedent.
|
|
73
100
|
let nodes = [];
|
|
74
101
|
try {
|
|
75
|
-
nodes =
|
|
102
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
103
|
+
? helpers.queryAllSmart('h1, [role]')
|
|
104
|
+
: document.querySelectorAll('h1, [role]');
|
|
76
105
|
} catch {
|
|
77
106
|
nodes = [];
|
|
78
107
|
}
|
|
79
108
|
|
|
80
|
-
const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el));
|
|
109
|
+
const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el) && isExposedToAt(el));
|
|
81
110
|
|
|
82
111
|
if (hasH1) {
|
|
83
112
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
@@ -73,8 +73,7 @@ const meta = {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
function runInPage(ctx) {
|
|
76
|
-
const { document,
|
|
77
|
-
const safeRoot = root || document;
|
|
76
|
+
const { document, helpers, rule } = ctx;
|
|
78
77
|
|
|
79
78
|
// The full set of ARIA attributes marked `global: true` per the WAI-ARIA
|
|
80
79
|
// spec (confirmed against a widely-used reference engine's own
|
|
@@ -91,8 +90,8 @@ function runInPage(ctx) {
|
|
|
91
90
|
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
92
91
|
|
|
93
92
|
const nodes = helpers.queryAllSmart
|
|
94
|
-
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]'
|
|
95
|
-
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]'
|
|
93
|
+
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
|
|
94
|
+
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
|
|
96
95
|
|
|
97
96
|
const occurrences = [];
|
|
98
97
|
let applicableCount = 0;
|
|
@@ -64,8 +64,7 @@ const meta = {
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
function runInPage(ctx) {
|
|
67
|
-
const { document,
|
|
68
|
-
const safeRoot = root || document;
|
|
67
|
+
const { document, helpers, rule } = ctx;
|
|
69
68
|
|
|
70
69
|
function safeComputedStyle(el) {
|
|
71
70
|
try {
|
|
@@ -110,7 +109,7 @@ function runInPage(ctx) {
|
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
|
|
113
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR
|
|
112
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR) : helpers.queryAll(CANDIDATE_SELECTOR);
|
|
114
113
|
|
|
115
114
|
const occurrences = [];
|
|
116
115
|
let applicableCount = 0;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @check skip-link
|
|
5
5
|
* @atomic true
|
|
6
|
-
* @summary A "skip" link must resolve to a real,
|
|
6
|
+
* @summary A "skip" link must resolve to a real, usable target
|
|
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
9
|
* Applies to <a href="#fragment"> elements whose accessible name
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* implementation-notes) — this only widens the known-convention list.
|
|
19
19
|
* @expectation
|
|
20
20
|
* The link's fragment resolves to a real element in the document
|
|
21
|
-
* (via a matching id, or a legacy <a name="...">)
|
|
22
|
-
*
|
|
21
|
+
* (via a matching id, or a legacy <a name="...">), and that target is
|
|
22
|
+
* currently usable (not hidden from the accessibility tree; and, when
|
|
23
|
+
* browser geometry is available, not zero-area/no-rects). A skip link
|
|
24
|
+
* whose target is missing or effectively unusable does not provide a
|
|
25
|
+
* reliable bypass destination.
|
|
23
26
|
* @implementation-notes
|
|
24
27
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
25
28
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
@@ -33,8 +36,8 @@
|
|
|
33
36
|
const id = 'skip-link';
|
|
34
37
|
|
|
35
38
|
const meta = {
|
|
36
|
-
title: 'Skip link must have a resolvable target',
|
|
37
|
-
description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real element in the document.',
|
|
39
|
+
title: 'Skip link must have a resolvable, usable target',
|
|
40
|
+
description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
|
|
38
41
|
i18n: {
|
|
39
42
|
titleKey: 'skipLink_title',
|
|
40
43
|
descriptionKey: 'skipLink_description'
|
|
@@ -51,8 +54,7 @@ const meta = {
|
|
|
51
54
|
};
|
|
52
55
|
|
|
53
56
|
function runInPage(ctx) {
|
|
54
|
-
const { document,
|
|
55
|
-
const safeRoot = root || document;
|
|
57
|
+
const { document, helpers, rule } = ctx;
|
|
56
58
|
|
|
57
59
|
function normalizeWs(s) {
|
|
58
60
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
@@ -79,7 +81,31 @@ function runInPage(ctx) {
|
|
|
79
81
|
return normalizeWs(el.textContent);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
function hasReliableGeometrySupport() {
|
|
85
|
+
const probe = document.documentElement || document.body || null;
|
|
86
|
+
if (!probe || !probe.getClientRects || !probe.getBoundingClientRect) return false;
|
|
87
|
+
try {
|
|
88
|
+
const rects = probe.getClientRects();
|
|
89
|
+
const rectCount = rects ? rects.length : 0;
|
|
90
|
+
const r = probe.getBoundingClientRect();
|
|
91
|
+
const w = r && Number.isFinite(r.width) ? r.width : 0;
|
|
92
|
+
const h = r && Number.isFinite(r.height) ? r.height : 0;
|
|
93
|
+
return rectCount > 0 && (w > 0 || h > 0);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toEligibility(info) {
|
|
100
|
+
return {
|
|
101
|
+
eligible: !!(info && info.eligible),
|
|
102
|
+
reasons: info && Array.isArray(info.reasons) ? info.reasons.slice(0) : []
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const geometrySupported = hasReliableGeometrySupport();
|
|
107
|
+
|
|
108
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
|
|
83
109
|
|
|
84
110
|
const occurrences = [];
|
|
85
111
|
let applicableCount = 0;
|
|
@@ -117,7 +143,63 @@ function runInPage(ctx) {
|
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
if (target)
|
|
146
|
+
if (target) {
|
|
147
|
+
const accEligibility = toEligibility(
|
|
148
|
+
helpers.getEligibilityInfo
|
|
149
|
+
? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
|
|
150
|
+
: (helpers.isAccTreeEligible ? helpers.isAccTreeEligible(target, ctx) : { eligible: true, reasons: [] })
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
let geometryEligibility = null;
|
|
154
|
+
let geometryReasonCode = null;
|
|
155
|
+
if (geometrySupported && helpers.isDomVisibleEligible) {
|
|
156
|
+
geometryEligibility = toEligibility(
|
|
157
|
+
helpers.isDomVisibleEligible(target, ctx, { visibilityMode: 'styleAndGeometry', ignoreOpacity: true })
|
|
158
|
+
);
|
|
159
|
+
if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('noClientRects')) {
|
|
160
|
+
geometryReasonCode = 'NO_CLIENT_RECTS';
|
|
161
|
+
} else if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('zeroArea')) {
|
|
162
|
+
geometryReasonCode = 'ZERO_AREA_TARGET';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const unusableByAcc = !accEligibility.eligible;
|
|
167
|
+
const unusableByGeometry = !!geometryReasonCode;
|
|
168
|
+
if (!unusableByAcc && !unusableByGeometry) continue;
|
|
169
|
+
|
|
170
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
171
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
172
|
+
|
|
173
|
+
occurrences.push({
|
|
174
|
+
selector: stableSelector,
|
|
175
|
+
html,
|
|
176
|
+
summary: 'This skip link points to a target that exists but is not currently usable.',
|
|
177
|
+
hint: 'Point this skip link to a target that is exposed and usable as a navigation destination.',
|
|
178
|
+
i18n: {
|
|
179
|
+
summaryKey: 'skipLink_summary_unusableTarget_cantTell',
|
|
180
|
+
hintKey: 'skipLink_hint_unusableTarget_cantTell',
|
|
181
|
+
params: { href }
|
|
182
|
+
},
|
|
183
|
+
data: {
|
|
184
|
+
details: {
|
|
185
|
+
reasonCode: 'SKIP_LINK_TARGET_UNUSABLE',
|
|
186
|
+
href,
|
|
187
|
+
unusableReasonCode: unusableByAcc ? 'ACC_TREE_INELIGIBLE' : geometryReasonCode,
|
|
188
|
+
targetSelector: helpers.buildSelector ? helpers.buildSelector(target) : null,
|
|
189
|
+
geometryCheckEnabled: geometrySupported
|
|
190
|
+
},
|
|
191
|
+
visibilityFilter: {
|
|
192
|
+
targetSet: 'acc',
|
|
193
|
+
accEligible: accEligibility.eligible,
|
|
194
|
+
reasons: accEligibility.reasons
|
|
195
|
+
},
|
|
196
|
+
targetGeometry: geometryEligibility
|
|
197
|
+
? { eligible: geometryEligibility.eligible, reasons: geometryEligibility.reasons }
|
|
198
|
+
: { eligible: null, reasons: [] }
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
121
203
|
|
|
122
204
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
123
205
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
@@ -43,14 +43,13 @@ const meta = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
function runInPage(ctx) {
|
|
46
|
-
const { document,
|
|
47
|
-
const safeRoot = root || document;
|
|
46
|
+
const { document, helpers, rule } = ctx;
|
|
48
47
|
|
|
49
48
|
function normalizeWs(s) {
|
|
50
49
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]'
|
|
52
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]') : helpers.queryAll('table[summary]');
|
|
54
53
|
|
|
55
54
|
const occurrences = [];
|
|
56
55
|
let applicableCount = 0;
|
|
@@ -51,12 +51,11 @@ const meta = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
function runInPage(ctx) {
|
|
54
|
-
const { document,
|
|
55
|
-
const safeRoot = root || document;
|
|
54
|
+
const { document, helpers, rule } = ctx;
|
|
56
55
|
|
|
57
56
|
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
58
57
|
|
|
59
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table'
|
|
58
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
|
|
60
59
|
|
|
61
60
|
const occurrences = [];
|
|
62
61
|
let applicableCount = 0;
|
|
@@ -51,10 +51,9 @@ const meta = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
function runInPage(ctx) {
|
|
54
|
-
const { document,
|
|
55
|
-
const safeRoot = root || document;
|
|
54
|
+
const { document, helpers, rule } = ctx;
|
|
56
55
|
|
|
57
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video'
|
|
56
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video') : helpers.queryAll('video');
|
|
58
57
|
|
|
59
58
|
const occurrences = [];
|
|
60
59
|
let applicableCount = 0;
|
package/src/core/dom-helpers.js
CHANGED
|
@@ -1092,7 +1092,8 @@ function createDomHelpers(opts) {
|
|
|
1092
1092
|
'templateContent',
|
|
1093
1093
|
'nonRenderedElement',
|
|
1094
1094
|
'inputHidden',
|
|
1095
|
-
'visibilityHidden'
|
|
1095
|
+
'visibilityHidden',
|
|
1096
|
+
'contentVisibilityHidden'
|
|
1096
1097
|
]);
|
|
1097
1098
|
|
|
1098
1099
|
function queryAllSmart(sel) {
|
|
@@ -1109,6 +1110,23 @@ function createDomHelpers(opts) {
|
|
|
1109
1110
|
for (const r of reasons) {
|
|
1110
1111
|
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1111
1112
|
}
|
|
1113
|
+
|
|
1114
|
+
// `isAccTreeEligible` can short-circuit on an inert ancestor
|
|
1115
|
+
// before it reaches an outer hard-hidden ancestor (e.g.
|
|
1116
|
+
// display:none wrapper). In that case the node is still
|
|
1117
|
+
// structurally hidden and should be excluded by the default
|
|
1118
|
+
// hidden-content policy.
|
|
1119
|
+
if (reasons.includes('inert')) {
|
|
1120
|
+
const domVis = isDomVisibleEligible(el, null, {
|
|
1121
|
+
visibilityMode: 'styleOnly',
|
|
1122
|
+
disableGeometry: true,
|
|
1123
|
+
ignoreOpacity: true
|
|
1124
|
+
});
|
|
1125
|
+
const domReasons = Array.isArray(domVis && domVis.reasons) ? domVis.reasons : [];
|
|
1126
|
+
for (const r of domReasons) {
|
|
1127
|
+
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1112
1130
|
return true;
|
|
1113
1131
|
} catch {
|
|
1114
1132
|
return true;
|
|
@@ -3685,6 +3703,36 @@ function createDomHelpers(opts) {
|
|
|
3685
3703
|
let node = el;
|
|
3686
3704
|
let safety = 0;
|
|
3687
3705
|
|
|
3706
|
+
// Only apply the "stop climbing once we reach a contextSelector-
|
|
3707
|
+
// matched root" shortcut when there's a single (or no) matched
|
|
3708
|
+
// root -- resolveContextRoots() falls back to `[documentElement]`
|
|
3709
|
+
// when no contextSelector is given, so this is the overwhelmingly
|
|
3710
|
+
// common case and behaves exactly as before.
|
|
3711
|
+
//
|
|
3712
|
+
// With MULTIPLE matched roots (multi-region contextSelector
|
|
3713
|
+
// scans), stopping there without recording anything about which
|
|
3714
|
+
// root produced an ambiguous, non-unique selector string for two
|
|
3715
|
+
// structurally-identical regions -- a real, confirmed bug (found
|
|
3716
|
+
// 2026-07-29 via the cross-engine comparisons project): two
|
|
3717
|
+
// wrapper <div>s, each containing two identical ".widget"
|
|
3718
|
+
// sections scanned via `contextSelector: '.widget'`, produced the
|
|
3719
|
+
// *same* selector string ("section:nth-of-type(1) > div > div >
|
|
3720
|
+
// button") for the equivalent button in each wrapper --
|
|
3721
|
+
// resolving to 2 elements instead of 1 when queried, and pointing
|
|
3722
|
+
// at the wrong one for at least one of the two occurrences. The
|
|
3723
|
+
// existing `el.matches(candidate)` safety check below couldn't
|
|
3724
|
+
// catch this: it only verifies THIS element matches the string,
|
|
3725
|
+
// never that the string is unique document-wide.
|
|
3726
|
+
//
|
|
3727
|
+
// Fix: when multiple roots are in play, don't stop early --
|
|
3728
|
+
// keep climbing (same as the always-correct no-contextSelector
|
|
3729
|
+
// path) until finding a genuinely unique anchor or reaching the
|
|
3730
|
+
// true document root, which is always singular. That restores
|
|
3731
|
+
// the invariant the final safety-check comment below relies on,
|
|
3732
|
+
// rather than needing a separate (more expensive) document-wide
|
|
3733
|
+
// uniqueness re-check.
|
|
3734
|
+
const stopAtMatchedRoot = roots.length <= 1;
|
|
3735
|
+
|
|
3688
3736
|
while (node && node.nodeType === 1 && safety++ < 20) {
|
|
3689
3737
|
let anchor = null;
|
|
3690
3738
|
|
|
@@ -3724,7 +3772,7 @@ function createDomHelpers(opts) {
|
|
|
3724
3772
|
parts.unshift(nthOfType(node));
|
|
3725
3773
|
}
|
|
3726
3774
|
|
|
3727
|
-
if (!node.parentElement || roots.includes(node)) break;
|
|
3775
|
+
if (!node.parentElement || (stopAtMatchedRoot && roots.includes(node))) break;
|
|
3728
3776
|
node = node.parentElement;
|
|
3729
3777
|
}
|
|
3730
3778
|
|
|
@@ -3743,7 +3791,12 @@ function createDomHelpers(opts) {
|
|
|
3743
3791
|
// position relative to its own parent via `>` (child, not
|
|
3744
3792
|
// descendant) combinators, so a correctly-matching chain can
|
|
3745
3793
|
// only resolve to one element short of a malformed document
|
|
3746
|
-
// (e.g. two <html> roots)
|
|
3794
|
+
// (e.g. two <html> roots) -- true as long as the walk above
|
|
3795
|
+
// never stops short of a genuinely unique anchor/root, which is
|
|
3796
|
+
// exactly what `stopAtMatchedRoot` now guarantees (see its own
|
|
3797
|
+
// comment above; a multi-root contextSelector scan stopping
|
|
3798
|
+
// early used to violate this invariant silently). Re-deriving
|
|
3799
|
+
// that guarantee via a
|
|
3747
3800
|
// document-wide :nth-of-type scan was measured to cost O(total
|
|
3748
3801
|
// same-tag siblings) per call — pathological on pages with many
|
|
3749
3802
|
// flat, unidentified siblings (e.g. hundreds of unlabeled
|
package/src/core/dom-runner.js
CHANGED
|
@@ -61,6 +61,21 @@ function runCore(pageUrl, contextSelector, engineOptions, runOnly, CHECK_DEFS, R
|
|
|
61
61
|
? engineOptionsResolved.timestamp.trim()
|
|
62
62
|
: null;
|
|
63
63
|
|
|
64
|
+
// createDomHelpers()/createContrastHelpers() persist their element-keyed
|
|
65
|
+
// caches (outerHtmlCache, selectorCache, etc.) on window.__a11ycoreSharedCache
|
|
66
|
+
// so multiple helper instances created *within this run* can share them
|
|
67
|
+
// deterministically. But a window/document is frequently reused across
|
|
68
|
+
// SEPARATE runs -- e.g. Jest's jsdom environment creates one window per
|
|
69
|
+
// test file, and mutating document.body between it() blocks is standard.
|
|
70
|
+
// Those caches are keyed by element reference, not content, so a run that
|
|
71
|
+
// reuses an already-cached element (document.body never changes identity)
|
|
72
|
+
// would otherwise read stale data cached by an earlier, unrelated run on
|
|
73
|
+
// the same window. Clearing at the start of every run keeps sharing scoped
|
|
74
|
+
// to "this run" as intended, without leaking across runs.
|
|
75
|
+
try {
|
|
76
|
+
if (window && window.__a11ycoreSharedCache) window.__a11ycoreSharedCache = {};
|
|
77
|
+
} catch {}
|
|
78
|
+
|
|
64
79
|
const sharedHelpers = createDomHelpers({
|
|
65
80
|
document,
|
|
66
81
|
window,
|