@surea11y/core 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -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/package.json +50 -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 +15 -51
- 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 +4 -3
- 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 +2 -0
- 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 +2 -0
- package/src/checks/automatic/avoid-inline-spacing.js +3 -2
- package/src/checks/automatic/binary-control-name-present.js +2 -0
- package/src/checks/automatic/button-name-present.js +7 -7
- package/src/checks/automatic/bypass-blocks-present.js +9 -7
- package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
- package/src/checks/automatic/combobox-name-present.js +2 -0
- 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 +10 -10
- 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 +2 -0
- package/src/checks/automatic/form-control-single-label.js +6 -7
- 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 +7 -9
- package/src/checks/automatic/input-image-alt-present.js +4 -6
- package/src/checks/automatic/label-in-name.js +15 -19
- package/src/checks/automatic/language-page-present.js +2 -0
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +2 -0
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +2 -0
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +2 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
- package/src/checks/automatic/meter-name-present.js +4 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
- package/src/checks/automatic/object-text-alternative-present.js +2 -0
- package/src/checks/automatic/option-name-present.js +2 -0
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +8 -10
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +2 -0
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +2 -0
- package/src/checks/automatic/spinbutton-name-present.js +2 -0
- package/src/checks/automatic/summary-name-present.js +2 -0
- 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 +2 -0
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +2 -0
- 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 +2 -0
- package/src/checks/automatic/tooltip-name-present.js +2 -0
- package/src/checks/automatic/treeitem-name-present.js +2 -0
- package/src/checks/automatic/valid-lang.js +2 -0
- 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/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 +2 -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 +54 -47
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
- 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 +6772 -2158
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +18 -3
- package/surea11y.browser.js +2731 -999
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- 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
package/src/core/dom-helpers.js
DELETED
|
@@ -1,4493 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* DOM helpers used by checks (ctx.helpers).
|
|
5
|
-
*
|
|
6
|
-
* Kernel philosophy (A11yCore helpers contract)
|
|
7
|
-
* ---------------------------------------------
|
|
8
|
-
* These helpers exist to keep checks:
|
|
9
|
-
* - **Atomic** (checks decide outcomes; helpers provide facts),
|
|
10
|
-
* - **Deterministic** (no randomness, time, locale, or non-deterministic iteration),
|
|
11
|
-
* - **Serializable** (inlined into generated core.js; no Node-only APIs),
|
|
12
|
-
* - **Standards-aligned** (helpers are mechanism-aware where requirements depend on element/type).
|
|
13
|
-
*
|
|
14
|
-
* Design principles:
|
|
15
|
-
* 1) Helpers return **structured "Info" objects** (facts + mechanism + flags), not verdicts.
|
|
16
|
-
* - Rules produce outcomes: pass/fail/cantTell/notApplicable.
|
|
17
|
-
* 2) Prefer **mechanism-first** semantics:
|
|
18
|
-
* - Example: <img> requires an `alt` attribute (even if aria-label exists). Helpers surface that:
|
|
19
|
-
* `{ present:false, flags:['name-present-but-alt-missing'] }` when `alt` is missing.
|
|
20
|
-
* 3) Keep "kernel" helpers small, stable, and reusable across domains. If/when domain helpers emerge
|
|
21
|
-
* (tables/media/forms), they should build on these kernel primitives.
|
|
22
|
-
*
|
|
23
|
-
* Kernel helpers included (A → F):
|
|
24
|
-
* A) eligibility: isAccTreeEligible (existing), getEligibilityInfo (new)
|
|
25
|
-
* B) name/description: getAccessibleNameInfo, getAccessibleDescriptionInfo (new)
|
|
26
|
-
* C) text alternatives: getTextAlternativeInfo (new)
|
|
27
|
-
* D) role/focusability: getRoleInfo, getFocusableInfo (new)
|
|
28
|
-
* E) IDREF resolution: resolveIdRefs, getTextFromIdRefs (new)
|
|
29
|
-
* F) selector/snippet: buildSelector/buildSimpleSelector/getOuterHtmlSnippet (existing)
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
const { createContrastHelpers } = require('./contrast-helpers');
|
|
33
|
-
const { createAriaHelpers } = require('./aria-helpers');
|
|
34
|
-
|
|
35
|
-
function normalizeSelectorList(value) {
|
|
36
|
-
if (!value) return [];
|
|
37
|
-
if (Array.isArray(value))
|
|
38
|
-
return value
|
|
39
|
-
.map(String)
|
|
40
|
-
.map((s) => s.trim())
|
|
41
|
-
.filter(Boolean);
|
|
42
|
-
if (typeof value === 'string') {
|
|
43
|
-
// allow "#a,#b" or "#a, #b"
|
|
44
|
-
return value
|
|
45
|
-
.split(',')
|
|
46
|
-
.map((s) => s.trim())
|
|
47
|
-
.filter(Boolean);
|
|
48
|
-
}
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Resolves a raw contextSelector (string | string[] | null) to the
|
|
54
|
-
* normalized selector value (`ctxSelector`) plus the actual root elements to
|
|
55
|
-
* scan (`roots`, deduped, in resolution order), falling back to
|
|
56
|
-
* documentElement/body/html when nothing matches. Extracted out of
|
|
57
|
-
* dom-runner.js's runCore so frame-scan.js can discover which child
|
|
58
|
-
* <iframe>/<frame> elements fall within the same scan scope, without
|
|
59
|
-
* duplicating this resolution logic a second time.
|
|
60
|
-
*/
|
|
61
|
-
function resolveContextRoots(document, contextSelector) {
|
|
62
|
-
const ctxSelector = Array.isArray(contextSelector)
|
|
63
|
-
? (() => {
|
|
64
|
-
const list = contextSelector
|
|
65
|
-
.map((s) => (typeof s === 'string' ? s.trim() : ''))
|
|
66
|
-
.filter(Boolean);
|
|
67
|
-
return list.length ? list : null;
|
|
68
|
-
})()
|
|
69
|
-
: typeof contextSelector === 'string' && contextSelector.trim()
|
|
70
|
-
? contextSelector.trim()
|
|
71
|
-
: null;
|
|
72
|
-
|
|
73
|
-
let roots = [];
|
|
74
|
-
{
|
|
75
|
-
const selectorList = Array.isArray(ctxSelector)
|
|
76
|
-
? ctxSelector
|
|
77
|
-
: ctxSelector
|
|
78
|
-
? [ctxSelector]
|
|
79
|
-
: [];
|
|
80
|
-
const seen = new Set();
|
|
81
|
-
for (const sel of selectorList) {
|
|
82
|
-
let matches;
|
|
83
|
-
try {
|
|
84
|
-
matches = document.querySelectorAll(sel);
|
|
85
|
-
} catch {
|
|
86
|
-
matches = [];
|
|
87
|
-
}
|
|
88
|
-
for (const el of matches) {
|
|
89
|
-
if (el && !seen.has(el)) {
|
|
90
|
-
seen.add(el);
|
|
91
|
-
roots.push(el);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (!roots.length) {
|
|
97
|
-
const fallback = document.documentElement || document.body || document.querySelector('html');
|
|
98
|
-
if (fallback) roots = [fallback];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return { ctxSelector, roots };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function createDomHelpers(opts) {
|
|
105
|
-
const document = opts && opts.document ? opts.document : null;
|
|
106
|
-
const window = opts && opts.window ? opts.window : null;
|
|
107
|
-
// Some engine paths may not pass opts.window; recover it from document when possible.
|
|
108
|
-
const realmWindow = window || (document && document.defaultView) || null;
|
|
109
|
-
// opts.root accepts either a single element (back-compat -- every
|
|
110
|
-
// existing call site, including every test, passes one) or an array of
|
|
111
|
-
// elements (multi-region contextSelector support, dom-runner.js). Every
|
|
112
|
-
// internal consumer below works off `roots` (always an array, possibly
|
|
113
|
-
// empty) rather than assuming a single element.
|
|
114
|
-
const roots = (() => {
|
|
115
|
-
const r = opts && opts.root;
|
|
116
|
-
if (Array.isArray(r)) return r.filter((x) => x && typeof x === 'object');
|
|
117
|
-
if (r && typeof r === 'object') return [r];
|
|
118
|
-
return [];
|
|
119
|
-
})();
|
|
120
|
-
// Default on: opt OUT with `includeShadowDom: false`, not opt in.
|
|
121
|
-
const includeShadowDom = !(opts && opts.includeShadowDom === false);
|
|
122
|
-
// Default off: by default, helper queries skip structurally/CSS-hidden
|
|
123
|
-
// subtrees (display:none, [hidden], closed <details>, etc.). Callers can
|
|
124
|
-
// opt out with includeHiddenElements:true.
|
|
125
|
-
const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
|
|
126
|
-
const excludeSelectors = Array.isArray(opts && opts.excludeSelectors)
|
|
127
|
-
? opts.excludeSelectors
|
|
128
|
-
: [];
|
|
129
|
-
// Default off: explicit opt-in for "this scan target was never meant to
|
|
130
|
-
// represent a real page" (e.g. a raw component fragment parsed on its
|
|
131
|
-
// own), regardless of whether document.documentElement happens to be in
|
|
132
|
-
// scope. See isWholeDocumentScope() below.
|
|
133
|
-
const fragment = !!(opts && opts.fragment === true);
|
|
134
|
-
|
|
135
|
-
// Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
|
|
136
|
-
// by dom-runner.js immediately before invoking each rule's applicability/
|
|
137
|
-
// run function via __setActiveRuleExcludeSelectors(). Safe as mutable
|
|
138
|
-
// closure state because rule execution is synchronous and single-rule-
|
|
139
|
-
// at-a-time: exactly one rule's excludes are ever "active" at once.
|
|
140
|
-
var __activeRuleExcludeSelectors = [];
|
|
141
|
-
|
|
142
|
-
function __getEffectiveExcludeSelectors() {
|
|
143
|
-
return __activeRuleExcludeSelectors.length
|
|
144
|
-
? excludeSelectors.concat(__activeRuleExcludeSelectors)
|
|
145
|
-
: excludeSelectors;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function __setActiveRuleExcludeSelectors(list) {
|
|
149
|
-
__activeRuleExcludeSelectors = normalizeSelectorList(list);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Selector-related caches (selector uniqueness index, per-element built
|
|
153
|
-
// selector strings) depend on includeShadowDom/the effective exclude
|
|
154
|
-
// list, since those change which elements are considered when checking
|
|
155
|
-
// uniqueness. The underlying storage is shared across createDomHelpers()
|
|
156
|
-
// calls on the same window/document (see __domSharedCache below), so a
|
|
157
|
-
// run -- or a rule with its own rule-scoped excludes -- must not
|
|
158
|
-
// read/write another run/rule's cached selectors. This key partitions
|
|
159
|
-
// those caches per effective option set; recomputed per call (not a
|
|
160
|
-
// constant) since the effective list changes as the active rule changes.
|
|
161
|
-
function __getSelectorOptsKey() {
|
|
162
|
-
return (
|
|
163
|
-
(includeShadowDom ? 'sd1' : 'sd0') +
|
|
164
|
-
'|' +
|
|
165
|
-
__getEffectiveExcludeSelectors().slice().sort().join(',')
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// -------------------------------------------------------------------------
|
|
170
|
-
// Per-run shared caches (DOM helpers)
|
|
171
|
-
// Stored on the realm window when possible so multiple helper instances
|
|
172
|
-
// within the same run share caches deterministically.
|
|
173
|
-
// -------------------------------------------------------------------------
|
|
174
|
-
var __domSharedCache = {};
|
|
175
|
-
var __selectorCache = null;
|
|
176
|
-
var __outerHtmlCache = null;
|
|
177
|
-
var __idLookupDocCache = null; // Map<string, Element|null>
|
|
178
|
-
var __idLookupRootCache = null; // Map<string, Element|null>
|
|
179
|
-
var __idRefCacheByRoot = null; // WeakMap<object, Map<string, {refs, missing, flags, partsLen}>>
|
|
180
|
-
var __idRefReverseIndexByScope = null; // WeakMap<object, Map<string, Set<Element>>>
|
|
181
|
-
var __uniqIndexByScope = null; // WeakMap<object, object> (selector uniqueness index per scope)
|
|
182
|
-
var __shadowRootsByRoot = null; // WeakMap<object, Array<object>> (cached open shadow roots per root)
|
|
183
|
-
|
|
184
|
-
// Shared recursion-depth guard across the mutually-recursive naming
|
|
185
|
-
// functions (computeIdRefTargetTextAlternative <-> getContentNameInfo <->
|
|
186
|
-
// getAccessibleNameInfo, via aria-labelledby targets that themselves
|
|
187
|
-
// contain elements with their own aria-labelledby). Each per-call
|
|
188
|
-
// `visited` Set only guards against cycles *within* a single top-level
|
|
189
|
-
// getTextFromIdRefs() invocation; a cross-function hop (e.g. resolving a
|
|
190
|
-
// labelledby target's content, which contains a descendant with its own
|
|
191
|
-
// aria-labelledby) starts a *fresh* visited Set and would defeat that
|
|
192
|
-
// guard on a genuine circular reference. This counter bounds the total
|
|
193
|
-
// combined call depth regardless of which function is on the stack.
|
|
194
|
-
var __nameComputationDepth = 0;
|
|
195
|
-
var __NAME_COMPUTATION_MAX_DEPTH = 40;
|
|
196
|
-
|
|
197
|
-
// -------------------------------------------------------------------------
|
|
198
|
-
// Optional per-run performance counters (debug/benchmark only)
|
|
199
|
-
// -------------------------------------------------------------------------
|
|
200
|
-
const __perfEnabled = !!(opts && opts.perfStats);
|
|
201
|
-
const __perf = __perfEnabled ? { enabled: true, counters: Object.create(null) } : null;
|
|
202
|
-
|
|
203
|
-
function __perfInc(key, n) {
|
|
204
|
-
if (!__perfEnabled || !__perf) return;
|
|
205
|
-
const k = String(key);
|
|
206
|
-
const add = n == null ? 1 : Number(n) || 0;
|
|
207
|
-
__perf.counters[k] = (__perf.counters[k] || 0) + add;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function getPerfStats() {
|
|
211
|
-
if (!__perfEnabled || !__perf) return { enabled: false, counters: {} };
|
|
212
|
-
// Return a shallow copy to prevent accidental mutation by callers
|
|
213
|
-
return { enabled: true, counters: { ...__perf.counters } };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function resetPerfStats() {
|
|
217
|
-
if (!__perfEnabled || !__perf) return;
|
|
218
|
-
__perf.counters = Object.create(null);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// -------------------------------------------------------------------------
|
|
222
|
-
// Shared escaping helpers (reduce per-call allocations, deterministic)
|
|
223
|
-
// -------------------------------------------------------------------------
|
|
224
|
-
const __w = realmWindow || window;
|
|
225
|
-
const __cssEscapeSafe = (s) => {
|
|
226
|
-
try {
|
|
227
|
-
return __w && __w.CSS && typeof __w.CSS.escape === 'function'
|
|
228
|
-
? __w.CSS.escape(String(s))
|
|
229
|
-
: String(s);
|
|
230
|
-
} catch {
|
|
231
|
-
return String(s);
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
// Spec-accurate CSS.escape() fallback (CSSOM "serialize an identifier"
|
|
235
|
-
// algorithm) for environments without a native window.CSS.escape — this
|
|
236
|
-
// is jsdom's actual situation (confirmed: window.CSS.escape is
|
|
237
|
-
// undefined there), so this fallback is not a rare edge case, it's the
|
|
238
|
-
// one actually exercised on every selector this engine ever builds.
|
|
239
|
-
// The previous fallback (a flat "escape any disallowed character"
|
|
240
|
-
// regex) didn't handle CSS identifiers that START with a digit or a
|
|
241
|
-
// hyphen+digit — a real pattern on real sites (UUID-style element IDs,
|
|
242
|
-
// e.g. Nike's homepage: id="13cbc70d-ca70-4938-9150-5abddc780c24").
|
|
243
|
-
// An unescaped leading digit makes the resulting selector fragment
|
|
244
|
-
// (e.g. "#13cbc70d-...") invalid CSS, which made buildSelectorUncached's
|
|
245
|
-
// own el.matches(candidate) verification throw (silently caught),
|
|
246
|
-
// degrading the selector to buildSimpleSelector's bare-tag-name
|
|
247
|
-
// fallback — losing all positional/uniqueness information for every
|
|
248
|
-
// element anchored under that ancestor.
|
|
249
|
-
function __cssEscapeIdentFallback(value) {
|
|
250
|
-
const string = String(value);
|
|
251
|
-
const length = string.length;
|
|
252
|
-
const firstCodeUnit = string.charCodeAt(0);
|
|
253
|
-
if (length === 1 && firstCodeUnit === 0x002d) return '\\' + string;
|
|
254
|
-
let result = '';
|
|
255
|
-
for (let index = 0; index < length; index++) {
|
|
256
|
-
const codeUnit = string.charCodeAt(index);
|
|
257
|
-
if (codeUnit === 0x0000) {
|
|
258
|
-
result += '\uFFFD';
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
if (
|
|
262
|
-
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
|
|
263
|
-
codeUnit === 0x007f ||
|
|
264
|
-
(index === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
|
|
265
|
-
(index === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && firstCodeUnit === 0x002d)
|
|
266
|
-
) {
|
|
267
|
-
result += '\\' + codeUnit.toString(16) + ' ';
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
if (
|
|
271
|
-
codeUnit >= 0x0080 ||
|
|
272
|
-
codeUnit === 0x002d ||
|
|
273
|
-
codeUnit === 0x005f ||
|
|
274
|
-
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
|
|
275
|
-
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
|
|
276
|
-
(codeUnit >= 0x0061 && codeUnit <= 0x007a)
|
|
277
|
-
) {
|
|
278
|
-
result += string.charAt(index);
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
result += '\\' + string.charAt(index);
|
|
282
|
-
}
|
|
283
|
-
return result;
|
|
284
|
-
}
|
|
285
|
-
const __cssEscapeIdent = (s) => {
|
|
286
|
-
try {
|
|
287
|
-
if (__w && __w.CSS && typeof __w.CSS.escape === 'function') return __w.CSS.escape(String(s));
|
|
288
|
-
} catch {}
|
|
289
|
-
return __cssEscapeIdentFallback(s);
|
|
290
|
-
};
|
|
291
|
-
const __escapeAttrValue = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
292
|
-
|
|
293
|
-
// --- eligibility utilities ---
|
|
294
|
-
const isElement = (n) => !!n && n.nodeType === 1;
|
|
295
|
-
const computedStyle = (el) => {
|
|
296
|
-
// Per-run memoization scoped by *helper scope* (root/document), to ensure
|
|
297
|
-
// style caching does not bleed across helper instances with different roots.
|
|
298
|
-
// This aligns with eligibility cache scoping semantics locked by checks.
|
|
299
|
-
const scope = __getScopeObj();
|
|
300
|
-
|
|
301
|
-
let map = null;
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
if (__computedStyleCacheByScope && scope && el && typeof el === 'object') {
|
|
305
|
-
map = __computedStyleCacheByScope.get(scope) || null;
|
|
306
|
-
if (map && map.has(el)) {
|
|
307
|
-
__perfInc('computedStyle.hit');
|
|
308
|
-
const c = map.get(el);
|
|
309
|
-
return c && typeof c === 'object' ? c : {};
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
} catch {
|
|
313
|
-
/* ignore */
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
__perfInc('computedStyle.miss');
|
|
317
|
-
let cs;
|
|
318
|
-
try {
|
|
319
|
-
const w = realmWindow || window;
|
|
320
|
-
cs = w && w.getComputedStyle ? w.getComputedStyle(el) : (el && el.style) || {};
|
|
321
|
-
} catch {
|
|
322
|
-
cs = {};
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
if (__computedStyleCacheByScope && scope && el && typeof el === 'object') {
|
|
327
|
-
if (!map) {
|
|
328
|
-
map = __computedStyleCacheByScope.get(scope) || null;
|
|
329
|
-
if (!map) {
|
|
330
|
-
map = new WeakMap();
|
|
331
|
-
__computedStyleCacheByScope.set(scope, map);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
map.set(el, cs);
|
|
335
|
-
}
|
|
336
|
-
} catch {
|
|
337
|
-
__perfInc('computedStyle.nocache');
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return cs && typeof cs === 'object' ? cs : {};
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const getOpenModalDialogs = () => {
|
|
344
|
-
// Per-run memoization of open modal dialogs (document-scoped).
|
|
345
|
-
// Safe under engine constraints (no DOM mutation during a run); deterministic.
|
|
346
|
-
if (!document || !document.querySelectorAll) return [];
|
|
347
|
-
if (!__openModalDialogsByDoc) {
|
|
348
|
-
__perfInc('modalDialogs.nocache');
|
|
349
|
-
}
|
|
350
|
-
try {
|
|
351
|
-
if (__openModalDialogsByDoc) {
|
|
352
|
-
const cached = __openModalDialogsByDoc.get(document);
|
|
353
|
-
if (cached) {
|
|
354
|
-
__perfInc('modalDialogs.hit');
|
|
355
|
-
return cached;
|
|
356
|
-
}
|
|
357
|
-
__perfInc('modalDialogs.miss');
|
|
358
|
-
}
|
|
359
|
-
} catch {
|
|
360
|
-
__perfInc('modalDialogs.nocache');
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
let list = [];
|
|
364
|
-
try {
|
|
365
|
-
const nl = document.querySelectorAll('dialog[open][aria-modal="true"]');
|
|
366
|
-
// Preserve document order, avoid Array.from allocation where possible.
|
|
367
|
-
for (const el of nl) list.push(el);
|
|
368
|
-
} catch {
|
|
369
|
-
list = [];
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
try {
|
|
373
|
-
if (__openModalDialogsByDoc) __openModalDialogsByDoc.set(document, list);
|
|
374
|
-
} catch {
|
|
375
|
-
/* ignore */
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
return list;
|
|
379
|
-
};
|
|
380
|
-
|
|
381
|
-
// Flat-tree (composed) parent: a distributed/slotted node's real rendered
|
|
382
|
-
// parent is the <slot> it's assigned to, NOT its own light-DOM parentNode
|
|
383
|
-
// (parentNode is unaffected by slot assignment and stays truthy for any
|
|
384
|
-
// normally-connected slotted element — checking it first, as an earlier
|
|
385
|
-
// version of this helper did, means the assignedSlot branch never fires
|
|
386
|
-
// for the common case of a real, connected slotted child, silently
|
|
387
|
-
// treating it as if it rendered under its light-DOM parent instead of
|
|
388
|
-
// the shadow-tree container it's actually distributed into). assignedSlot
|
|
389
|
-
// must be checked first; parentNode only applies to nodes that aren't
|
|
390
|
-
// currently distributed through a slot. Once climbing reaches a
|
|
391
|
-
// ShadowRoot itself (parentNode is null there), `.host` is the shadow
|
|
392
|
-
// host element directly — NOT `getRootNode({composed:true})`, which
|
|
393
|
-
// resolves all the way to the top-level document, skipping past the
|
|
394
|
-
// immediate shadow boundary this function is trying to climb out of one
|
|
395
|
-
// level at a time.
|
|
396
|
-
const composedParent = (n) => {
|
|
397
|
-
if (!n) return null;
|
|
398
|
-
if (n.assignedSlot) return n.assignedSlot;
|
|
399
|
-
if (n.parentNode) return n.parentNode;
|
|
400
|
-
return n.host || null;
|
|
401
|
-
};
|
|
402
|
-
const ancestorsIncludingSelf = (n) => {
|
|
403
|
-
if (!n) return [];
|
|
404
|
-
// Cache ancestor chains per node, per run, to avoid repeated composed-parent walks.
|
|
405
|
-
// Deterministic: purely memoized within the current run, no cross-run persistence.
|
|
406
|
-
try {
|
|
407
|
-
if (
|
|
408
|
-
__ancestorsIncludingSelfCache &&
|
|
409
|
-
typeof __ancestorsIncludingSelfCache.get === 'function'
|
|
410
|
-
) {
|
|
411
|
-
const cached = __ancestorsIncludingSelfCache.get(n);
|
|
412
|
-
if (cached) {
|
|
413
|
-
__perfInc('ancestorsIncludingSelf.hit');
|
|
414
|
-
return cached;
|
|
415
|
-
}
|
|
416
|
-
__perfInc('ancestorsIncludingSelf.miss');
|
|
417
|
-
const out = [];
|
|
418
|
-
let cur = n,
|
|
419
|
-
guard = 0;
|
|
420
|
-
while (cur && guard++ < 200) {
|
|
421
|
-
out.push(cur);
|
|
422
|
-
cur = composedParent(cur);
|
|
423
|
-
}
|
|
424
|
-
__ancestorsIncludingSelfCache.set(n, out);
|
|
425
|
-
return out;
|
|
426
|
-
}
|
|
427
|
-
} catch {
|
|
428
|
-
/* fall through */
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
__perfInc('ancestorsIncludingSelf.nocache');
|
|
432
|
-
const out = [];
|
|
433
|
-
let cur = n,
|
|
434
|
-
guard = 0;
|
|
435
|
-
while (cur && guard++ < 200) {
|
|
436
|
-
out.push(cur);
|
|
437
|
-
cur = composedParent(cur);
|
|
438
|
-
}
|
|
439
|
-
return out;
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
function getClosestMap(el) {
|
|
443
|
-
try {
|
|
444
|
-
if (!isElement(el)) return null;
|
|
445
|
-
return el.closest ? el.closest('map') : null;
|
|
446
|
-
} catch {
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function hasBlockingInert(node) {
|
|
452
|
-
// Default behavior: inert anywhere in ancestorsIncludingSelf blocks.
|
|
453
|
-
if (!isElement(node)) return false;
|
|
454
|
-
|
|
455
|
-
const tag = (node.tagName || '').toLowerCase();
|
|
456
|
-
const isArea = tag === 'area';
|
|
457
|
-
|
|
458
|
-
let mapEl = null;
|
|
459
|
-
if (isArea) mapEl = getClosestMap(node);
|
|
460
|
-
|
|
461
|
-
const chain = ancestorsIncludingSelf(node);
|
|
462
|
-
|
|
463
|
-
for (const a of chain) {
|
|
464
|
-
if (!isElement(a)) continue;
|
|
465
|
-
|
|
466
|
-
// Exception: for <area>, inert on itself or on its <map> does NOT block
|
|
467
|
-
if (isArea) {
|
|
468
|
-
if (a === node) continue; // ignore <area inert>
|
|
469
|
-
if (mapEl && a === mapEl) continue; // ignore <map inert>
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
if (a.hasAttribute && a.hasAttribute('inert')) return true;
|
|
473
|
-
}
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
478
|
-
|
|
479
|
-
const getAttr = (el, name) => {
|
|
480
|
-
try {
|
|
481
|
-
return el && el.getAttribute ? el.getAttribute(name) : null;
|
|
482
|
-
} catch {
|
|
483
|
-
return null;
|
|
484
|
-
}
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
function parseTabIndex(el) {
|
|
488
|
-
const raw = getAttr(el, 'tabindex');
|
|
489
|
-
const t = trim(raw);
|
|
490
|
-
if (raw == null || t === '') return { has: false, value: null, valid: false };
|
|
491
|
-
const n = Number(t);
|
|
492
|
-
if (Number.isNaN(n)) return { has: true, value: null, valid: false };
|
|
493
|
-
return { has: true, value: n, valid: true };
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function getPlatformFocusability(el) {
|
|
497
|
-
// Per-run memoization (WeakMap<Element, Result>)
|
|
498
|
-
try {
|
|
499
|
-
if (__focusabilityCache && el && typeof el === 'object' && __focusabilityCache.has(el)) {
|
|
500
|
-
__perfInc('focusability.hit');
|
|
501
|
-
const c = __focusabilityCache.get(el);
|
|
502
|
-
if (c && typeof c === 'object') {
|
|
503
|
-
return {
|
|
504
|
-
focusable: !!c.focusable,
|
|
505
|
-
tabbable: !!c.tabbable,
|
|
506
|
-
mechanism: c.mechanism || 'none',
|
|
507
|
-
flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
} catch {}
|
|
512
|
-
|
|
513
|
-
__perfInc('focusability.miss');
|
|
514
|
-
let result;
|
|
515
|
-
|
|
516
|
-
if (!isElement(el)) {
|
|
517
|
-
result = { focusable: false, tabbable: false, mechanism: 'none', flags: ['notElement'] };
|
|
518
|
-
} else if (hasBlockingInert(el)) {
|
|
519
|
-
result = { focusable: false, tabbable: false, mechanism: 'none', flags: ['inert'] };
|
|
520
|
-
} else {
|
|
521
|
-
const flags = [];
|
|
522
|
-
const disabled = !!(el.matches && el.matches(':disabled'));
|
|
523
|
-
if (disabled) {
|
|
524
|
-
result = { focusable: false, tabbable: false, mechanism: 'disabled', flags: ['disabled'] };
|
|
525
|
-
} else {
|
|
526
|
-
const ti = parseTabIndex(el);
|
|
527
|
-
if (ti.has) {
|
|
528
|
-
if (!ti.valid)
|
|
529
|
-
result = {
|
|
530
|
-
focusable: false,
|
|
531
|
-
tabbable: false,
|
|
532
|
-
mechanism: 'tabindex',
|
|
533
|
-
flags: ['tabindex-invalid']
|
|
534
|
-
};
|
|
535
|
-
else if (ti.value < 0)
|
|
536
|
-
result = {
|
|
537
|
-
focusable: true,
|
|
538
|
-
tabbable: false,
|
|
539
|
-
mechanism: 'tabindex',
|
|
540
|
-
flags: ['tabindex-negative']
|
|
541
|
-
};
|
|
542
|
-
else
|
|
543
|
-
result = {
|
|
544
|
-
focusable: true,
|
|
545
|
-
tabbable: true,
|
|
546
|
-
mechanism: 'tabindex',
|
|
547
|
-
flags: ['tabindex-nonnegative']
|
|
548
|
-
};
|
|
549
|
-
} else {
|
|
550
|
-
// native focusability
|
|
551
|
-
const native = isPlatformFocusable(el); // uses your existing boolean logic
|
|
552
|
-
if (native) result = { focusable: true, tabbable: true, mechanism: 'native', flags };
|
|
553
|
-
else result = { focusable: false, tabbable: false, mechanism: 'none', flags };
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
try {
|
|
559
|
-
if (__focusabilityCache && el && typeof el === 'object') {
|
|
560
|
-
__focusabilityCache.set(el, {
|
|
561
|
-
focusable: !!result.focusable,
|
|
562
|
-
tabbable: !!result.tabbable,
|
|
563
|
-
mechanism: result.mechanism || 'none',
|
|
564
|
-
flags: Array.isArray(result.flags) ? result.flags.slice(0) : []
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
} catch {}
|
|
568
|
-
|
|
569
|
-
return {
|
|
570
|
-
focusable: !!result.focusable,
|
|
571
|
-
tabbable: !!result.tabbable,
|
|
572
|
-
mechanism: result.mechanism || 'none',
|
|
573
|
-
flags: Array.isArray(result.flags) ? result.flags.slice(0) : []
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// --- attribute ---
|
|
578
|
-
function getAttributeInfo(el, attr) {
|
|
579
|
-
const flags = [];
|
|
580
|
-
if (!isElement(el))
|
|
581
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
582
|
-
|
|
583
|
-
const attrValue = trim(getAttr(el, attr));
|
|
584
|
-
if (!attrValue) return { present: false, value: '', mechanism: attr, flags: ['empty'] };
|
|
585
|
-
|
|
586
|
-
return { present: true, value: attrValue, mechanism: attr, flags };
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// --- ARIA name primitives (reusable across checks) ---
|
|
590
|
-
function getAriaLabelInfo(el) {
|
|
591
|
-
const flags = [];
|
|
592
|
-
if (!isElement(el))
|
|
593
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
594
|
-
|
|
595
|
-
const ariaLabel = trim(getAttr(el, 'aria-label'));
|
|
596
|
-
if (!ariaLabel) return { present: false, value: '', mechanism: 'aria-label', flags: ['empty'] };
|
|
597
|
-
|
|
598
|
-
return { present: true, value: ariaLabel, mechanism: 'aria-label', flags };
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
function getAriaLabelledByInfo(el, _ctx, opts) {
|
|
602
|
-
const flags = [];
|
|
603
|
-
if (!isElement(el))
|
|
604
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
605
|
-
|
|
606
|
-
const ariaLabelledBy = trim(getAttr(el, 'aria-labelledby'));
|
|
607
|
-
if (!ariaLabelledBy)
|
|
608
|
-
return { present: false, value: '', mechanism: 'aria-labelledby', flags: ['missing'] };
|
|
609
|
-
|
|
610
|
-
const t = getTextFromIdRefs(ariaLabelledBy, _ctx, opts);
|
|
611
|
-
for (const f of t.flags) flags.push(f);
|
|
612
|
-
|
|
613
|
-
if (!t.text) flags.push('empty');
|
|
614
|
-
|
|
615
|
-
return {
|
|
616
|
-
present: !!t.text,
|
|
617
|
-
value: t.text || '',
|
|
618
|
-
mechanism: 'aria-labelledby',
|
|
619
|
-
refsCount: t.refsCount,
|
|
620
|
-
missing: t.missing ? t.missing.slice(0) : [],
|
|
621
|
-
flags
|
|
622
|
-
};
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
/**
|
|
626
|
-
* getAriaNameInfo: ARIA-only name, with correct precedence.
|
|
627
|
-
* aria-labelledby (if non-empty) wins over aria-label.
|
|
628
|
-
*/
|
|
629
|
-
function getAriaNameInfo(el, _ctx, opts) {
|
|
630
|
-
const flags = [];
|
|
631
|
-
if (!isElement(el))
|
|
632
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
633
|
-
|
|
634
|
-
const lb = getAriaLabelledByInfo(el, _ctx, opts);
|
|
635
|
-
if (lb.present && lb.value)
|
|
636
|
-
return {
|
|
637
|
-
present: true,
|
|
638
|
-
value: lb.value,
|
|
639
|
-
mechanism: 'aria-labelledby',
|
|
640
|
-
flags: flags.concat(lb.flags || [])
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
const al = getAriaLabelInfo(el);
|
|
644
|
-
if (al.present && al.value)
|
|
645
|
-
return {
|
|
646
|
-
present: true,
|
|
647
|
-
value: al.value,
|
|
648
|
-
mechanism: 'aria-label',
|
|
649
|
-
flags: flags.concat(al.flags || [])
|
|
650
|
-
};
|
|
651
|
-
|
|
652
|
-
// If aria-labelledby existed but was empty/unresolvable, preserve that info in flags.
|
|
653
|
-
if (trim(getAttr(el, 'aria-labelledby'))) flags.push('aria-labelledby-empty-or-unresolvable');
|
|
654
|
-
if (getAttr(el, 'aria-label') != null && !trim(getAttr(el, 'aria-label')))
|
|
655
|
-
flags.push('aria-label-empty');
|
|
656
|
-
|
|
657
|
-
return { present: false, value: '', mechanism: 'none', flags };
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Landmark-role naming (nav/main/region/banner/contentinfo/etc.): these roles don't derive
|
|
661
|
-
// a name from content (unlike a button/link), so per the accname spec their only sources are
|
|
662
|
-
// aria-label, aria-labelledby, then a title-attribute fallback. Was duplicated ad hoc across 7
|
|
663
|
-
// landmark rule files (landmark-unique, landmark-no-duplicate-banner/-contentinfo,
|
|
664
|
-
// landmark-banner/-main/-contentinfo-is-top-level, region), each its own local
|
|
665
|
-
// getAccessibleLandmarkName -- some using getAriaLabelledByInfo's target-name resolution,
|
|
666
|
-
// others a raw ref.textContent copy predating that fix, and NONE checking title at all.
|
|
667
|
-
// Confirmed via a real page (2026-07-22, live-DOM corpus): DuckDuckGo's homepage has two
|
|
668
|
-
// <nav>s distinguished only by title="navigation" on one of them -- a widely-used reference
|
|
669
|
-
// engine's landmark-unique correctly treats them as uniquely named (verified directly against
|
|
670
|
-
// that engine's own runtime, not assumed), while every one of the 7 local copies saw both as unnamed and flagged a false
|
|
671
|
-
// duplicate. One shared, correct implementation replaces all 7 copies.
|
|
672
|
-
function getLandmarkNameInfo(el, ctx) {
|
|
673
|
-
if (!isElement(el))
|
|
674
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
675
|
-
|
|
676
|
-
const aria = getAriaNameInfo(el, ctx);
|
|
677
|
-
if (aria.present && aria.value) return aria;
|
|
678
|
-
|
|
679
|
-
const title = trim(getAttr(el, 'title'));
|
|
680
|
-
if (title) return { present: true, value: title, mechanism: 'title', flags: aria.flags || [] };
|
|
681
|
-
|
|
682
|
-
return {
|
|
683
|
-
present: false,
|
|
684
|
-
value: '',
|
|
685
|
-
mechanism: 'none',
|
|
686
|
-
flags: (aria.flags || []).concat(
|
|
687
|
-
title === '' && getAttr(el, 'title') != null ? ['title-empty'] : []
|
|
688
|
-
)
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
const lower = (v) => trim(v).toLowerCase();
|
|
693
|
-
|
|
694
|
-
const safeDocGetById = (id) => {
|
|
695
|
-
const key = trim(id);
|
|
696
|
-
if (!key) return null;
|
|
697
|
-
|
|
698
|
-
// Shared cache (per run)
|
|
699
|
-
try {
|
|
700
|
-
if (__idLookupDocCache && __idLookupDocCache.has(key)) {
|
|
701
|
-
__perfInc('idLookup.doc.hit');
|
|
702
|
-
return __idLookupDocCache.get(key) || null;
|
|
703
|
-
}
|
|
704
|
-
} catch {}
|
|
705
|
-
|
|
706
|
-
__perfInc('idLookup.doc.miss');
|
|
707
|
-
let el = null;
|
|
708
|
-
try {
|
|
709
|
-
if (document && document.getElementById) el = document.getElementById(key);
|
|
710
|
-
} catch {
|
|
711
|
-
el = null;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
try {
|
|
715
|
-
if (__idLookupDocCache) __idLookupDocCache.set(key, el || null);
|
|
716
|
-
} catch {}
|
|
717
|
-
|
|
718
|
-
return el || null;
|
|
719
|
-
};
|
|
720
|
-
|
|
721
|
-
const safeRootQueryById = (id) => {
|
|
722
|
-
// Best-effort for cases where root is not the document (e.g. shadow root-like, fragment roots).
|
|
723
|
-
// Note: IDs are document-global in HTML, but test harnesses may use scoped roots.
|
|
724
|
-
// With multi-region contextSelector, tries each root in turn and
|
|
725
|
-
// returns the first match -- IDs are meant to be document-unique
|
|
726
|
-
// anyway, so at most one root should ever actually contain it.
|
|
727
|
-
const key = trim(id);
|
|
728
|
-
if (!key) return null;
|
|
729
|
-
if (!roots.length) return null;
|
|
730
|
-
|
|
731
|
-
try {
|
|
732
|
-
const cacheKey = '#' + key;
|
|
733
|
-
if (__idLookupRootCache && __idLookupRootCache.has(cacheKey)) {
|
|
734
|
-
__perfInc('idLookup.root.hit');
|
|
735
|
-
return __idLookupRootCache.get(cacheKey) || null;
|
|
736
|
-
}
|
|
737
|
-
} catch {}
|
|
738
|
-
|
|
739
|
-
__perfInc('idLookup.root.miss');
|
|
740
|
-
let el = null;
|
|
741
|
-
for (const r of roots) {
|
|
742
|
-
if (!r || !r.querySelector) continue;
|
|
743
|
-
try {
|
|
744
|
-
el = r.querySelector('#' + key);
|
|
745
|
-
} catch {
|
|
746
|
-
el = null;
|
|
747
|
-
}
|
|
748
|
-
if (el) break;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
try {
|
|
752
|
-
const cacheKey = '#' + key;
|
|
753
|
-
if (__idLookupRootCache) __idLookupRootCache.set(cacheKey, el || null);
|
|
754
|
-
} catch {}
|
|
755
|
-
|
|
756
|
-
return el || null;
|
|
757
|
-
};
|
|
758
|
-
|
|
759
|
-
function inClosedDetailsContent(node) {
|
|
760
|
-
try {
|
|
761
|
-
if (!isElement(node)) return false;
|
|
762
|
-
const summary = node.closest && node.closest('summary');
|
|
763
|
-
if (summary && summary.contains(node)) return false;
|
|
764
|
-
const details = node.closest && node.closest('details');
|
|
765
|
-
if (details && !details.hasAttribute('open')) return true;
|
|
766
|
-
} catch {}
|
|
767
|
-
return false;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function isPlatformFocusable(el) {
|
|
771
|
-
if (!isElement(el) || hasBlockingInert(el)) return false;
|
|
772
|
-
const tag = (el.tagName || '').toLowerCase();
|
|
773
|
-
const type = (el.getAttribute && (el.getAttribute('type') || '').toLowerCase()) || '';
|
|
774
|
-
const disabled = !!(el.matches && el.matches(':disabled'));
|
|
775
|
-
if (disabled) return false;
|
|
776
|
-
|
|
777
|
-
if (tag === 'a') {
|
|
778
|
-
const href = el.getAttribute && el.getAttribute('href');
|
|
779
|
-
if (href && href.trim()) return true;
|
|
780
|
-
}
|
|
781
|
-
if (tag === 'area') {
|
|
782
|
-
// Engine policy: treat <area> as focusable when it's part of a *used* image map.
|
|
783
|
-
const map = getClosestMap(el);
|
|
784
|
-
if (map) {
|
|
785
|
-
const rawName = (
|
|
786
|
-
map.getAttribute &&
|
|
787
|
-
(map.getAttribute('name') || map.getAttribute('id') || '')
|
|
788
|
-
).trim();
|
|
789
|
-
if (rawName && document && document.querySelector) {
|
|
790
|
-
const esc = __cssEscapeSafe;
|
|
791
|
-
const n = esc(rawName);
|
|
792
|
-
|
|
793
|
-
// Be practical: accept both "#name" and "name", and ignore case.
|
|
794
|
-
const sels = [`img[usemap="#${n}" i]`, `img[usemap="${n}" i]`];
|
|
795
|
-
|
|
796
|
-
for (const sel of sels) {
|
|
797
|
-
try {
|
|
798
|
-
if (document.querySelector(sel)) return true;
|
|
799
|
-
} catch {}
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
if (tag === 'input') {
|
|
805
|
-
if (type !== 'hidden') return true;
|
|
806
|
-
}
|
|
807
|
-
if (tag === 'select' || tag === 'textarea' || tag === 'button' || tag === 'summary')
|
|
808
|
-
return true;
|
|
809
|
-
if (el.hasAttribute && el.hasAttribute('contenteditable')) {
|
|
810
|
-
// contenteditable="false" explicitly disables the editing host
|
|
811
|
-
// and does not by itself add the element to the tab order.
|
|
812
|
-
const ceVal = lower(getAttr(el, 'contenteditable'));
|
|
813
|
-
if (ceVal !== 'false') return true;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const tabindex = el.getAttribute && el.getAttribute('tabindex');
|
|
817
|
-
if (tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(tabindex)))
|
|
818
|
-
return true;
|
|
819
|
-
|
|
820
|
-
return false;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
function getIdRefReverseIndex(scopeObj) {
|
|
824
|
-
// Reverse index: id token -> referencing elements (aria-labelledby / aria-describedby)
|
|
825
|
-
// Built once per scope per run. Deterministic: querySelectorAll order is document order.
|
|
826
|
-
if (!scopeObj || !scopeObj.querySelectorAll) return null;
|
|
827
|
-
|
|
828
|
-
if (!__idRefReverseIndexByScope) {
|
|
829
|
-
__perfInc('idrefReverseIndex.nocache');
|
|
830
|
-
return null;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
try {
|
|
834
|
-
const cached = __idRefReverseIndexByScope.get(scopeObj);
|
|
835
|
-
if (cached) {
|
|
836
|
-
__perfInc('idrefReverseIndex.hit');
|
|
837
|
-
return cached;
|
|
838
|
-
}
|
|
839
|
-
} catch {
|
|
840
|
-
__perfInc('idrefReverseIndex.nocache');
|
|
841
|
-
return null;
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
__perfInc('idrefReverseIndex.miss');
|
|
845
|
-
|
|
846
|
-
const idx = new Map();
|
|
847
|
-
let refs;
|
|
848
|
-
try {
|
|
849
|
-
refs = Array.from(scopeObj.querySelectorAll('[aria-labelledby],[aria-describedby]'));
|
|
850
|
-
} catch {
|
|
851
|
-
refs = [];
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
for (const el of refs) {
|
|
855
|
-
if (!isElement(el)) continue;
|
|
856
|
-
|
|
857
|
-
// Parse tokens deterministically
|
|
858
|
-
const lb = trim(getAttr(el, 'aria-labelledby'));
|
|
859
|
-
const db = trim(getAttr(el, 'aria-describedby'));
|
|
860
|
-
|
|
861
|
-
// Avoid pushing same element twice for the same token when both attrs contain it.
|
|
862
|
-
const pushed = new Set();
|
|
863
|
-
|
|
864
|
-
if (lb) {
|
|
865
|
-
const parts = lb.split(/\s+/).filter(Boolean);
|
|
866
|
-
for (const t of parts) {
|
|
867
|
-
const tok = trim(t);
|
|
868
|
-
if (!tok || pushed.has(tok)) continue;
|
|
869
|
-
pushed.add(tok);
|
|
870
|
-
const arr = idx.get(tok);
|
|
871
|
-
if (arr) arr.push(el);
|
|
872
|
-
else idx.set(tok, [el]);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
if (db) {
|
|
877
|
-
const parts = db.split(/\s+/).filter(Boolean);
|
|
878
|
-
for (const t of parts) {
|
|
879
|
-
const tok = trim(t);
|
|
880
|
-
if (!tok || pushed.has(tok)) continue;
|
|
881
|
-
pushed.add(tok);
|
|
882
|
-
const arr = idx.get(tok);
|
|
883
|
-
if (arr) arr.push(el);
|
|
884
|
-
else idx.set(tok, [el]);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
try {
|
|
890
|
-
__idRefReverseIndexByScope.set(scopeObj, idx);
|
|
891
|
-
__perfInc('idrefReverseIndex.build');
|
|
892
|
-
} catch {
|
|
893
|
-
// ignore cache set errors
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
return idx;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function isReferencedByVisibleIdRef(node) {
|
|
900
|
-
if (!document || !isElement(node)) return false;
|
|
901
|
-
const id = node.getAttribute && node.getAttribute('id');
|
|
902
|
-
const idTok = id && id.trim ? id.trim() : '';
|
|
903
|
-
if (!idTok) return false;
|
|
904
|
-
|
|
905
|
-
// Prefer reverse-index lookup (single build per run) over repeated querySelectorAll per node.
|
|
906
|
-
const idx = getIdRefReverseIndex(document);
|
|
907
|
-
if (idx && typeof idx.get === 'function') {
|
|
908
|
-
let refs;
|
|
909
|
-
try {
|
|
910
|
-
refs = idx.get(idTok) || null;
|
|
911
|
-
} catch {
|
|
912
|
-
refs = null;
|
|
913
|
-
}
|
|
914
|
-
if (refs && refs.length) {
|
|
915
|
-
for (const ref of refs) {
|
|
916
|
-
if (!isElement(ref)) continue;
|
|
917
|
-
const elig = isAccTreeEligible(ref); // safe recursion
|
|
918
|
-
if (elig && elig.eligible) return true;
|
|
919
|
-
}
|
|
920
|
-
return false;
|
|
921
|
-
}
|
|
922
|
-
// If index exists but no references, short-circuit.
|
|
923
|
-
return false;
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// Fallback to querySelectorAll when cache is unavailable.
|
|
927
|
-
const esc = __cssEscapeSafe;
|
|
928
|
-
const idSel = esc(idTok);
|
|
929
|
-
let refs;
|
|
930
|
-
try {
|
|
931
|
-
refs = [
|
|
932
|
-
...Array.from(document.querySelectorAll('[aria-labelledby~="' + idSel + '"]')),
|
|
933
|
-
...Array.from(document.querySelectorAll('[aria-describedby~="' + idSel + '"]'))
|
|
934
|
-
];
|
|
935
|
-
} catch {
|
|
936
|
-
refs = [];
|
|
937
|
-
}
|
|
938
|
-
for (const ref of refs) {
|
|
939
|
-
if (!isElement(ref)) continue;
|
|
940
|
-
const elig = isAccTreeEligible(ref); // safe recursion
|
|
941
|
-
if (elig && elig.eligible) return true;
|
|
942
|
-
}
|
|
943
|
-
return false;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
function isExcluded(el) {
|
|
947
|
-
const eff = __getEffectiveExcludeSelectors();
|
|
948
|
-
if (!eff.length || !el || !el.closest) return false;
|
|
949
|
-
try {
|
|
950
|
-
return eff.some((sel) => !!el.closest(sel));
|
|
951
|
-
} catch {
|
|
952
|
-
return false;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
function queryAll(sel) {
|
|
957
|
-
if (!roots.length) return [];
|
|
958
|
-
const out = [];
|
|
959
|
-
const seen = new Set();
|
|
960
|
-
// Per root: self-match first (matching the original single-root
|
|
961
|
-
// ordering), then descendants, deduped across all roots -- matters
|
|
962
|
-
// when multiple contextSelector regions overlap/nest, so an element
|
|
963
|
-
// reachable from more than one root is only ever reported once.
|
|
964
|
-
for (const r of roots) {
|
|
965
|
-
if (!r) continue;
|
|
966
|
-
// querySelectorAll never returns its own context node, only
|
|
967
|
-
// descendants — so an attribute/role selector can never match
|
|
968
|
-
// `r` itself this way. In the default (unscoped) case `r` is
|
|
969
|
-
// `document.documentElement` (the <html> element), meaning every
|
|
970
|
-
// rule using this helper was structurally blind to an issue
|
|
971
|
-
// asserted directly on <html> (e.g. `<html role="...">`,
|
|
972
|
-
// `[lang]`, any `[aria-*]`) — not a narrow, rule-specific gap.
|
|
973
|
-
// Found via a real page: news24.com's South Africa homepage,
|
|
974
|
-
// `<html role="document">`, which a widely-used reference engine correctly flags but
|
|
975
|
-
// this engine's aria-allowed-role couldn't reach at all, no
|
|
976
|
-
// matter how correct its ALLOWED_ROLES_BY_ELEMENT entry was.
|
|
977
|
-
if (r.nodeType === 1 && typeof r.matches === 'function' && !seen.has(r)) {
|
|
978
|
-
try {
|
|
979
|
-
if (r.matches(sel)) {
|
|
980
|
-
seen.add(r);
|
|
981
|
-
out.push(r);
|
|
982
|
-
}
|
|
983
|
-
} catch {}
|
|
984
|
-
}
|
|
985
|
-
try {
|
|
986
|
-
const list = r.querySelectorAll(sel);
|
|
987
|
-
for (const el of list) {
|
|
988
|
-
if (el && !seen.has(el)) {
|
|
989
|
-
seen.add(el);
|
|
990
|
-
out.push(el);
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
} catch {
|
|
994
|
-
// skip this root, keep results from the others
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
return out;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
function queryAllDeep(sel) {
|
|
1001
|
-
if (!roots.length) return [];
|
|
1002
|
-
// Performance note:
|
|
1003
|
-
// Avoid the old "querySelectorAll('*')" approach which is O(N) per shadow host
|
|
1004
|
-
// and explodes on huge DOMs. Instead, walk shadow roots only and run the selector
|
|
1005
|
-
// in each root once. This keeps work proportional to the number of shadow roots.
|
|
1006
|
-
const results = [];
|
|
1007
|
-
const seen = new Set();
|
|
1008
|
-
const visitedRoots = new Set();
|
|
1009
|
-
|
|
1010
|
-
const pushMatches = (scope) => {
|
|
1011
|
-
if (!scope || !scope.querySelectorAll) return;
|
|
1012
|
-
let els;
|
|
1013
|
-
try {
|
|
1014
|
-
els = scope.querySelectorAll(sel);
|
|
1015
|
-
} catch {
|
|
1016
|
-
els = [];
|
|
1017
|
-
}
|
|
1018
|
-
// NodeList is iterable; avoid Array.from to reduce allocations.
|
|
1019
|
-
for (const el of els) {
|
|
1020
|
-
if (el && !seen.has(el) && !isExcluded(el)) {
|
|
1021
|
-
seen.add(el);
|
|
1022
|
-
results.push(el);
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
// Same root-self-match gap as queryAll above: querySelectorAll
|
|
1026
|
-
// never returns `scope` itself, so a shadow-root host element
|
|
1027
|
-
// (or the top-level <html> root) matching `sel` directly would
|
|
1028
|
-
// otherwise be invisible here too.
|
|
1029
|
-
if (
|
|
1030
|
-
scope.nodeType === 1 &&
|
|
1031
|
-
typeof scope.matches === 'function' &&
|
|
1032
|
-
!seen.has(scope) &&
|
|
1033
|
-
!isExcluded(scope)
|
|
1034
|
-
) {
|
|
1035
|
-
try {
|
|
1036
|
-
if (scope.matches(sel)) {
|
|
1037
|
-
seen.add(scope);
|
|
1038
|
-
results.push(scope);
|
|
1039
|
-
}
|
|
1040
|
-
} catch {}
|
|
1041
|
-
}
|
|
1042
|
-
};
|
|
1043
|
-
|
|
1044
|
-
const collectShadowRoots = (scope) => {
|
|
1045
|
-
if (!scope || !scope.querySelectorAll) return [];
|
|
1046
|
-
|
|
1047
|
-
// Cache shadow root discovery per root to avoid repeated querySelectorAll('*') walks.
|
|
1048
|
-
// IMPORTANT: do not cache when the effective exclude list (global
|
|
1049
|
-
// ∪ active rule-scoped excludes) is non-empty -- different rules
|
|
1050
|
-
// may have different effective lists and must not share results.
|
|
1051
|
-
if (!__getEffectiveExcludeSelectors().length && __shadowRootsByRoot) {
|
|
1052
|
-
try {
|
|
1053
|
-
const cached = __shadowRootsByRoot.get(scope);
|
|
1054
|
-
if (cached) {
|
|
1055
|
-
__perfInc('shadowRoots.hit');
|
|
1056
|
-
return cached;
|
|
1057
|
-
}
|
|
1058
|
-
__perfInc('shadowRoots.miss');
|
|
1059
|
-
|
|
1060
|
-
let hosts = [];
|
|
1061
|
-
try {
|
|
1062
|
-
hosts = scope.querySelectorAll('*');
|
|
1063
|
-
} catch {
|
|
1064
|
-
hosts = [];
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
const roots = [];
|
|
1068
|
-
for (const el of hosts) {
|
|
1069
|
-
if (!el || el.nodeType !== 1) continue;
|
|
1070
|
-
const sr = el.shadowRoot;
|
|
1071
|
-
if (sr) roots.push(sr);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
try {
|
|
1075
|
-
__shadowRootsByRoot.set(scope, roots);
|
|
1076
|
-
} catch {
|
|
1077
|
-
__perfInc('shadowRoots.nocache');
|
|
1078
|
-
}
|
|
1079
|
-
return roots;
|
|
1080
|
-
} catch {
|
|
1081
|
-
__perfInc('shadowRoots.nocache');
|
|
1082
|
-
// fall through to uncached path
|
|
1083
|
-
}
|
|
1084
|
-
} else {
|
|
1085
|
-
__perfInc('shadowRoots.nocache');
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Uncached path (preserves excludeSelectors filtering semantics).
|
|
1089
|
-
let hosts;
|
|
1090
|
-
try {
|
|
1091
|
-
hosts = scope.querySelectorAll('*');
|
|
1092
|
-
} catch {
|
|
1093
|
-
hosts = [];
|
|
1094
|
-
}
|
|
1095
|
-
const roots = [];
|
|
1096
|
-
for (const el of hosts) {
|
|
1097
|
-
if (!el || el.nodeType !== 1) continue;
|
|
1098
|
-
if (isExcluded(el)) continue;
|
|
1099
|
-
const sr = el.shadowRoot;
|
|
1100
|
-
if (sr) roots.push(sr);
|
|
1101
|
-
}
|
|
1102
|
-
return roots;
|
|
1103
|
-
};
|
|
1104
|
-
|
|
1105
|
-
// Seed the BFS queue with every resolved context root (not just one)
|
|
1106
|
-
// -- the existing shadow-root discovery loop below already
|
|
1107
|
-
// generalizes to multiple starting points without further changes,
|
|
1108
|
-
// since it was already a growing queue, not a single fixed root.
|
|
1109
|
-
const q = roots.slice();
|
|
1110
|
-
for (let qi = 0; qi < q.length; qi++) {
|
|
1111
|
-
const curRoot = q[qi];
|
|
1112
|
-
if (!curRoot || visitedRoots.has(curRoot)) continue;
|
|
1113
|
-
visitedRoots.add(curRoot);
|
|
1114
|
-
|
|
1115
|
-
pushMatches(curRoot);
|
|
1116
|
-
|
|
1117
|
-
const childShadowRoots = collectShadowRoots(curRoot);
|
|
1118
|
-
for (const sr of childShadowRoots) q.push(sr);
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
return results;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
const HARD_HIDDEN_REASONS = new Set([
|
|
1125
|
-
'displayNone',
|
|
1126
|
-
'hiddenAttr',
|
|
1127
|
-
'detailsClosed',
|
|
1128
|
-
'templateContent',
|
|
1129
|
-
'nonRenderedElement',
|
|
1130
|
-
'inputHidden',
|
|
1131
|
-
'visibilityHidden',
|
|
1132
|
-
'contentVisibilityHidden'
|
|
1133
|
-
]);
|
|
1134
|
-
|
|
1135
|
-
function queryAllSmart(sel) {
|
|
1136
|
-
let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
|
|
1137
|
-
|
|
1138
|
-
// Global hidden-content policy: skip nodes that are fully excluded from
|
|
1139
|
-
// rendered visibility by default (unless includeHiddenElements:true).
|
|
1140
|
-
if (!includeHiddenElements) {
|
|
1141
|
-
list = list.filter((el) => {
|
|
1142
|
-
try {
|
|
1143
|
-
const vis = isAccTreeEligible(el);
|
|
1144
|
-
if (!vis || vis.eligible !== false) return true;
|
|
1145
|
-
const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
|
|
1146
|
-
for (const r of reasons) {
|
|
1147
|
-
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// `isAccTreeEligible` can short-circuit on an inert ancestor
|
|
1151
|
-
// before it reaches an outer hard-hidden ancestor (e.g.
|
|
1152
|
-
// display:none wrapper). In that case the node is still
|
|
1153
|
-
// structurally hidden and should be excluded by the default
|
|
1154
|
-
// hidden-content policy.
|
|
1155
|
-
if (reasons.includes('inert')) {
|
|
1156
|
-
const domVis = isDomVisibleEligible(el, null, {
|
|
1157
|
-
visibilityMode: 'styleOnly',
|
|
1158
|
-
disableGeometry: true,
|
|
1159
|
-
ignoreOpacity: true
|
|
1160
|
-
});
|
|
1161
|
-
const domReasons = Array.isArray(domVis && domVis.reasons) ? domVis.reasons : [];
|
|
1162
|
-
for (const r of domReasons) {
|
|
1163
|
-
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
return true;
|
|
1167
|
-
} catch {
|
|
1168
|
-
return true;
|
|
1169
|
-
}
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// -------------------------------------------------------------------------
|
|
1177
|
-
// Per-run shared caches (DOM helpers)
|
|
1178
|
-
// -------------------------------------------------------------------------
|
|
1179
|
-
try {
|
|
1180
|
-
const w =
|
|
1181
|
-
realmWindow ||
|
|
1182
|
-
(document && document.defaultView) ||
|
|
1183
|
-
(typeof global !== 'undefined' && global.window ? global.window : null);
|
|
1184
|
-
|
|
1185
|
-
if (w) {
|
|
1186
|
-
if (!w.__a11ycoreSharedCache) w.__a11ycoreSharedCache = {};
|
|
1187
|
-
if (!w.__a11ycoreSharedCache.dom) w.__a11ycoreSharedCache.dom = {};
|
|
1188
|
-
__domSharedCache = w.__a11ycoreSharedCache.dom;
|
|
1189
|
-
}
|
|
1190
|
-
} catch {
|
|
1191
|
-
__domSharedCache = {};
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
// Selector cache (per element), partitioned by __selectorOptsKey since
|
|
1195
|
-
// built selectors depend on includeShadowDom/excludeSelectors.
|
|
1196
|
-
try {
|
|
1197
|
-
__selectorCache =
|
|
1198
|
-
__domSharedCache.selectorCache instanceof Map
|
|
1199
|
-
? __domSharedCache.selectorCache
|
|
1200
|
-
: (__domSharedCache.selectorCache = new Map());
|
|
1201
|
-
} catch {
|
|
1202
|
-
__selectorCache = null;
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
function __getSelectorCacheForOpts() {
|
|
1206
|
-
if (!__selectorCache) return null;
|
|
1207
|
-
try {
|
|
1208
|
-
const key = __getSelectorOptsKey();
|
|
1209
|
-
let wm = __selectorCache.get(key);
|
|
1210
|
-
if (!(wm instanceof WeakMap)) {
|
|
1211
|
-
wm = new WeakMap();
|
|
1212
|
-
__selectorCache.set(key, wm);
|
|
1213
|
-
}
|
|
1214
|
-
return wm;
|
|
1215
|
-
} catch {
|
|
1216
|
-
return null;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
try {
|
|
1221
|
-
__outerHtmlCache =
|
|
1222
|
-
__domSharedCache.outerHtmlCache instanceof WeakMap
|
|
1223
|
-
? __domSharedCache.outerHtmlCache
|
|
1224
|
-
: (__domSharedCache.outerHtmlCache = new WeakMap());
|
|
1225
|
-
} catch {
|
|
1226
|
-
__outerHtmlCache = null;
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// ID lookups: cache getElementById / root.querySelector(#id) results within a run
|
|
1230
|
-
try {
|
|
1231
|
-
__idLookupDocCache =
|
|
1232
|
-
__domSharedCache.idLookupDocCache instanceof Map
|
|
1233
|
-
? __domSharedCache.idLookupDocCache
|
|
1234
|
-
: (__domSharedCache.idLookupDocCache = new Map());
|
|
1235
|
-
} catch {
|
|
1236
|
-
__idLookupDocCache = null;
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
try {
|
|
1240
|
-
__idLookupRootCache =
|
|
1241
|
-
__domSharedCache.idLookupRootCache instanceof Map
|
|
1242
|
-
? __domSharedCache.idLookupRootCache
|
|
1243
|
-
: (__domSharedCache.idLookupRootCache = new Map());
|
|
1244
|
-
} catch {
|
|
1245
|
-
__idLookupRootCache = null;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
// IDREF resolution: cache resolveIdRefs results (root-scoped) within a run
|
|
1249
|
-
try {
|
|
1250
|
-
__idRefCacheByRoot =
|
|
1251
|
-
__domSharedCache.idRefCacheByRoot instanceof WeakMap
|
|
1252
|
-
? __domSharedCache.idRefCacheByRoot
|
|
1253
|
-
: (__domSharedCache.idRefCacheByRoot = new WeakMap());
|
|
1254
|
-
} catch {
|
|
1255
|
-
__idRefCacheByRoot = null;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
// Reverse index for aria-labelledby/aria-describedby -> id token
|
|
1259
|
-
try {
|
|
1260
|
-
__idRefReverseIndexByScope =
|
|
1261
|
-
__domSharedCache.idRefReverseIndexByScope instanceof WeakMap
|
|
1262
|
-
? __domSharedCache.idRefReverseIndexByScope
|
|
1263
|
-
: (__domSharedCache.idRefReverseIndexByScope = new WeakMap());
|
|
1264
|
-
} catch {
|
|
1265
|
-
__idRefReverseIndexByScope = null;
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
// Selector uniqueness index (per scope) within a run
|
|
1269
|
-
try {
|
|
1270
|
-
__uniqIndexByScope =
|
|
1271
|
-
__domSharedCache.uniqIndexByScope instanceof WeakMap
|
|
1272
|
-
? __domSharedCache.uniqIndexByScope
|
|
1273
|
-
: (__domSharedCache.uniqIndexByScope = new WeakMap());
|
|
1274
|
-
} catch {
|
|
1275
|
-
__uniqIndexByScope = null;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// Shadow root discovery cache (per root) within a run.
|
|
1279
|
-
// Only used when excludeSelectors is empty to avoid cross-helper bleed.
|
|
1280
|
-
try {
|
|
1281
|
-
__shadowRootsByRoot =
|
|
1282
|
-
__domSharedCache.shadowRootsByRoot instanceof WeakMap
|
|
1283
|
-
? __domSharedCache.shadowRootsByRoot
|
|
1284
|
-
: (__domSharedCache.shadowRootsByRoot = new WeakMap());
|
|
1285
|
-
} catch {
|
|
1286
|
-
__shadowRootsByRoot = null;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// -------------------------------------------------------------------------
|
|
1290
|
-
// Additional per-run caches (eligibility / focusability / labeling)
|
|
1291
|
-
// -------------------------------------------------------------------------
|
|
1292
|
-
let __ancestorsIncludingSelfCache = null;
|
|
1293
|
-
let __eligibilityAccCache = null;
|
|
1294
|
-
let __eligibilityDomCacheByMode = null; // Map<string, WeakMap<Element, Result>>
|
|
1295
|
-
let __focusabilityCache = null;
|
|
1296
|
-
let __visibilityHintsCache = null; // WeakMap<Element, {hints:Array<string>, metrics:object}>
|
|
1297
|
-
let __computedStyleCacheByScope = null; // WeakMap<object, WeakMap<Element, CSSStyleDeclaration|object>>
|
|
1298
|
-
let __openModalDialogsByDoc = null; // WeakMap<Document, Array<Element>>
|
|
1299
|
-
let __ancestorBlockerAccByScope = null; // WeakMap<object, WeakMap<Element, {struct:string|null, css:string|null}>>
|
|
1300
|
-
let __ancestorBlockerDomByScope = null; // WeakMap<object, WeakMap<Element, {struct, css, cssKnown, visibility, contentVisHidden, opacity}>>
|
|
1301
|
-
let __ancestorBlockerDomStructFinalByScope = null; // WeakMap<object, WeakMap<Element, string|null>> (final structural blocker per element per scope)
|
|
1302
|
-
let __labelAssociationCache = null;
|
|
1303
|
-
let __labelMethodCache = null;
|
|
1304
|
-
let __labelForIndexByDoc = null; // WeakMap<Document, Map<string, {exists:boolean, text:string}>> (label[for] by id cache)
|
|
1305
|
-
let __accessibleNameCacheByKey = null; // Map<string, WeakMap<Element, Info>>
|
|
1306
|
-
let __accessibleDescCacheByKey = null; // Map<string, WeakMap<Element, Info>>
|
|
1307
|
-
|
|
1308
|
-
try {
|
|
1309
|
-
__ancestorsIncludingSelfCache =
|
|
1310
|
-
__domSharedCache.ancestorsIncludingSelfCache instanceof WeakMap
|
|
1311
|
-
? __domSharedCache.ancestorsIncludingSelfCache
|
|
1312
|
-
: (__domSharedCache.ancestorsIncludingSelfCache = new WeakMap());
|
|
1313
|
-
} catch {
|
|
1314
|
-
__ancestorsIncludingSelfCache = null;
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
try {
|
|
1318
|
-
__eligibilityAccCache =
|
|
1319
|
-
__domSharedCache.eligibilityAccCache instanceof WeakMap
|
|
1320
|
-
? __domSharedCache.eligibilityAccCache
|
|
1321
|
-
: (__domSharedCache.eligibilityAccCache = new WeakMap());
|
|
1322
|
-
} catch {
|
|
1323
|
-
__eligibilityAccCache = null;
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
try {
|
|
1327
|
-
__eligibilityDomCacheByMode =
|
|
1328
|
-
__domSharedCache.eligibilityDomCacheByMode instanceof Map
|
|
1329
|
-
? __domSharedCache.eligibilityDomCacheByMode
|
|
1330
|
-
: (__domSharedCache.eligibilityDomCacheByMode = new Map());
|
|
1331
|
-
} catch {
|
|
1332
|
-
__eligibilityDomCacheByMode = null;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
try {
|
|
1336
|
-
__focusabilityCache =
|
|
1337
|
-
__domSharedCache.focusabilityCache instanceof WeakMap
|
|
1338
|
-
? __domSharedCache.focusabilityCache
|
|
1339
|
-
: (__domSharedCache.focusabilityCache = new WeakMap());
|
|
1340
|
-
} catch {
|
|
1341
|
-
__focusabilityCache = null;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
try {
|
|
1345
|
-
__visibilityHintsCache =
|
|
1346
|
-
__domSharedCache.visibilityHintsCache instanceof WeakMap
|
|
1347
|
-
? __domSharedCache.visibilityHintsCache
|
|
1348
|
-
: (__domSharedCache.visibilityHintsCache = new WeakMap());
|
|
1349
|
-
} catch {
|
|
1350
|
-
__visibilityHintsCache = null;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
try {
|
|
1354
|
-
__computedStyleCacheByScope =
|
|
1355
|
-
__domSharedCache.computedStyleCacheByScope instanceof WeakMap
|
|
1356
|
-
? __domSharedCache.computedStyleCacheByScope
|
|
1357
|
-
: (__domSharedCache.computedStyleCacheByScope = new WeakMap());
|
|
1358
|
-
} catch {
|
|
1359
|
-
__computedStyleCacheByScope = null;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
try {
|
|
1363
|
-
__openModalDialogsByDoc =
|
|
1364
|
-
__domSharedCache.openModalDialogsByDoc instanceof WeakMap
|
|
1365
|
-
? __domSharedCache.openModalDialogsByDoc
|
|
1366
|
-
: (__domSharedCache.openModalDialogsByDoc = new WeakMap());
|
|
1367
|
-
} catch {
|
|
1368
|
-
__openModalDialogsByDoc = null;
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
try {
|
|
1372
|
-
__ancestorBlockerAccByScope =
|
|
1373
|
-
__domSharedCache.ancestorBlockerAccByScope instanceof WeakMap
|
|
1374
|
-
? __domSharedCache.ancestorBlockerAccByScope
|
|
1375
|
-
: (__domSharedCache.ancestorBlockerAccByScope = new WeakMap());
|
|
1376
|
-
} catch {
|
|
1377
|
-
__ancestorBlockerAccByScope = null;
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
try {
|
|
1381
|
-
__ancestorBlockerDomByScope =
|
|
1382
|
-
__domSharedCache.ancestorBlockerDomByScope instanceof WeakMap
|
|
1383
|
-
? __domSharedCache.ancestorBlockerDomByScope
|
|
1384
|
-
: (__domSharedCache.ancestorBlockerDomByScope = new WeakMap());
|
|
1385
|
-
} catch {
|
|
1386
|
-
__ancestorBlockerDomByScope = null;
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
try {
|
|
1390
|
-
__ancestorBlockerDomStructFinalByScope =
|
|
1391
|
-
__domSharedCache.ancestorBlockerDomStructFinalByScope instanceof WeakMap
|
|
1392
|
-
? __domSharedCache.ancestorBlockerDomStructFinalByScope
|
|
1393
|
-
: (__domSharedCache.ancestorBlockerDomStructFinalByScope = new WeakMap());
|
|
1394
|
-
} catch {
|
|
1395
|
-
__ancestorBlockerDomStructFinalByScope = null;
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
try {
|
|
1399
|
-
__labelAssociationCache =
|
|
1400
|
-
__domSharedCache.labelAssociationCache instanceof WeakMap
|
|
1401
|
-
? __domSharedCache.labelAssociationCache
|
|
1402
|
-
: (__domSharedCache.labelAssociationCache = new WeakMap());
|
|
1403
|
-
} catch {
|
|
1404
|
-
__labelAssociationCache = null;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
try {
|
|
1408
|
-
__labelMethodCache =
|
|
1409
|
-
__domSharedCache.labelMethodCache instanceof WeakMap
|
|
1410
|
-
? __domSharedCache.labelMethodCache
|
|
1411
|
-
: (__domSharedCache.labelMethodCache = new WeakMap());
|
|
1412
|
-
} catch {
|
|
1413
|
-
__labelMethodCache = null;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
try {
|
|
1417
|
-
__labelForIndexByDoc =
|
|
1418
|
-
__domSharedCache.labelForIndexByDoc instanceof WeakMap
|
|
1419
|
-
? __domSharedCache.labelForIndexByDoc
|
|
1420
|
-
: (__domSharedCache.labelForIndexByDoc = new WeakMap());
|
|
1421
|
-
} catch {
|
|
1422
|
-
__labelForIndexByDoc = null;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
try {
|
|
1426
|
-
__accessibleNameCacheByKey =
|
|
1427
|
-
__domSharedCache.accessibleNameCacheByKey instanceof Map
|
|
1428
|
-
? __domSharedCache.accessibleNameCacheByKey
|
|
1429
|
-
: (__domSharedCache.accessibleNameCacheByKey = new Map());
|
|
1430
|
-
} catch {
|
|
1431
|
-
__accessibleNameCacheByKey = null;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
try {
|
|
1435
|
-
__accessibleDescCacheByKey =
|
|
1436
|
-
__domSharedCache.accessibleDescCacheByKey instanceof Map
|
|
1437
|
-
? __domSharedCache.accessibleDescCacheByKey
|
|
1438
|
-
: (__domSharedCache.accessibleDescCacheByKey = new Map());
|
|
1439
|
-
} catch {
|
|
1440
|
-
__accessibleDescCacheByKey = null;
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
function __getScopeObj() {
|
|
1444
|
-
// Purely a cache-partition key -- doesn't need to BE a real scan
|
|
1445
|
-
// scope, just a value that's stable for this run and distinct across
|
|
1446
|
-
// runs with a different root set. `roots` itself (the array) is a
|
|
1447
|
-
// stable reference for the whole run when there's more than one.
|
|
1448
|
-
if (roots.length === 1) return roots[0];
|
|
1449
|
-
if (roots.length > 1) return roots;
|
|
1450
|
-
return document && typeof document === 'object' ? document : null;
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
function __getLabelForByIdCache(nameKey) {
|
|
1454
|
-
// Document-scoped cache for `document.querySelector('label[for="..."]')`.
|
|
1455
|
-
// Keeps test semantics (first lookup uses querySelector) while eliminating repeated lookups.
|
|
1456
|
-
if (!document || !document.querySelector) return null;
|
|
1457
|
-
if (!__labelForIndexByDoc) {
|
|
1458
|
-
__perfInc('labelForById.nocache');
|
|
1459
|
-
return null;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
try {
|
|
1463
|
-
const nk = nameKey == null ? '__default__' : String(nameKey);
|
|
1464
|
-
let byKey = __labelForIndexByDoc.get(document);
|
|
1465
|
-
if (!(byKey instanceof Map)) {
|
|
1466
|
-
__perfInc('labelForById.miss');
|
|
1467
|
-
byKey = new Map();
|
|
1468
|
-
__labelForIndexByDoc.set(document, byKey);
|
|
1469
|
-
__perfInc('labelForById.build');
|
|
1470
|
-
}
|
|
1471
|
-
const existing = byKey.get(nk);
|
|
1472
|
-
if (existing && existing instanceof Map) {
|
|
1473
|
-
__perfInc('labelForById.hit');
|
|
1474
|
-
return existing;
|
|
1475
|
-
}
|
|
1476
|
-
__perfInc('labelForById.miss');
|
|
1477
|
-
const map = new Map();
|
|
1478
|
-
byKey.set(nk, map);
|
|
1479
|
-
__perfInc('labelForById.build');
|
|
1480
|
-
return map;
|
|
1481
|
-
} catch {
|
|
1482
|
-
__perfInc('labelForById.nocache');
|
|
1483
|
-
return null;
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
function __lookupLabelForId(id, nameKey) {
|
|
1488
|
-
const key = trim(id);
|
|
1489
|
-
if (!key) return null;
|
|
1490
|
-
|
|
1491
|
-
const map = __getLabelForByIdCache(nameKey);
|
|
1492
|
-
if (map) {
|
|
1493
|
-
if (map.has(key)) return map.get(key) || null;
|
|
1494
|
-
// compute and store
|
|
1495
|
-
let entry;
|
|
1496
|
-
try {
|
|
1497
|
-
const sel = 'label[for="' + key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
|
|
1498
|
-
const label = document.querySelector(sel);
|
|
1499
|
-
if (label && isElement(label)) {
|
|
1500
|
-
let t = '';
|
|
1501
|
-
try {
|
|
1502
|
-
t = trim(label.textContent);
|
|
1503
|
-
} catch {
|
|
1504
|
-
t = '';
|
|
1505
|
-
}
|
|
1506
|
-
entry = { exists: true, text: t };
|
|
1507
|
-
} else {
|
|
1508
|
-
entry = { exists: false, text: '' };
|
|
1509
|
-
}
|
|
1510
|
-
} catch {
|
|
1511
|
-
entry = { exists: false, text: '' };
|
|
1512
|
-
}
|
|
1513
|
-
try {
|
|
1514
|
-
map.set(key, entry);
|
|
1515
|
-
} catch {}
|
|
1516
|
-
return entry && entry.exists ? entry : null;
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// No cache available: fallback to direct querySelector
|
|
1520
|
-
try {
|
|
1521
|
-
const sel = 'label[for="' + key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]';
|
|
1522
|
-
const label = document.querySelector(sel);
|
|
1523
|
-
if (label && isElement(label)) {
|
|
1524
|
-
let t = '';
|
|
1525
|
-
try {
|
|
1526
|
-
t = trim(label.textContent);
|
|
1527
|
-
} catch {
|
|
1528
|
-
t = '';
|
|
1529
|
-
}
|
|
1530
|
-
return { exists: true, text: t };
|
|
1531
|
-
}
|
|
1532
|
-
} catch {}
|
|
1533
|
-
return null;
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
function __getEligibilityAccCacheForScope() {
|
|
1537
|
-
const scopeObj = __getScopeObj();
|
|
1538
|
-
if (!scopeObj || !__domSharedCache) return null;
|
|
1539
|
-
try {
|
|
1540
|
-
const wmByScope =
|
|
1541
|
-
__domSharedCache.eligibilityAccCacheByScope instanceof WeakMap
|
|
1542
|
-
? __domSharedCache.eligibilityAccCacheByScope
|
|
1543
|
-
: (__domSharedCache.eligibilityAccCacheByScope = new WeakMap());
|
|
1544
|
-
|
|
1545
|
-
let perScope = wmByScope.get(scopeObj);
|
|
1546
|
-
if (!(perScope instanceof WeakMap)) {
|
|
1547
|
-
perScope = new WeakMap();
|
|
1548
|
-
wmByScope.set(scopeObj, perScope);
|
|
1549
|
-
}
|
|
1550
|
-
return perScope;
|
|
1551
|
-
} catch {
|
|
1552
|
-
return null;
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
function __getEligibilityDomCacheForScope(modeKey) {
|
|
1557
|
-
const scopeObj = __getScopeObj();
|
|
1558
|
-
if (!scopeObj || !__domSharedCache) return null;
|
|
1559
|
-
try {
|
|
1560
|
-
const wmByScope =
|
|
1561
|
-
__domSharedCache.eligibilityDomCacheByScope instanceof WeakMap
|
|
1562
|
-
? __domSharedCache.eligibilityDomCacheByScope
|
|
1563
|
-
: (__domSharedCache.eligibilityDomCacheByScope = new WeakMap());
|
|
1564
|
-
|
|
1565
|
-
let perScopeMap = wmByScope.get(scopeObj);
|
|
1566
|
-
if (!(perScopeMap instanceof Map)) {
|
|
1567
|
-
perScopeMap = new Map();
|
|
1568
|
-
wmByScope.set(scopeObj, perScopeMap);
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
let perMode = perScopeMap.get(modeKey);
|
|
1572
|
-
if (!(perMode instanceof WeakMap)) {
|
|
1573
|
-
perMode = new WeakMap();
|
|
1574
|
-
perScopeMap.set(modeKey, perMode);
|
|
1575
|
-
}
|
|
1576
|
-
return perMode;
|
|
1577
|
-
} catch {
|
|
1578
|
-
return null;
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
function __getAncestorBlockerAccCacheForScope() {
|
|
1583
|
-
const scopeObj = __getScopeObj();
|
|
1584
|
-
if (!scopeObj || !__ancestorBlockerAccByScope) return null;
|
|
1585
|
-
try {
|
|
1586
|
-
let perScope = __ancestorBlockerAccByScope.get(scopeObj);
|
|
1587
|
-
if (!(perScope instanceof WeakMap)) {
|
|
1588
|
-
perScope = new WeakMap();
|
|
1589
|
-
__ancestorBlockerAccByScope.set(scopeObj, perScope);
|
|
1590
|
-
}
|
|
1591
|
-
return perScope;
|
|
1592
|
-
} catch {
|
|
1593
|
-
return null;
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
function __getAncestorBlockerDomCacheForScope() {
|
|
1598
|
-
const scopeObj = __getScopeObj();
|
|
1599
|
-
if (!scopeObj || !__ancestorBlockerDomByScope) return null;
|
|
1600
|
-
try {
|
|
1601
|
-
let perScope = __ancestorBlockerDomByScope.get(scopeObj);
|
|
1602
|
-
if (!(perScope instanceof WeakMap)) {
|
|
1603
|
-
perScope = new WeakMap();
|
|
1604
|
-
__ancestorBlockerDomByScope.set(scopeObj, perScope);
|
|
1605
|
-
}
|
|
1606
|
-
return perScope;
|
|
1607
|
-
} catch {
|
|
1608
|
-
return null;
|
|
1609
|
-
}
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
function __getAncestorBlockerDomStructFinalCacheForScope() {
|
|
1613
|
-
const scopeObj = __getScopeObj();
|
|
1614
|
-
if (!scopeObj || !__ancestorBlockerDomStructFinalByScope) return null;
|
|
1615
|
-
try {
|
|
1616
|
-
let perScope = __ancestorBlockerDomStructFinalByScope.get(scopeObj);
|
|
1617
|
-
if (!(perScope instanceof WeakMap)) {
|
|
1618
|
-
perScope = new WeakMap();
|
|
1619
|
-
__ancestorBlockerDomStructFinalByScope.set(scopeObj, perScope);
|
|
1620
|
-
}
|
|
1621
|
-
return perScope;
|
|
1622
|
-
} catch {
|
|
1623
|
-
return null;
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
function __getDomEligibilityModeKey(opts) {
|
|
1628
|
-
const mode =
|
|
1629
|
-
opts && opts.visibilityMode === 'styleAndGeometry' ? 'styleAndGeometry' : 'styleOnly';
|
|
1630
|
-
const disableGeometry = !!(opts && opts.disableGeometry === true);
|
|
1631
|
-
return mode + '|' + (disableGeometry ? 'dg1' : 'dg0');
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
function __getNameOptsKey(opts) {
|
|
1635
|
-
// Only include options that affect this helper's output.
|
|
1636
|
-
const disallowContents = !!(opts && opts.disallowContents === true);
|
|
1637
|
-
const maxRefs = opts && opts.maxRefs != null ? Number(opts.maxRefs) | 0 : -1;
|
|
1638
|
-
return (disallowContents ? 'dc1' : 'dc0') + '|mr' + String(maxRefs);
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
function __getDescOptsKey(opts) {
|
|
1642
|
-
const allowTitle = !!(opts && opts.allowTitle === true);
|
|
1643
|
-
const maxRefs = opts && opts.maxRefs != null ? Number(opts.maxRefs) | 0 : -1;
|
|
1644
|
-
return (allowTitle ? 'at1' : 'at0') + '|mr' + String(maxRefs);
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
function getOuterHtmlSnippet(el) {
|
|
1648
|
-
if (!el || typeof el !== 'object') return '';
|
|
1649
|
-
try {
|
|
1650
|
-
if (__outerHtmlCache && __outerHtmlCache.has(el)) {
|
|
1651
|
-
__perfInc('outerHtml.hit');
|
|
1652
|
-
return __outerHtmlCache.get(el) || '';
|
|
1653
|
-
}
|
|
1654
|
-
} catch {}
|
|
1655
|
-
|
|
1656
|
-
__perfInc('outerHtml.miss');
|
|
1657
|
-
|
|
1658
|
-
let out;
|
|
1659
|
-
try {
|
|
1660
|
-
const html = el.outerHTML || '';
|
|
1661
|
-
if (html.length > 2000) out = html.slice(0, 2000) + '…';
|
|
1662
|
-
else out = html;
|
|
1663
|
-
} catch {
|
|
1664
|
-
out = '';
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
try {
|
|
1668
|
-
if (__outerHtmlCache && el && typeof el === 'object') __outerHtmlCache.set(el, out);
|
|
1669
|
-
} catch {}
|
|
1670
|
-
return out;
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
// --- Accessibility-tree eligibility (ordered checks) ---
|
|
1674
|
-
function isAccTreeEligible(node) {
|
|
1675
|
-
// Cache is per-scope (root/document) to avoid cross-run leakage.
|
|
1676
|
-
const __accCache = __getEligibilityAccCacheForScope();
|
|
1677
|
-
const __ancBlockCache = __getAncestorBlockerAccCacheForScope();
|
|
1678
|
-
|
|
1679
|
-
if (!isElement(node)) {
|
|
1680
|
-
return { eligible: false, reasons: ['notElement'] };
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
try {
|
|
1684
|
-
if (__accCache && node && typeof node === 'object' && __accCache.has(node)) {
|
|
1685
|
-
const c = __accCache.get(node);
|
|
1686
|
-
if (c && typeof c === 'object') {
|
|
1687
|
-
return {
|
|
1688
|
-
eligible: !!c.eligible,
|
|
1689
|
-
reasons: Array.isArray(c.reasons) ? c.reasons.slice(0) : []
|
|
1690
|
-
};
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
} catch {}
|
|
1694
|
-
|
|
1695
|
-
const reasons = [];
|
|
1696
|
-
|
|
1697
|
-
function __cacheAndReturn(res) {
|
|
1698
|
-
const out = {
|
|
1699
|
-
eligible: !!(res && res.eligible),
|
|
1700
|
-
reasons: res && Array.isArray(res.reasons) ? res.reasons.slice(0) : []
|
|
1701
|
-
};
|
|
1702
|
-
try {
|
|
1703
|
-
if (__accCache && node && typeof node === 'object') {
|
|
1704
|
-
__accCache.set(node, { eligible: out.eligible, reasons: out.reasons.slice(0) });
|
|
1705
|
-
}
|
|
1706
|
-
} catch {}
|
|
1707
|
-
return out;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
const chain = ancestorsIncludingSelf(node);
|
|
1711
|
-
|
|
1712
|
-
// 1) HTML/DOM hiding
|
|
1713
|
-
for (const a of chain) {
|
|
1714
|
-
if (!isElement(a)) continue;
|
|
1715
|
-
|
|
1716
|
-
// Ancestor structural blockers are scope-cached (per run) to avoid repeated checks.
|
|
1717
|
-
let struct = null;
|
|
1718
|
-
try {
|
|
1719
|
-
if (__ancBlockCache && __ancBlockCache.has(a)) {
|
|
1720
|
-
__perfInc('ancestorBlockerAcc.struct.hit');
|
|
1721
|
-
const cached = __ancBlockCache.get(a);
|
|
1722
|
-
struct = cached && cached.struct ? String(cached.struct) : null;
|
|
1723
|
-
} else {
|
|
1724
|
-
__perfInc('ancestorBlockerAcc.struct.miss');
|
|
1725
|
-
const tn = (a.tagName || '').toLowerCase();
|
|
1726
|
-
if (a.hasAttribute && a.hasAttribute('hidden')) struct = 'hiddenAttr';
|
|
1727
|
-
else if (tn === 'template') struct = 'templateContent';
|
|
1728
|
-
else if (
|
|
1729
|
-
tn === 'script' ||
|
|
1730
|
-
tn === 'style' ||
|
|
1731
|
-
tn === 'meta' ||
|
|
1732
|
-
tn === 'link' ||
|
|
1733
|
-
tn === 'noscript'
|
|
1734
|
-
)
|
|
1735
|
-
struct = 'nonRenderedElement';
|
|
1736
|
-
else if (tn === 'input') {
|
|
1737
|
-
const t = (a.getAttribute && (a.getAttribute('type') || '').toLowerCase()) || '';
|
|
1738
|
-
if (t === 'hidden') struct = 'inputHidden';
|
|
1739
|
-
}
|
|
1740
|
-
try {
|
|
1741
|
-
try {
|
|
1742
|
-
if (__ancBlockCache) {
|
|
1743
|
-
const prev = __ancBlockCache.has(a) ? __ancBlockCache.get(a) || null : null;
|
|
1744
|
-
__ancBlockCache.set(a, {
|
|
1745
|
-
struct,
|
|
1746
|
-
css: prev && prev.css ? prev.css : null,
|
|
1747
|
-
cssKnown: prev && prev.cssKnown === true ? true : false
|
|
1748
|
-
});
|
|
1749
|
-
}
|
|
1750
|
-
} catch {
|
|
1751
|
-
__perfInc('ancestorBlockerAcc.struct.nocache');
|
|
1752
|
-
}
|
|
1753
|
-
} catch {
|
|
1754
|
-
__perfInc('ancestorBlockerAcc.struct.nocache');
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
} catch {
|
|
1758
|
-
/* ignore */
|
|
1759
|
-
}
|
|
1760
|
-
|
|
1761
|
-
if (struct) return __cacheAndReturn({ eligible: false, reasons: [struct] });
|
|
1762
|
-
}
|
|
1763
|
-
if (inClosedDetailsContent(node))
|
|
1764
|
-
return __cacheAndReturn({ eligible: false, reasons: ['detailsClosed'] });
|
|
1765
|
-
|
|
1766
|
-
// 2) Inertness / modality
|
|
1767
|
-
if (hasBlockingInert(node)) {
|
|
1768
|
-
return __cacheAndReturn({ eligible: false, reasons: ['inert'] });
|
|
1769
|
-
}
|
|
1770
|
-
// Modal dialog (best effort)
|
|
1771
|
-
try {
|
|
1772
|
-
const openModals = getOpenModalDialogs();
|
|
1773
|
-
if (openModals.length) {
|
|
1774
|
-
let inside = false;
|
|
1775
|
-
for (const d of openModals) {
|
|
1776
|
-
if (d && d.contains && d.contains(node)) {
|
|
1777
|
-
inside = true;
|
|
1778
|
-
break;
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
if (!inside) return __cacheAndReturn({ eligible: false, reasons: ['modalInert'] });
|
|
1782
|
-
}
|
|
1783
|
-
} catch {}
|
|
1784
|
-
|
|
1785
|
-
// 3) CSS rendering suppression
|
|
1786
|
-
// display:none is NOT inherited: if ANY ancestor (or self) has
|
|
1787
|
-
// display:none, the whole subtree is unrendered no matter what a
|
|
1788
|
-
// descendant's own display is, so this must be resolved via an
|
|
1789
|
-
// ancestor walk that breaks on the first blocker found.
|
|
1790
|
-
for (const a of chain) {
|
|
1791
|
-
if (!isElement(a)) continue;
|
|
1792
|
-
|
|
1793
|
-
// <area> is a non-rendered element; some DOMs report display:none for it.
|
|
1794
|
-
// Don’t treat the *area itself* as ineligible based on computed style.
|
|
1795
|
-
if (a === node) {
|
|
1796
|
-
const tn = (a.tagName || '').toLowerCase();
|
|
1797
|
-
if (tn === 'area') continue;
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
// Cache ancestor CSS blockers (display) per scope.
|
|
1801
|
-
let cssBlock = null;
|
|
1802
|
-
let cssKnown = false;
|
|
1803
|
-
try {
|
|
1804
|
-
if (__ancBlockCache && __ancBlockCache.has(a)) {
|
|
1805
|
-
const cached = __ancBlockCache.get(a);
|
|
1806
|
-
if (cached && cached.cssKnown === true) {
|
|
1807
|
-
__perfInc('ancestorBlockerAcc.css.hit');
|
|
1808
|
-
cssKnown = true;
|
|
1809
|
-
cssBlock = cached.css ? String(cached.css) : null;
|
|
1810
|
-
} else {
|
|
1811
|
-
__perfInc('ancestorBlockerAcc.css.miss');
|
|
1812
|
-
}
|
|
1813
|
-
} else {
|
|
1814
|
-
__perfInc('ancestorBlockerAcc.css.miss');
|
|
1815
|
-
}
|
|
1816
|
-
} catch {}
|
|
1817
|
-
|
|
1818
|
-
if (!cssKnown) {
|
|
1819
|
-
const cs = computedStyle(a);
|
|
1820
|
-
cssBlock = cs && cs.display === 'none' ? 'displayNone' : null;
|
|
1821
|
-
|
|
1822
|
-
try {
|
|
1823
|
-
if (__ancBlockCache) {
|
|
1824
|
-
const prev = __ancBlockCache.has(a) ? __ancBlockCache.get(a) || null : null;
|
|
1825
|
-
__ancBlockCache.set(a, {
|
|
1826
|
-
struct: prev && prev.struct ? prev.struct : null,
|
|
1827
|
-
css: cssBlock || null,
|
|
1828
|
-
cssKnown: true
|
|
1829
|
-
});
|
|
1830
|
-
}
|
|
1831
|
-
} catch {
|
|
1832
|
-
__perfInc('ancestorBlockerAcc.css.nocache');
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
if (cssBlock === 'displayNone')
|
|
1837
|
-
return __cacheAndReturn({ eligible: false, reasons: ['displayNone'] });
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
// visibility IS inherited (and thus invertible): a descendant with an
|
|
1841
|
-
// explicit visibility:visible re-renders even under a
|
|
1842
|
-
// visibility:hidden ancestor. The fully resolved, post-inheritance
|
|
1843
|
-
// value is already reflected in the target node's own computed
|
|
1844
|
-
// style, so this is checked on `node` directly rather than by
|
|
1845
|
-
// walking ancestors (which would incorrectly treat visibility like
|
|
1846
|
-
// the non-inherited `display` property above).
|
|
1847
|
-
{
|
|
1848
|
-
const tn = (node.tagName || '').toLowerCase();
|
|
1849
|
-
if (tn !== 'area') {
|
|
1850
|
-
const cs = computedStyle(node);
|
|
1851
|
-
if (cs && (cs.visibility === 'hidden' || cs.visibility === 'collapse')) {
|
|
1852
|
-
return __cacheAndReturn({ eligible: false, reasons: ['visibilityHidden'] });
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
|
|
1857
|
-
// 4) ARIA subtree hiding with exceptions with exceptions
|
|
1858
|
-
let ariaHidden = false;
|
|
1859
|
-
for (const a of chain) {
|
|
1860
|
-
if (!isElement(a)) continue;
|
|
1861
|
-
const v = a.getAttribute && a.getAttribute('aria-hidden');
|
|
1862
|
-
if (v != null && String(v).trim().toLowerCase() === 'true') {
|
|
1863
|
-
ariaHidden = true;
|
|
1864
|
-
break;
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
if (ariaHidden) {
|
|
1868
|
-
const idref = isReferencedByVisibleIdRef(node);
|
|
1869
|
-
|
|
1870
|
-
// IDREF exception stays
|
|
1871
|
-
if (idref)
|
|
1872
|
-
return __cacheAndReturn({ eligible: true, reasons: ['ariaHiddenOverriddenIdref'] });
|
|
1873
|
-
|
|
1874
|
-
// Only *explicit* tabbable focus (tabindex >= 0) overrides aria-hidden by default.
|
|
1875
|
-
// Native focusability alone does not override aria-hidden EXCEPT for specific
|
|
1876
|
-
// mechanisms where the engine must still evaluate (e.g. <area> in a *used* map,
|
|
1877
|
-
// and <input type="image">).
|
|
1878
|
-
const ti = parseTabIndex(node);
|
|
1879
|
-
if (ti.has && ti.valid && ti.value >= 0) {
|
|
1880
|
-
return __cacheAndReturn({ eligible: true, reasons: ['ariaHiddenOverriddenTabbable'] });
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
// Programmatic focus (explicit tabindex < 0) does NOT override eligibility.
|
|
1884
|
-
if (ti.has && ti.valid && ti.value < 0) {
|
|
1885
|
-
return __cacheAndReturn({
|
|
1886
|
-
eligible: false,
|
|
1887
|
-
reasons: ['ariaHiddenProgrammaticFocusExcluded']
|
|
1888
|
-
});
|
|
1889
|
-
}
|
|
1890
|
-
|
|
1891
|
-
// Exception: allow aria-hidden override for mechanisms where the engine must
|
|
1892
|
-
// still evaluate required labeling/alt checks. Keep this narrowly scoped.
|
|
1893
|
-
const tag = (node.tagName || '').toLowerCase();
|
|
1894
|
-
const type =
|
|
1895
|
-
tag === 'input'
|
|
1896
|
-
? (node.getAttribute && (node.getAttribute('type') || '').toLowerCase()) || ''
|
|
1897
|
-
: '';
|
|
1898
|
-
|
|
1899
|
-
// Native form controls are tabbable by default (even without tabindex)
|
|
1900
|
-
// and are targeted by labeling checks.
|
|
1901
|
-
const isNativeFormControl =
|
|
1902
|
-
tag === 'select' || tag === 'textarea' || (tag === 'input' && type !== 'hidden'); // includes type=image
|
|
1903
|
-
|
|
1904
|
-
// Other elements that are natively tabbable by default (no explicit
|
|
1905
|
-
// tabindex required): <button>, <summary>, and <a>/<area> with a
|
|
1906
|
-
// non-empty href. Real browsers keep these in the tab order
|
|
1907
|
-
// regardless of aria-hidden — this is exactly the "aria-hidden on a
|
|
1908
|
-
// focusable element" anti-pattern that aria-hidden-focus.js itself
|
|
1909
|
-
// detects as a violation, so the eligibility model must evaluate
|
|
1910
|
-
// these too rather than silently excluding them. getPlatformFocusability
|
|
1911
|
-
// (via isPlatformFocusable) already checks the href/disabled/inert
|
|
1912
|
-
// conditions correctly for each of these tags.
|
|
1913
|
-
const isOtherNativelyFocusable = tag === 'button' || tag === 'summary' || tag === 'a';
|
|
1914
|
-
|
|
1915
|
-
if (tag === 'area' || isNativeFormControl || isOtherNativelyFocusable) {
|
|
1916
|
-
const f2 = getPlatformFocusability(node);
|
|
1917
|
-
if (f2 && f2.tabbable) {
|
|
1918
|
-
return __cacheAndReturn({ eligible: true, reasons: ['ariaHiddenOverriddenTabbable'] });
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
return __cacheAndReturn({ eligible: false, reasons: ['ariaHidden'] });
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
// 5/6 handled implicitly; 7 already covered
|
|
1926
|
-
return __cacheAndReturn({ eligible: true, reasons });
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
function isDomVisibleEligible(node, _ctx, opts) {
|
|
1930
|
-
const reasons = [];
|
|
1931
|
-
const out = (visible, reasonsArr, metrics) => ({
|
|
1932
|
-
eligible: !!visible,
|
|
1933
|
-
reasons: reasonsArr.slice(0),
|
|
1934
|
-
metrics: metrics && typeof metrics === 'object' ? { ...metrics } : {}
|
|
1935
|
-
});
|
|
1936
|
-
|
|
1937
|
-
if (!isElement(node)) return out(false, ['notElement'], {});
|
|
1938
|
-
|
|
1939
|
-
const __modeKey = __getDomEligibilityModeKey(opts);
|
|
1940
|
-
const __domCache = __getEligibilityDomCacheForScope(__modeKey);
|
|
1941
|
-
|
|
1942
|
-
const __ancBlockDomCache = __getAncestorBlockerDomCacheForScope();
|
|
1943
|
-
const __ancBlockStructFinalCache = __getAncestorBlockerDomStructFinalCacheForScope();
|
|
1944
|
-
|
|
1945
|
-
try {
|
|
1946
|
-
if (__domCache && node && typeof node === 'object' && __domCache.has(node)) {
|
|
1947
|
-
const c = __domCache.get(node);
|
|
1948
|
-
if (c && typeof c === 'object') {
|
|
1949
|
-
return {
|
|
1950
|
-
eligible: !!c.eligible,
|
|
1951
|
-
reasons: Array.isArray(c.reasons) ? c.reasons.slice(0) : [],
|
|
1952
|
-
metrics: c.metrics && typeof c.metrics === 'object' ? { ...c.metrics } : {}
|
|
1953
|
-
};
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
} catch {}
|
|
1957
|
-
|
|
1958
|
-
function __cacheAndReturn(res) {
|
|
1959
|
-
const outRes = {
|
|
1960
|
-
eligible: !!(res && res.eligible),
|
|
1961
|
-
reasons: res && Array.isArray(res.reasons) ? res.reasons.slice(0) : [],
|
|
1962
|
-
metrics: res && res.metrics && typeof res.metrics === 'object' ? { ...res.metrics } : {}
|
|
1963
|
-
};
|
|
1964
|
-
try {
|
|
1965
|
-
if (__domCache && node && typeof node === 'object') {
|
|
1966
|
-
__domCache.set(node, {
|
|
1967
|
-
eligible: outRes.eligible,
|
|
1968
|
-
reasons: outRes.reasons.slice(0),
|
|
1969
|
-
metrics: { ...outRes.metrics }
|
|
1970
|
-
});
|
|
1971
|
-
}
|
|
1972
|
-
} catch {}
|
|
1973
|
-
return outRes;
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
// 1) HTML hiding
|
|
1977
|
-
// Final short-circuit: reuse structural blocker result for this node when already known.
|
|
1978
|
-
try {
|
|
1979
|
-
if (__ancBlockStructFinalCache && __ancBlockStructFinalCache.has(node)) {
|
|
1980
|
-
__perfInc('ancestorBlockerDom.structFinal.hit');
|
|
1981
|
-
const r = __ancBlockStructFinalCache.get(node);
|
|
1982
|
-
const rr = r != null && r !== '' ? String(r) : null;
|
|
1983
|
-
if (rr) return __cacheAndReturn(out(false, [rr], {}));
|
|
1984
|
-
} else {
|
|
1985
|
-
__perfInc('ancestorBlockerDom.structFinal.miss');
|
|
1986
|
-
}
|
|
1987
|
-
} catch {}
|
|
1988
|
-
|
|
1989
|
-
const chain = ancestorsIncludingSelf(node);
|
|
1990
|
-
const __domStructSeen = [];
|
|
1991
|
-
for (const a of chain) {
|
|
1992
|
-
if (!isElement(a)) continue;
|
|
1993
|
-
|
|
1994
|
-
__domStructSeen.push(a);
|
|
1995
|
-
|
|
1996
|
-
// If an ancestor already has a final structural blocker cached,
|
|
1997
|
-
// short-circuit immediately (this is what the test expects).
|
|
1998
|
-
try {
|
|
1999
|
-
if (__ancBlockStructFinalCache && __ancBlockStructFinalCache.has(a)) {
|
|
2000
|
-
__perfInc('ancestorBlockerDom.structFinal.hit');
|
|
2001
|
-
const r = __ancBlockStructFinalCache.get(a);
|
|
2002
|
-
const rr = r != null && r !== '' ? String(r) : null;
|
|
2003
|
-
if (rr) {
|
|
2004
|
-
// Propagate to nodes we've seen on this walk (including `node`)
|
|
2005
|
-
try {
|
|
2006
|
-
for (const s of __domStructSeen) {
|
|
2007
|
-
if (!__ancBlockStructFinalCache.has(s)) __ancBlockStructFinalCache.set(s, rr);
|
|
2008
|
-
}
|
|
2009
|
-
} catch {}
|
|
2010
|
-
return __cacheAndReturn(out(false, [rr], {}));
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
} catch {}
|
|
2014
|
-
|
|
2015
|
-
// Cached structural blockers (per scope) to short-circuit shared ancestor checks.
|
|
2016
|
-
let struct = null;
|
|
2017
|
-
try {
|
|
2018
|
-
if (__ancBlockDomCache && __ancBlockDomCache.has(a)) {
|
|
2019
|
-
__perfInc('ancestorBlockerDom.struct.hit');
|
|
2020
|
-
const cached = __ancBlockDomCache.get(a);
|
|
2021
|
-
struct = cached && cached.struct ? String(cached.struct) : null;
|
|
2022
|
-
} else {
|
|
2023
|
-
__perfInc('ancestorBlockerDom.struct.miss');
|
|
2024
|
-
const tn = (a.tagName || '').toLowerCase();
|
|
2025
|
-
if (a.hasAttribute && a.hasAttribute('hidden')) struct = 'hiddenAttr';
|
|
2026
|
-
else if (tn === 'template') struct = 'templateContent';
|
|
2027
|
-
else if (
|
|
2028
|
-
tn === 'script' ||
|
|
2029
|
-
tn === 'style' ||
|
|
2030
|
-
tn === 'meta' ||
|
|
2031
|
-
tn === 'link' ||
|
|
2032
|
-
tn === 'noscript'
|
|
2033
|
-
)
|
|
2034
|
-
struct = 'nonRenderedElement';
|
|
2035
|
-
else if (tn === 'input') {
|
|
2036
|
-
const t = (a.getAttribute && (a.getAttribute('type') || '').toLowerCase()) || '';
|
|
2037
|
-
if (t === 'hidden') struct = 'inputHidden';
|
|
2038
|
-
}
|
|
2039
|
-
try {
|
|
2040
|
-
if (__ancBlockDomCache) {
|
|
2041
|
-
const prev = __ancBlockDomCache.has(a) ? __ancBlockDomCache.get(a) || null : null;
|
|
2042
|
-
__ancBlockDomCache.set(a, {
|
|
2043
|
-
struct,
|
|
2044
|
-
css: prev && prev.css ? prev.css : null,
|
|
2045
|
-
cssKnown: prev && prev.cssKnown === true ? true : false,
|
|
2046
|
-
visibility: prev && prev.visibility ? prev.visibility : null,
|
|
2047
|
-
contentVisHidden: prev && prev.contentVisHidden === true ? true : null,
|
|
2048
|
-
opacity: prev && typeof prev.opacity === 'number' ? prev.opacity : null
|
|
2049
|
-
});
|
|
2050
|
-
}
|
|
2051
|
-
} catch {
|
|
2052
|
-
__perfInc('ancestorBlockerDom.struct.nocache');
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
} catch {}
|
|
2056
|
-
|
|
2057
|
-
if (struct) {
|
|
2058
|
-
try {
|
|
2059
|
-
if (__ancBlockStructFinalCache) {
|
|
2060
|
-
for (const s of __domStructSeen) {
|
|
2061
|
-
if (!__ancBlockStructFinalCache.has(s)) __ancBlockStructFinalCache.set(s, struct);
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
} catch {}
|
|
2065
|
-
return __cacheAndReturn(out(false, [struct], {}));
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
try {
|
|
2070
|
-
if (__ancBlockStructFinalCache) {
|
|
2071
|
-
for (const s of __domStructSeen) {
|
|
2072
|
-
if (!__ancBlockStructFinalCache.has(s)) __ancBlockStructFinalCache.set(s, null);
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
} catch {}
|
|
2076
|
-
|
|
2077
|
-
// Closed <details> hides content visually
|
|
2078
|
-
if (inClosedDetailsContent(node)) return __cacheAndReturn(out(false, ['detailsClosed'], {}));
|
|
2079
|
-
|
|
2080
|
-
const visibilityMode =
|
|
2081
|
-
opts && opts.visibilityMode === 'pointer'
|
|
2082
|
-
? 'pointer'
|
|
2083
|
-
: opts && opts.visibilityMode === 'styleAndGeometry'
|
|
2084
|
-
? 'styleAndGeometry'
|
|
2085
|
-
: 'styleOnly';
|
|
2086
|
-
|
|
2087
|
-
// CSS visibility is inherited, so the target node's own computed
|
|
2088
|
-
// style already reflects the fully-resolved (post-inheritance)
|
|
2089
|
-
// value. Checked here, before the opacity accumulation walk below,
|
|
2090
|
-
// so an element that is BOTH opacity:0 AND visibility:hidden (a
|
|
2091
|
-
// common hover/JS-reveal dropdown pattern — confirmed on a real
|
|
2092
|
-
// site, Getty's global nav dropdowns) is correctly reported as
|
|
2093
|
-
// 'visibilityHidden' rather than only 'opacityZero'. Reporting only
|
|
2094
|
-
// 'opacityZero' matters because callers that deliberately treat
|
|
2095
|
-
// opacity:0 as "still in-scope" (e.g. aria-hidden-focus, which must
|
|
2096
|
-
// not exclude opacity-based hiding) would otherwise see no other
|
|
2097
|
-
// blocking reason and wrongly conclude the element is focusable,
|
|
2098
|
-
// even though visibility:hidden alone already removes it from the
|
|
2099
|
-
// tab order in real browsers.
|
|
2100
|
-
{
|
|
2101
|
-
const nodeCs = computedStyle(node);
|
|
2102
|
-
if (nodeCs && (nodeCs.visibility === 'hidden' || nodeCs.visibility === 'collapse')) {
|
|
2103
|
-
return __cacheAndReturn(
|
|
2104
|
-
out(false, ['visibilityHidden'], { visibility: nodeCs.visibility })
|
|
2105
|
-
);
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
// 2) CSS visibility suppression + opacity chain
|
|
2110
|
-
//
|
|
2111
|
-
// Two passes over the SAME ancestor chain, deliberately NOT
|
|
2112
|
-
// interleaved: display:none (and content-visibility:hidden) are
|
|
2113
|
-
// absolute, un-overridable blocks — there is no CSS mechanism for a
|
|
2114
|
-
// descendant to un-hide itself from a display:none ancestor, unlike
|
|
2115
|
-
// visibility:hidden (invertible) or opacity (never a hard block by
|
|
2116
|
-
// this function's own design — see callers like aria-hidden-focus
|
|
2117
|
-
// that deliberately keep opacity:0 in-scope). A single interleaved
|
|
2118
|
-
// loop that returns on the FIRST blocking condition found while
|
|
2119
|
-
// walking outward from the target would let a CLOSER ancestor's
|
|
2120
|
-
// opacity:0 short-circuit before a FARTHER ancestor's display:none
|
|
2121
|
-
// is ever reached — silently hiding the stronger, unconditional
|
|
2122
|
-
// block behind the weaker, filterable one. Found via a real site:
|
|
2123
|
-
// BuzzFeed's carousel slides are aria-hidden with opacity:0 (by
|
|
2124
|
-
// design, for a fade transition) AND nested several levels inside a
|
|
2125
|
-
// responsive wrapper that is display:none at the simulated
|
|
2126
|
-
// viewport width — the opacity:0 on the closer ancestor was
|
|
2127
|
-
// masking the display:none on the farther one, wrongly reporting
|
|
2128
|
-
// only 'opacityZero' (which aria-hidden-focus filters out as
|
|
2129
|
-
// still-in-scope) and missing the real, unconditional
|
|
2130
|
-
// non-rendering. Pass 1 here checks every ancestor for a hard
|
|
2131
|
-
// structural CSS block first, with no early exit for opacity; pass
|
|
2132
|
-
// 2 (below) computes the accumulated opacity only once no hard
|
|
2133
|
-
// block was found anywhere in the chain.
|
|
2134
|
-
const __cssInfoByAncestor = new Map();
|
|
2135
|
-
|
|
2136
|
-
for (const a of chain) {
|
|
2137
|
-
if (!isElement(a)) continue;
|
|
2138
|
-
|
|
2139
|
-
let cssBlock = null;
|
|
2140
|
-
let cssKnown = false;
|
|
2141
|
-
|
|
2142
|
-
let cachedVisibility = null;
|
|
2143
|
-
let cachedContentVisHidden = null;
|
|
2144
|
-
let cachedOpacity = null;
|
|
2145
|
-
let cachedPointerEventsNone = null;
|
|
2146
|
-
let cachedPointerEventsKnown = false;
|
|
2147
|
-
let cs;
|
|
2148
|
-
|
|
2149
|
-
try {
|
|
2150
|
-
if (__ancBlockDomCache && __ancBlockDomCache.has(a)) {
|
|
2151
|
-
const cached = __ancBlockDomCache.get(a);
|
|
2152
|
-
if (cached) {
|
|
2153
|
-
// cssKnown means "we already computed display/visibility/content-visibility once"
|
|
2154
|
-
if (cached.cssKnown === true) {
|
|
2155
|
-
__perfInc('ancestorBlockerDom.css.hit');
|
|
2156
|
-
cssKnown = true;
|
|
2157
|
-
cssBlock = cached.css ? String(cached.css) : null;
|
|
2158
|
-
} else {
|
|
2159
|
-
__perfInc('ancestorBlockerDom.css.miss');
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
cachedVisibility = cached.visibility != null ? String(cached.visibility) : null;
|
|
2163
|
-
cachedContentVisHidden = cached.contentVisHidden === true ? true : null;
|
|
2164
|
-
cachedOpacity =
|
|
2165
|
-
cached && typeof cached.opacity === 'number' && Number.isFinite(cached.opacity)
|
|
2166
|
-
? cached.opacity
|
|
2167
|
-
: null;
|
|
2168
|
-
|
|
2169
|
-
cachedPointerEventsNone = cached.pointerEventsNone === true ? true : null;
|
|
2170
|
-
cachedPointerEventsKnown = cached.pointerEventsKnown === true ? true : false;
|
|
2171
|
-
}
|
|
2172
|
-
} else {
|
|
2173
|
-
__perfInc('ancestorBlockerDom.css.miss');
|
|
2174
|
-
}
|
|
2175
|
-
} catch {}
|
|
2176
|
-
|
|
2177
|
-
// Compute CSS blockers (and maybe opacity) only when needed
|
|
2178
|
-
if (!cssKnown && cachedContentVisHidden !== true) {
|
|
2179
|
-
cs = computedStyle(a);
|
|
2180
|
-
|
|
2181
|
-
if (cs && cs.display === 'none') cssBlock = 'displayNone';
|
|
2182
|
-
else if (cs && (cs.visibility === 'hidden' || cs.visibility === 'collapse')) {
|
|
2183
|
-
cssBlock = 'visibilityHidden';
|
|
2184
|
-
cachedVisibility = cs.visibility;
|
|
2185
|
-
} else if (cs && cs.contentVisibility === 'hidden') {
|
|
2186
|
-
cssBlock = 'contentVisibilityHidden';
|
|
2187
|
-
cachedContentVisHidden = true;
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
// NEW: parse opacity once and cache it (even if cssBlock is null)
|
|
2191
|
-
if (cachedOpacity == null) {
|
|
2192
|
-
try {
|
|
2193
|
-
const raw = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
|
|
2194
|
-
const parsed = Number.parseFloat(raw);
|
|
2195
|
-
if (Number.isFinite(parsed)) cachedOpacity = parsed;
|
|
2196
|
-
} catch {}
|
|
2197
|
-
}
|
|
2198
|
-
|
|
2199
|
-
// Pointer reachability: pointer-events:none blocks hit-testing
|
|
2200
|
-
if (visibilityMode === 'pointer' && !cachedPointerEventsKnown) {
|
|
2201
|
-
try {
|
|
2202
|
-
const pe = cs && cs.pointerEvents != null ? String(cs.pointerEvents).trim() : '';
|
|
2203
|
-
cachedPointerEventsKnown = true;
|
|
2204
|
-
if (pe === 'none') cachedPointerEventsNone = true;
|
|
2205
|
-
} catch {}
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
try {
|
|
2209
|
-
if (__ancBlockDomCache) {
|
|
2210
|
-
const prev = __ancBlockDomCache.has(a) ? __ancBlockDomCache.get(a) || null : null;
|
|
2211
|
-
__ancBlockDomCache.set(a, {
|
|
2212
|
-
struct: prev && prev.struct ? prev.struct : null,
|
|
2213
|
-
css: cssBlock || null,
|
|
2214
|
-
cssKnown: true,
|
|
2215
|
-
visibility: cachedVisibility || (prev && prev.visibility ? prev.visibility : null),
|
|
2216
|
-
contentVisHidden:
|
|
2217
|
-
cachedContentVisHidden === true
|
|
2218
|
-
? true
|
|
2219
|
-
: prev && prev.contentVisHidden === true
|
|
2220
|
-
? true
|
|
2221
|
-
: null,
|
|
2222
|
-
opacity:
|
|
2223
|
-
cachedOpacity == null
|
|
2224
|
-
? prev && typeof prev.opacity === 'number'
|
|
2225
|
-
? prev.opacity
|
|
2226
|
-
: null
|
|
2227
|
-
: cachedOpacity,
|
|
2228
|
-
pointerEventsNone:
|
|
2229
|
-
cachedPointerEventsNone === true
|
|
2230
|
-
? true
|
|
2231
|
-
: prev && prev.pointerEventsNone === true
|
|
2232
|
-
? true
|
|
2233
|
-
: null,
|
|
2234
|
-
pointerEventsKnown:
|
|
2235
|
-
cachedPointerEventsKnown === true
|
|
2236
|
-
? true
|
|
2237
|
-
: prev && prev.pointerEventsKnown === true
|
|
2238
|
-
? true
|
|
2239
|
-
: false
|
|
2240
|
-
});
|
|
2241
|
-
}
|
|
2242
|
-
} catch {
|
|
2243
|
-
__perfInc('ancestorBlockerDom.css.nocache');
|
|
2244
|
-
}
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
__cssInfoByAncestor.set(a, {
|
|
2248
|
-
cssBlock,
|
|
2249
|
-
cachedOpacity,
|
|
2250
|
-
cachedPointerEventsNone,
|
|
2251
|
-
cachedPointerEventsKnown
|
|
2252
|
-
});
|
|
2253
|
-
|
|
2254
|
-
if (cssBlock === 'displayNone') return __cacheAndReturn(out(false, ['displayNone'], {}));
|
|
2255
|
-
// NOTE: unlike display:none, CSS visibility is inherited and thus
|
|
2256
|
-
// invertible — a descendant with an explicit visibility:visible
|
|
2257
|
-
// re-renders even under a visibility:hidden ancestor. So an
|
|
2258
|
-
// ancestor's visibility:hidden must NOT short-circuit this walk;
|
|
2259
|
-
// the target node's own fully-resolved visibility is checked
|
|
2260
|
-
// once, after the loop (see below).
|
|
2261
|
-
if (cssBlock === 'contentVisibilityHidden') {
|
|
2262
|
-
return __cacheAndReturn(out(false, ['contentVisibilityHidden'], {}));
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
let opacityProduct = 1;
|
|
2267
|
-
for (const a of chain) {
|
|
2268
|
-
if (!isElement(a)) continue;
|
|
2269
|
-
|
|
2270
|
-
const info = __cssInfoByAncestor.get(a) || {};
|
|
2271
|
-
let cachedOpacity = info.cachedOpacity;
|
|
2272
|
-
let cachedPointerEventsNone = info.cachedPointerEventsNone;
|
|
2273
|
-
let cachedPointerEventsKnown = info.cachedPointerEventsKnown;
|
|
2274
|
-
let cs = null;
|
|
2275
|
-
|
|
2276
|
-
if (visibilityMode === 'pointer') {
|
|
2277
|
-
// pointer-events:none prevents the element from receiving pointer interactions
|
|
2278
|
-
if (cachedPointerEventsKnown === true && cachedPointerEventsNone === true) {
|
|
2279
|
-
return __cacheAndReturn(out(false, ['pointerEventsNone'], {}));
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
if (cachedPointerEventsKnown !== true) {
|
|
2283
|
-
try {
|
|
2284
|
-
if (!cs) cs = computedStyle(a);
|
|
2285
|
-
const pe = cs && cs.pointerEvents != null ? String(cs.pointerEvents).trim() : '';
|
|
2286
|
-
cachedPointerEventsKnown = true;
|
|
2287
|
-
if (pe === 'none') cachedPointerEventsNone = true;
|
|
2288
|
-
|
|
2289
|
-
// Write back pointer-events status without disturbing other fields
|
|
2290
|
-
try {
|
|
2291
|
-
if (__ancBlockDomCache) {
|
|
2292
|
-
const prev = __ancBlockDomCache.has(a) ? __ancBlockDomCache.get(a) || null : null;
|
|
2293
|
-
if (prev) {
|
|
2294
|
-
__ancBlockDomCache.set(a, {
|
|
2295
|
-
struct: prev.struct || null,
|
|
2296
|
-
css: prev.css || null,
|
|
2297
|
-
cssKnown: prev.cssKnown === true ? true : false,
|
|
2298
|
-
visibility: prev.visibility || null,
|
|
2299
|
-
contentVisHidden: prev.contentVisHidden === true ? true : null,
|
|
2300
|
-
opacity: typeof prev.opacity === 'number' ? prev.opacity : null,
|
|
2301
|
-
pointerEventsNone: cachedPointerEventsNone === true ? true : null,
|
|
2302
|
-
pointerEventsKnown: cachedPointerEventsKnown === true ? true : false
|
|
2303
|
-
});
|
|
2304
|
-
} else {
|
|
2305
|
-
__ancBlockDomCache.set(a, {
|
|
2306
|
-
struct: null,
|
|
2307
|
-
css: null,
|
|
2308
|
-
cssKnown: false,
|
|
2309
|
-
visibility: null,
|
|
2310
|
-
contentVisHidden: null,
|
|
2311
|
-
opacity: null,
|
|
2312
|
-
pointerEventsNone: cachedPointerEventsNone === true ? true : null,
|
|
2313
|
-
pointerEventsKnown: cachedPointerEventsKnown === true ? true : false
|
|
2314
|
-
});
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
2317
|
-
} catch {}
|
|
2318
|
-
} catch {}
|
|
2319
|
-
}
|
|
2320
|
-
|
|
2321
|
-
if (cachedPointerEventsNone === true) {
|
|
2322
|
-
return __cacheAndReturn(out(false, ['pointerEventsNone'], {}));
|
|
2323
|
-
}
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
// If opacity isn't cached yet, compute once and write it back even when cssBlock was cached.
|
|
2327
|
-
// This prevents repeated computedStyle(a) calls across many isDomVisibleEligible() invocations.
|
|
2328
|
-
if (cachedOpacity == null) {
|
|
2329
|
-
try {
|
|
2330
|
-
if (!cs) cs = computedStyle(a);
|
|
2331
|
-
const raw = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
|
|
2332
|
-
const parsed = Number.parseFloat(raw);
|
|
2333
|
-
if (Number.isFinite(parsed)) {
|
|
2334
|
-
cachedOpacity = parsed;
|
|
2335
|
-
|
|
2336
|
-
// Write back to cache without disturbing other fields
|
|
2337
|
-
try {
|
|
2338
|
-
if (__ancBlockDomCache) {
|
|
2339
|
-
const prev = __ancBlockDomCache.has(a) ? __ancBlockDomCache.get(a) || null : null;
|
|
2340
|
-
if (prev) {
|
|
2341
|
-
__ancBlockDomCache.set(a, {
|
|
2342
|
-
struct: prev.struct || null,
|
|
2343
|
-
css: prev.css || null,
|
|
2344
|
-
visibility: prev.visibility || null,
|
|
2345
|
-
contentVisHidden: prev.contentVisHidden === true ? true : null,
|
|
2346
|
-
opacity: cachedOpacity,
|
|
2347
|
-
pointerEventsNone: prev.pointerEventsNone === true ? true : null,
|
|
2348
|
-
pointerEventsKnown: prev.pointerEventsKnown === true ? true : false
|
|
2349
|
-
});
|
|
2350
|
-
} else {
|
|
2351
|
-
// No prior cache entry for this ancestor and no hard
|
|
2352
|
-
// structural block was found for it in pass 1 above
|
|
2353
|
-
// (pass 1 would have returned early otherwise), so
|
|
2354
|
-
// struct/css/visibility/contentVisHidden are all
|
|
2355
|
-
// known-null here.
|
|
2356
|
-
__ancBlockDomCache.set(a, {
|
|
2357
|
-
struct: null,
|
|
2358
|
-
css: null,
|
|
2359
|
-
visibility: null,
|
|
2360
|
-
contentVisHidden: null,
|
|
2361
|
-
opacity: cachedOpacity,
|
|
2362
|
-
pointerEventsNone: null,
|
|
2363
|
-
pointerEventsKnown: false
|
|
2364
|
-
});
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
} catch {}
|
|
2368
|
-
}
|
|
2369
|
-
} catch {}
|
|
2370
|
-
}
|
|
2371
|
-
|
|
2372
|
-
// opacity handling (visual)
|
|
2373
|
-
const op = cachedOpacity != null ? cachedOpacity : 1;
|
|
2374
|
-
opacityProduct *= op;
|
|
2375
|
-
// Allow callers to ignore opacity-based invisibility (still focusable).
|
|
2376
|
-
const ignoreOpacity = !!(opts && opts.ignoreOpacity === true);
|
|
2377
|
-
|
|
2378
|
-
if (!ignoreOpacity && visibilityMode !== 'pointer' && opacityProduct <= 0.0001) {
|
|
2379
|
-
return __cacheAndReturn(out(false, ['opacityZero'], { opacity: opacityProduct }));
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
|
|
2383
|
-
// 3) Layout/geometry (optional)
|
|
2384
|
-
const useGeometry =
|
|
2385
|
-
visibilityMode === 'pointer'
|
|
2386
|
-
? !(opts && opts.disableGeometry === true)
|
|
2387
|
-
: visibilityMode === 'styleAndGeometry' && !(opts && opts.disableGeometry === true);
|
|
2388
|
-
|
|
2389
|
-
if (useGeometry) {
|
|
2390
|
-
try {
|
|
2391
|
-
if (node.getClientRects) {
|
|
2392
|
-
const rects = node.getClientRects();
|
|
2393
|
-
const rectCount = rects ? rects.length : 0;
|
|
2394
|
-
|
|
2395
|
-
if (!rectCount) {
|
|
2396
|
-
return __cacheAndReturn(out(false, ['noClientRects'], { rectCount: 0 }));
|
|
2397
|
-
}
|
|
2398
|
-
|
|
2399
|
-
const r = node.getBoundingClientRect ? node.getBoundingClientRect() : null;
|
|
2400
|
-
const w = r && Number.isFinite(r.width) ? r.width : 0;
|
|
2401
|
-
const h = r && Number.isFinite(r.height) ? r.height : 0;
|
|
2402
|
-
|
|
2403
|
-
if (w <= 0 || h <= 0) {
|
|
2404
|
-
return __cacheAndReturn(out(false, ['zeroArea'], { rectCount, width: w, height: h }));
|
|
2405
|
-
}
|
|
2406
|
-
|
|
2407
|
-
return __cacheAndReturn(
|
|
2408
|
-
out(true, reasons, {
|
|
2409
|
-
rectCount,
|
|
2410
|
-
width: w,
|
|
2411
|
-
height: h,
|
|
2412
|
-
opacity: opacityProduct
|
|
2413
|
-
})
|
|
2414
|
-
);
|
|
2415
|
-
}
|
|
2416
|
-
} catch {
|
|
2417
|
-
// ignore geometry failures; fall back to style-only eligibility
|
|
2418
|
-
}
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
return __cacheAndReturn(out(true, reasons, { opacity: opacityProduct }));
|
|
2422
|
-
}
|
|
2423
|
-
|
|
2424
|
-
function getEligibilityInfo(node, _ctx, opts) {
|
|
2425
|
-
const targetSet =
|
|
2426
|
-
opts && (opts.targetSet === 'acc' || opts.targetSet === 'dom') ? opts.targetSet : 'dom';
|
|
2427
|
-
const r =
|
|
2428
|
-
targetSet === 'dom' ? isDomVisibleEligible(node, _ctx, opts) : isAccTreeEligible(node);
|
|
2429
|
-
return {
|
|
2430
|
-
eligible: !!(r && r.eligible),
|
|
2431
|
-
reasons: r && Array.isArray(r.reasons) ? r.reasons.slice(0) : [],
|
|
2432
|
-
targetSet,
|
|
2433
|
-
accEligible: targetSet === 'acc' ? !!(r && r.eligible) : null
|
|
2434
|
-
};
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2437
|
-
// E) IDREF helpers
|
|
2438
|
-
function resolveIdRefs(idrefString, _ctx, opts) {
|
|
2439
|
-
const raw = trim(idrefString);
|
|
2440
|
-
if (!raw) return { refs: [], missing: [], flags: ['empty'] };
|
|
2441
|
-
|
|
2442
|
-
// Normalize whitespace for stable cache keys
|
|
2443
|
-
const parts = raw.split(/\s+/).filter(Boolean);
|
|
2444
|
-
const normKey = parts.join(' ');
|
|
2445
|
-
|
|
2446
|
-
// Root-scoped cache map
|
|
2447
|
-
let cacheMap = null;
|
|
2448
|
-
if (__idRefCacheByRoot) {
|
|
2449
|
-
const scopeObj = __getScopeObj();
|
|
2450
|
-
if (scopeObj) {
|
|
2451
|
-
try {
|
|
2452
|
-
cacheMap = __idRefCacheByRoot.get(scopeObj) || null;
|
|
2453
|
-
if (!cacheMap) {
|
|
2454
|
-
cacheMap = new Map();
|
|
2455
|
-
__idRefCacheByRoot.set(scopeObj, cacheMap);
|
|
2456
|
-
}
|
|
2457
|
-
} catch {
|
|
2458
|
-
cacheMap = null;
|
|
2459
|
-
}
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2462
|
-
|
|
2463
|
-
// Cached base result is *untruncated* (opts.maxRefs applied per call)
|
|
2464
|
-
if (cacheMap) {
|
|
2465
|
-
try {
|
|
2466
|
-
const cached = cacheMap.get(normKey);
|
|
2467
|
-
if (cached && cached.refs && cached.missing && cached.flags) {
|
|
2468
|
-
const baseRefs = Array.isArray(cached.refs) ? cached.refs.slice(0) : [];
|
|
2469
|
-
const baseMissing = Array.isArray(cached.missing) ? cached.missing.slice(0) : [];
|
|
2470
|
-
const baseFlags = Array.isArray(cached.flags) ? cached.flags.slice(0) : [];
|
|
2471
|
-
|
|
2472
|
-
// Apply deterministic truncation if requested
|
|
2473
|
-
if (opts && opts.maxRefs && baseRefs.length > opts.maxRefs) {
|
|
2474
|
-
baseRefs.length = Math.max(0, Number(opts.maxRefs) | 0);
|
|
2475
|
-
baseFlags.push('truncated');
|
|
2476
|
-
}
|
|
2477
|
-
|
|
2478
|
-
__perfInc('idref.resolve.hit');
|
|
2479
|
-
return { refs: baseRefs, missing: baseMissing, flags: baseFlags };
|
|
2480
|
-
}
|
|
2481
|
-
} catch {
|
|
2482
|
-
// cache read errors should never throw
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
__perfInc(cacheMap ? 'idref.resolve.miss' : 'idref.resolve.nocache');
|
|
2487
|
-
// Compute base result
|
|
2488
|
-
const refs = [];
|
|
2489
|
-
const missing = [];
|
|
2490
|
-
const seen = new Set();
|
|
2491
|
-
|
|
2492
|
-
for (const id of parts) {
|
|
2493
|
-
const key = trim(id);
|
|
2494
|
-
if (!key) continue;
|
|
2495
|
-
|
|
2496
|
-
let el = safeDocGetById(key);
|
|
2497
|
-
if (!el) el = safeRootQueryById(key);
|
|
2498
|
-
|
|
2499
|
-
if (!el || !isElement(el)) {
|
|
2500
|
-
missing.push(key);
|
|
2501
|
-
continue;
|
|
2502
|
-
}
|
|
2503
|
-
if (seen.has(el)) continue;
|
|
2504
|
-
seen.add(el);
|
|
2505
|
-
refs.push(el);
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
const flags = [];
|
|
2509
|
-
if (missing.length) flags.push('idref-missing');
|
|
2510
|
-
if (parts.length !== refs.length + missing.length) flags.push('deduped'); // indicates repeats
|
|
2511
|
-
|
|
2512
|
-
// Store untruncated base result
|
|
2513
|
-
if (cacheMap) {
|
|
2514
|
-
try {
|
|
2515
|
-
cacheMap.set(normKey, {
|
|
2516
|
-
refs: refs.slice(0),
|
|
2517
|
-
missing: missing.slice(0),
|
|
2518
|
-
flags: flags.slice(0),
|
|
2519
|
-
partsLen: parts.length
|
|
2520
|
-
});
|
|
2521
|
-
} catch {
|
|
2522
|
-
// ignore cache write errors
|
|
2523
|
-
}
|
|
2524
|
-
}
|
|
2525
|
-
|
|
2526
|
-
// Apply deterministic truncation per call
|
|
2527
|
-
if (opts && opts.maxRefs && refs.length > opts.maxRefs) {
|
|
2528
|
-
refs.length = Math.max(0, Number(opts.maxRefs) | 0);
|
|
2529
|
-
flags.push('truncated');
|
|
2530
|
-
}
|
|
2531
|
-
|
|
2532
|
-
return { refs, missing, flags };
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
// Native "name is derived from value/alt" mechanisms, used when resolving
|
|
2536
|
-
// an IDREF *target*'s own text alternative (see computeIdRefTargetTextAlternative).
|
|
2537
|
-
function __getElementValueLikeName(el) {
|
|
2538
|
-
if (!isElement(el)) return '';
|
|
2539
|
-
const tag = (el.tagName || '').toLowerCase();
|
|
2540
|
-
|
|
2541
|
-
if (tag === 'img' || tag === 'area') {
|
|
2542
|
-
const alt = getAttr(el, 'alt');
|
|
2543
|
-
if (alt != null) {
|
|
2544
|
-
const t = trim(alt);
|
|
2545
|
-
if (t) return t;
|
|
2546
|
-
}
|
|
2547
|
-
return '';
|
|
2548
|
-
}
|
|
2549
|
-
|
|
2550
|
-
if (tag === 'input') {
|
|
2551
|
-
const type = lower(getAttr(el, 'type') || 'text');
|
|
2552
|
-
if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image') {
|
|
2553
|
-
const v = getAttr(el, 'value');
|
|
2554
|
-
if (v != null) {
|
|
2555
|
-
const t = trim(v);
|
|
2556
|
-
if (t) return t;
|
|
2557
|
-
}
|
|
2558
|
-
if (type === 'submit') return 'Submit';
|
|
2559
|
-
if (type === 'reset') return 'Reset';
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
return '';
|
|
2564
|
-
}
|
|
2565
|
-
|
|
2566
|
-
// Recursively computes an IDREF-referenced node's own text alternative,
|
|
2567
|
-
// per the Accessible Name and Description Computation spec (resolving a
|
|
2568
|
-
// reference re-applies the name-computation algorithm to the target, it
|
|
2569
|
-
// does not just read raw textContent — see getContentNameInfo for why
|
|
2570
|
-
// raw textContent misses image alt text and other attribute-sourced
|
|
2571
|
-
// names on descendants). `visited` guards against cycles reachable via
|
|
2572
|
-
// direct aria-labelledby chains (e.g. two elements labelling each
|
|
2573
|
-
// other); `__nameComputationDepth` additionally bounds the combined
|
|
2574
|
-
// depth across this function and getContentNameInfo/
|
|
2575
|
-
// getAccessibleNameInfo, since a hop through a target's *content* (a
|
|
2576
|
-
// descendant with its own aria-labelledby) starts a fresh `visited` Set
|
|
2577
|
-
// and would otherwise defeat that per-call guard on a genuine cycle.
|
|
2578
|
-
function computeIdRefTargetTextAlternative(el, visited, _ctx, opts) {
|
|
2579
|
-
if (!isElement(el)) return '';
|
|
2580
|
-
if (visited.has(el)) return '';
|
|
2581
|
-
visited.add(el);
|
|
2582
|
-
if (__nameComputationDepth >= __NAME_COMPUTATION_MAX_DEPTH) return '';
|
|
2583
|
-
|
|
2584
|
-
// Establish opts.includeHidden exactly once per aria-labelledby/aria-describedby
|
|
2585
|
-
// traversal, from the top-level referenced target's own hidden state -- mirrors
|
|
2586
|
-
// a widely-used reference engine's prepareContext, which only computes context.includeHidden when it's
|
|
2587
|
-
// still undefined and never overwrites it on recursive calls, so the whole
|
|
2588
|
-
// referenced subtree (nested labelledby chains included) shares one decision. See
|
|
2589
|
-
// getContentNameInfo's collect() for what this bypasses and why (real bug found via
|
|
2590
|
-
// Discord's live-DOM footer nav, 2026-07-23).
|
|
2591
|
-
let effOpts = opts;
|
|
2592
|
-
if (!opts || opts.includeHidden === undefined) {
|
|
2593
|
-
let hidden;
|
|
2594
|
-
try {
|
|
2595
|
-
const elig = isAccTreeEligible(el);
|
|
2596
|
-
hidden = !(elig && elig.eligible);
|
|
2597
|
-
} catch {
|
|
2598
|
-
hidden = false;
|
|
2599
|
-
}
|
|
2600
|
-
effOpts = Object.assign({}, opts, { includeHidden: hidden });
|
|
2601
|
-
}
|
|
2602
|
-
|
|
2603
|
-
__nameComputationDepth += 1;
|
|
2604
|
-
try {
|
|
2605
|
-
const ariaLabel = trim(getAttr(el, 'aria-label'));
|
|
2606
|
-
if (ariaLabel) return ariaLabel;
|
|
2607
|
-
|
|
2608
|
-
const labelledBy = trim(getAttr(el, 'aria-labelledby'));
|
|
2609
|
-
if (labelledBy) {
|
|
2610
|
-
const parts = labelledBy.split(/\s+/).filter(Boolean);
|
|
2611
|
-
const texts = [];
|
|
2612
|
-
for (const id of parts) {
|
|
2613
|
-
let ref = safeDocGetById(id);
|
|
2614
|
-
if (!ref) ref = safeRootQueryById(id);
|
|
2615
|
-
if (ref && isElement(ref)) {
|
|
2616
|
-
const t = computeIdRefTargetTextAlternative(ref, visited, _ctx, effOpts);
|
|
2617
|
-
if (t) texts.push(t);
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
const joined = trim(texts.join(' '));
|
|
2621
|
-
if (joined) return joined;
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
const valueLike = __getElementValueLikeName(el);
|
|
2625
|
-
if (valueLike) return valueLike;
|
|
2626
|
-
|
|
2627
|
-
const contentInfo = getContentNameInfo(el, _ctx, effOpts);
|
|
2628
|
-
if (contentInfo && contentInfo.present && contentInfo.value) return contentInfo.value;
|
|
2629
|
-
|
|
2630
|
-
const title = trim(getAttr(el, 'title'));
|
|
2631
|
-
if (title) return title;
|
|
2632
|
-
|
|
2633
|
-
return '';
|
|
2634
|
-
} finally {
|
|
2635
|
-
__nameComputationDepth -= 1;
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
|
|
2639
|
-
function getTextFromIdRefs(idrefString, _ctx, opts) {
|
|
2640
|
-
const r = resolveIdRefs(idrefString, _ctx, opts);
|
|
2641
|
-
const texts = [];
|
|
2642
|
-
const visited = new Set();
|
|
2643
|
-
for (const el of r.refs) {
|
|
2644
|
-
try {
|
|
2645
|
-
const t = computeIdRefTargetTextAlternative(el, visited, _ctx, opts);
|
|
2646
|
-
if (t) texts.push(t);
|
|
2647
|
-
} catch {}
|
|
2648
|
-
}
|
|
2649
|
-
const text = trim(texts.join(' '));
|
|
2650
|
-
const flags = r.flags.slice(0);
|
|
2651
|
-
if (!text && r.refs.length) flags.push('resolved-empty-text');
|
|
2652
|
-
return { text, refsCount: r.refs.length, missing: r.missing.slice(0), flags };
|
|
2653
|
-
}
|
|
2654
|
-
|
|
2655
|
-
function isIdRefEligibleTarget(node) {
|
|
2656
|
-
// IDREF policy: include hidden/aria-hidden/collapsed targets,
|
|
2657
|
-
// exclude only inertness or non-composed.
|
|
2658
|
-
if (!isElement(node)) return { eligible: false, reasons: ['notElement'] };
|
|
2659
|
-
|
|
2660
|
-
// NOTE: `root` is not an eligibility boundary for IDREF targets.
|
|
2661
|
-
|
|
2662
|
-
if (hasBlockingInert(node)) return { eligible: false, reasons: ['inert'] };
|
|
2663
|
-
|
|
2664
|
-
return { eligible: true, reasons: [] };
|
|
2665
|
-
}
|
|
2666
|
-
|
|
2667
|
-
function getTextFromIdRefsIdrefEligible(idrefString, _ctx, opts) {
|
|
2668
|
-
const r = resolveIdRefs(idrefString, _ctx, opts);
|
|
2669
|
-
|
|
2670
|
-
const texts = [];
|
|
2671
|
-
const excluded = []; // [{ id, reasons }]
|
|
2672
|
-
const visited = new Set();
|
|
2673
|
-
for (const el of r.refs) {
|
|
2674
|
-
const elig = isIdRefEligibleTarget(el);
|
|
2675
|
-
if (!elig.eligible) {
|
|
2676
|
-
const id = trim(el.getAttribute && el.getAttribute('id'));
|
|
2677
|
-
excluded.push({ id: id || null, reasons: elig.reasons.slice(0) });
|
|
2678
|
-
continue;
|
|
2679
|
-
}
|
|
2680
|
-
try {
|
|
2681
|
-
const t = computeIdRefTargetTextAlternative(el, visited, _ctx, opts);
|
|
2682
|
-
if (t) texts.push(t);
|
|
2683
|
-
} catch {}
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
const text = trim(texts.join(' '));
|
|
2687
|
-
const flags = r.flags.slice(0);
|
|
2688
|
-
if (!text && r.refs.length) flags.push('resolved-empty-text');
|
|
2689
|
-
|
|
2690
|
-
if (excluded.length) flags.push('idref-excluded');
|
|
2691
|
-
|
|
2692
|
-
return {
|
|
2693
|
-
text,
|
|
2694
|
-
refsCount: r.refs.length,
|
|
2695
|
-
missing: r.missing.slice(0),
|
|
2696
|
-
excluded,
|
|
2697
|
-
flags
|
|
2698
|
-
};
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
// B) Accessible name / description helpers (mechanism-first, but scoped & deterministic)
|
|
2702
|
-
// Computes a wrapping/explicit <label>'s own text for the purpose of
|
|
2703
|
-
// naming ONE specific control inside it, excluding that control's own
|
|
2704
|
-
// subtree (matches HTML-AAM's "label text minus embedded control
|
|
2705
|
-
// content" and a widely-used reference engine's implicit-evaluate/explicit-evaluate, which do
|
|
2706
|
-
// the same exclusion via a startNode/inControlContext flag on their own
|
|
2707
|
-
// subtreeText recursion). Deliberately does NOT call back into
|
|
2708
|
-
// getAccessibleNameInfo/getContentNameInfo for descendants — only img
|
|
2709
|
-
// alt (getTextAlternativeInfo), aria-label (getAriaLabelInfo), and
|
|
2710
|
-
// aria-labelledby (getAriaLabelledByInfo) on descendants, all of which
|
|
2711
|
-
// are leaf-safe with respect to <label> lookups. This is intentional:
|
|
2712
|
-
// getAccessibleNameInfo calls this function, and getContentNameInfo's
|
|
2713
|
-
// own descendant walk calls getAccessibleNameInfo — if this function
|
|
2714
|
-
// routed back through either of those instead, a control nested inside
|
|
2715
|
-
// its own naming <label> (the exact case this exists to handle) would
|
|
2716
|
-
// recurse forever between "what's my name" and "what's my label's
|
|
2717
|
-
// content."
|
|
2718
|
-
function getLabelSubtreeNameInfo(labelEl, excludeEl, _ctx, opts) {
|
|
2719
|
-
if (!isElement(labelEl)) return { present: false, value: '', mechanism: 'none', flags: [] };
|
|
2720
|
-
|
|
2721
|
-
const parts = [];
|
|
2722
|
-
let guardCount = 0;
|
|
2723
|
-
|
|
2724
|
-
function isImageLikeNode(node) {
|
|
2725
|
-
const tag = lower(node.tagName);
|
|
2726
|
-
const type = tag === 'input' ? lower(getAttr(node, 'type')) : '';
|
|
2727
|
-
return tag === 'img' || tag === 'area' || (tag === 'input' && type === 'image');
|
|
2728
|
-
}
|
|
2729
|
-
|
|
2730
|
-
function walk(node) {
|
|
2731
|
-
if (node === excludeEl) return; // exclude the target control's own subtree
|
|
2732
|
-
guardCount += 1;
|
|
2733
|
-
if (guardCount > 5000) return;
|
|
2734
|
-
|
|
2735
|
-
if (node.nodeType === 3) {
|
|
2736
|
-
const t = trim(node.nodeValue);
|
|
2737
|
-
if (t) parts.push(t);
|
|
2738
|
-
return;
|
|
2739
|
-
}
|
|
2740
|
-
if (!isElement(node)) return;
|
|
2741
|
-
|
|
2742
|
-
let eligible;
|
|
2743
|
-
try {
|
|
2744
|
-
const r = isAccTreeEligible(node);
|
|
2745
|
-
eligible = !!(r && r.eligible);
|
|
2746
|
-
} catch {
|
|
2747
|
-
eligible = true;
|
|
2748
|
-
}
|
|
2749
|
-
if (!eligible) return;
|
|
2750
|
-
|
|
2751
|
-
const al = getAriaLabelInfo(node);
|
|
2752
|
-
if (al && al.present && al.value) {
|
|
2753
|
-
parts.push(al.value);
|
|
2754
|
-
return;
|
|
2755
|
-
}
|
|
2756
|
-
const alb = getAriaLabelledByInfo(node, _ctx, opts);
|
|
2757
|
-
if (alb && alb.present && alb.value) {
|
|
2758
|
-
parts.push(alb.value);
|
|
2759
|
-
return;
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
if (isImageLikeNode(node)) {
|
|
2763
|
-
const alt = getTextAlternativeInfo(node, _ctx, opts);
|
|
2764
|
-
if (alt && alt.present && alt.value) parts.push(alt.value);
|
|
2765
|
-
return;
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
const kids = node.childNodes ? Array.from(node.childNodes) : [];
|
|
2769
|
-
for (const kid of kids) walk(kid);
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
try {
|
|
2773
|
-
const kids = labelEl.childNodes ? Array.from(labelEl.childNodes) : [];
|
|
2774
|
-
for (const kid of kids) walk(kid);
|
|
2775
|
-
} catch {}
|
|
2776
|
-
|
|
2777
|
-
const value = trim(parts.join(' ').replace(/\s+/g, ' '));
|
|
2778
|
-
return { present: !!value, value, mechanism: 'label', flags: value ? [] : ['empty'] };
|
|
2779
|
-
}
|
|
2780
|
-
|
|
2781
|
-
function getAccessibleNameInfo(el, _ctx, opts) {
|
|
2782
|
-
const flags = [];
|
|
2783
|
-
if (!isElement(el))
|
|
2784
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
2785
|
-
|
|
2786
|
-
const key = __getNameOptsKey(opts);
|
|
2787
|
-
try {
|
|
2788
|
-
if (__accessibleNameCacheByKey && __accessibleNameCacheByKey.has(key)) {
|
|
2789
|
-
const wm = __accessibleNameCacheByKey.get(key);
|
|
2790
|
-
if (wm && wm instanceof WeakMap && wm.has(el)) {
|
|
2791
|
-
const c = wm.get(el);
|
|
2792
|
-
if (c && typeof c === 'object') {
|
|
2793
|
-
__perfInc('accessibleName.hit');
|
|
2794
|
-
return {
|
|
2795
|
-
present: !!c.present,
|
|
2796
|
-
value: c.value == null ? '' : String(c.value),
|
|
2797
|
-
mechanism: c.mechanism || 'none',
|
|
2798
|
-
flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
|
|
2799
|
-
};
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
}
|
|
2803
|
-
} catch {}
|
|
2804
|
-
__perfInc('accessibleName.miss');
|
|
2805
|
-
|
|
2806
|
-
const aria = getAriaNameInfo(el, _ctx, opts);
|
|
2807
|
-
if (aria && aria.present && aria.value) {
|
|
2808
|
-
const out = {
|
|
2809
|
-
present: true,
|
|
2810
|
-
value: aria.value,
|
|
2811
|
-
mechanism: aria.mechanism,
|
|
2812
|
-
flags: flags.concat(aria.flags || [])
|
|
2813
|
-
};
|
|
2814
|
-
try {
|
|
2815
|
-
if (__accessibleNameCacheByKey) {
|
|
2816
|
-
const wm =
|
|
2817
|
-
__accessibleNameCacheByKey.get(key) ||
|
|
2818
|
-
(__accessibleNameCacheByKey.set(key, new WeakMap()),
|
|
2819
|
-
__accessibleNameCacheByKey.get(key));
|
|
2820
|
-
if (wm && wm instanceof WeakMap)
|
|
2821
|
-
wm.set(el, {
|
|
2822
|
-
present: !!out.present,
|
|
2823
|
-
value: out.value,
|
|
2824
|
-
mechanism: out.mechanism,
|
|
2825
|
-
flags: out.flags.slice(0)
|
|
2826
|
-
});
|
|
2827
|
-
}
|
|
2828
|
-
} catch {}
|
|
2829
|
-
return out;
|
|
2830
|
-
}
|
|
2831
|
-
if (aria && aria.flags && aria.flags.length) {
|
|
2832
|
-
for (const f of aria.flags) flags.push(f);
|
|
2833
|
-
}
|
|
2834
|
-
|
|
2835
|
-
// Native <label> association via the HTML `.labels` API, which
|
|
2836
|
-
// resolves BOTH `<label for="...">` and wrapping `<label>...</label>`
|
|
2837
|
-
// in one call, for any genuinely labelable element (button, input,
|
|
2838
|
-
// meter, output, progress, select, textarea — `.labels` is simply
|
|
2839
|
-
// absent/undefined on anything else, so this never over-triggers).
|
|
2840
|
-
// Found via a real page: DeviantArt's settings toggles wrap a
|
|
2841
|
-
// description div and an unlabeled icon-only <button aria-pressed>
|
|
2842
|
-
// in one <label> — a spec-valid naming mechanism (HTML lists
|
|
2843
|
-
// <button> as labelable) that a widely-used reference engine's
|
|
2844
|
-
// button-name rule already checks (its implicit-label/explicit-label checks) but this engine
|
|
2845
|
-
// wasn't checking at all, since the id-based lookup below only ever
|
|
2846
|
-
// handled explicit for="" and this button has no id to begin with.
|
|
2847
|
-
try {
|
|
2848
|
-
if (el.labels && el.labels.length) {
|
|
2849
|
-
for (const labelEl of Array.from(el.labels)) {
|
|
2850
|
-
const info = getLabelSubtreeNameInfo(labelEl, el, _ctx, opts);
|
|
2851
|
-
if (info.present && info.value) {
|
|
2852
|
-
const out = { present: true, value: info.value, mechanism: 'label', flags };
|
|
2853
|
-
try {
|
|
2854
|
-
if (__accessibleNameCacheByKey) {
|
|
2855
|
-
const wm =
|
|
2856
|
-
__accessibleNameCacheByKey.get(key) ||
|
|
2857
|
-
(__accessibleNameCacheByKey.set(key, new WeakMap()),
|
|
2858
|
-
__accessibleNameCacheByKey.get(key));
|
|
2859
|
-
if (wm && wm instanceof WeakMap)
|
|
2860
|
-
wm.set(el, {
|
|
2861
|
-
present: true,
|
|
2862
|
-
value: out.value,
|
|
2863
|
-
mechanism: out.mechanism,
|
|
2864
|
-
flags: out.flags.slice(0)
|
|
2865
|
-
});
|
|
2866
|
-
}
|
|
2867
|
-
} catch {}
|
|
2868
|
-
return out;
|
|
2869
|
-
}
|
|
2870
|
-
}
|
|
2871
|
-
}
|
|
2872
|
-
} catch {}
|
|
2873
|
-
|
|
2874
|
-
// Explicit <label for="..."> or wrapping <label> (common and deterministic for form controls)
|
|
2875
|
-
// (fallback for elements where `.labels` isn't natively available,
|
|
2876
|
-
// e.g. a non-native-labelable element like <div role="button" id="x">
|
|
2877
|
-
// still explicitly pointed at by <label for="x">.)
|
|
2878
|
-
const id = trim(getAttr(el, 'id'));
|
|
2879
|
-
if (id) {
|
|
2880
|
-
// Prefer indexed lookup (1 build per run) over repeated querySelector per element.
|
|
2881
|
-
const entry = __lookupLabelForId(id, key);
|
|
2882
|
-
if (entry && entry.exists) {
|
|
2883
|
-
const lt = entry.text || '';
|
|
2884
|
-
if (lt) {
|
|
2885
|
-
const out = { present: true, value: lt, mechanism: 'label', flags };
|
|
2886
|
-
try {
|
|
2887
|
-
if (__accessibleNameCacheByKey) {
|
|
2888
|
-
const wm =
|
|
2889
|
-
__accessibleNameCacheByKey.get(key) ||
|
|
2890
|
-
(__accessibleNameCacheByKey.set(key, new WeakMap()),
|
|
2891
|
-
__accessibleNameCacheByKey.get(key));
|
|
2892
|
-
if (wm && wm instanceof WeakMap)
|
|
2893
|
-
wm.set(el, {
|
|
2894
|
-
present: true,
|
|
2895
|
-
value: out.value,
|
|
2896
|
-
mechanism: out.mechanism,
|
|
2897
|
-
flags: out.flags.slice(0)
|
|
2898
|
-
});
|
|
2899
|
-
}
|
|
2900
|
-
} catch {}
|
|
2901
|
-
return out;
|
|
2902
|
-
}
|
|
2903
|
-
// If label exists but is empty, fall through (matches prior behavior: empty label doesn't produce a name).
|
|
2904
|
-
}
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
// POLICY NOTE (2026-07-23, revisit if ever reconsidered): title is accepted here as a
|
|
2908
|
-
// last-resort accessible-name source, matching HTML-AAM/accname and a widely-used
|
|
2909
|
-
// reference engine's own behavior (confirmed by reading that engine's source -- several
|
|
2910
|
-
// of its rules explicitly accept a
|
|
2911
|
-
// non-empty title, e.g. image-alt's non-empty-title check). This is a deliberate,
|
|
2912
|
-
// spec-compliant choice, not an oversight -- but title is a genuinely weak mechanism in
|
|
2913
|
-
// practice (no touch/mobile exposure, inconsistent screen-reader support, no visible
|
|
2914
|
-
// affordance for sighted users), and this is the shared function nearly every
|
|
2915
|
-
// accessible-name-dependent rule in the engine goes through. Kept for spec/reference-engine-parity
|
|
2916
|
-
// rather than removed outright; flagged here so it isn't silently load-bearing.
|
|
2917
|
-
const title = trim(getAttr(el, 'title'));
|
|
2918
|
-
if (title) {
|
|
2919
|
-
flags.push('title-used');
|
|
2920
|
-
const out = { present: true, value: title, mechanism: 'title', flags };
|
|
2921
|
-
try {
|
|
2922
|
-
if (__accessibleNameCacheByKey) {
|
|
2923
|
-
const wm =
|
|
2924
|
-
__accessibleNameCacheByKey.get(key) ||
|
|
2925
|
-
(__accessibleNameCacheByKey.set(key, new WeakMap()),
|
|
2926
|
-
__accessibleNameCacheByKey.get(key));
|
|
2927
|
-
if (wm && wm instanceof WeakMap)
|
|
2928
|
-
wm.set(el, {
|
|
2929
|
-
present: true,
|
|
2930
|
-
value: out.value,
|
|
2931
|
-
mechanism: out.mechanism,
|
|
2932
|
-
flags: out.flags.slice(0)
|
|
2933
|
-
});
|
|
2934
|
-
}
|
|
2935
|
-
} catch {}
|
|
2936
|
-
return out;
|
|
2937
|
-
}
|
|
2938
|
-
|
|
2939
|
-
const out = { present: false, value: '', mechanism: 'none', flags };
|
|
2940
|
-
try {
|
|
2941
|
-
if (__accessibleNameCacheByKey) {
|
|
2942
|
-
const wm =
|
|
2943
|
-
__accessibleNameCacheByKey.get(key) ||
|
|
2944
|
-
(__accessibleNameCacheByKey.set(key, new WeakMap()), __accessibleNameCacheByKey.get(key));
|
|
2945
|
-
if (wm && wm instanceof WeakMap)
|
|
2946
|
-
wm.set(el, {
|
|
2947
|
-
present: false,
|
|
2948
|
-
value: '',
|
|
2949
|
-
mechanism: 'none',
|
|
2950
|
-
flags: out.flags.slice(0)
|
|
2951
|
-
});
|
|
2952
|
-
}
|
|
2953
|
-
} catch {}
|
|
2954
|
-
return out;
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
|
-
function getAccessibleDescriptionInfo(el, _ctx, opts) {
|
|
2958
|
-
const flags = [];
|
|
2959
|
-
if (!isElement(el))
|
|
2960
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
2961
|
-
|
|
2962
|
-
const key = __getDescOptsKey(opts);
|
|
2963
|
-
try {
|
|
2964
|
-
if (__accessibleDescCacheByKey && __accessibleDescCacheByKey.has(key)) {
|
|
2965
|
-
const wm = __accessibleDescCacheByKey.get(key);
|
|
2966
|
-
if (wm && wm instanceof WeakMap && wm.has(el)) {
|
|
2967
|
-
const c = wm.get(el);
|
|
2968
|
-
if (c && typeof c === 'object') {
|
|
2969
|
-
__perfInc('accessibleDesc.hit');
|
|
2970
|
-
return {
|
|
2971
|
-
present: !!c.present,
|
|
2972
|
-
value: c.value == null ? '' : String(c.value),
|
|
2973
|
-
mechanism: c.mechanism || 'none',
|
|
2974
|
-
flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
|
|
2975
|
-
};
|
|
2976
|
-
}
|
|
2977
|
-
}
|
|
2978
|
-
}
|
|
2979
|
-
} catch {}
|
|
2980
|
-
__perfInc('accessibleDesc.miss');
|
|
2981
|
-
|
|
2982
|
-
const describedBy = trim(getAttr(el, 'aria-describedby'));
|
|
2983
|
-
if (describedBy) {
|
|
2984
|
-
const t = getTextFromIdRefs(describedBy, _ctx, opts);
|
|
2985
|
-
for (const f of t.flags) flags.push(f);
|
|
2986
|
-
if (t.text) {
|
|
2987
|
-
const out = { present: true, value: t.text, mechanism: 'aria-describedby', flags };
|
|
2988
|
-
try {
|
|
2989
|
-
if (__accessibleDescCacheByKey) {
|
|
2990
|
-
const wm =
|
|
2991
|
-
__accessibleDescCacheByKey.get(key) ||
|
|
2992
|
-
(__accessibleDescCacheByKey.set(key, new WeakMap()),
|
|
2993
|
-
__accessibleDescCacheByKey.get(key));
|
|
2994
|
-
if (wm && wm instanceof WeakMap)
|
|
2995
|
-
wm.set(el, {
|
|
2996
|
-
present: true,
|
|
2997
|
-
value: out.value,
|
|
2998
|
-
mechanism: out.mechanism,
|
|
2999
|
-
flags: out.flags.slice(0)
|
|
3000
|
-
});
|
|
3001
|
-
}
|
|
3002
|
-
} catch {}
|
|
3003
|
-
return out;
|
|
3004
|
-
}
|
|
3005
|
-
flags.push('empty');
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
const allowTitle = !!(opts && opts.allowTitle === true);
|
|
3009
|
-
if (allowTitle) {
|
|
3010
|
-
const title = trim(getAttr(el, 'title'));
|
|
3011
|
-
if (title) {
|
|
3012
|
-
flags.push('title-used');
|
|
3013
|
-
const out = { present: true, value: title, mechanism: 'title', flags };
|
|
3014
|
-
try {
|
|
3015
|
-
if (__accessibleDescCacheByKey) {
|
|
3016
|
-
const wm =
|
|
3017
|
-
__accessibleDescCacheByKey.get(key) ||
|
|
3018
|
-
(__accessibleDescCacheByKey.set(key, new WeakMap()),
|
|
3019
|
-
__accessibleDescCacheByKey.get(key));
|
|
3020
|
-
if (wm && wm instanceof WeakMap)
|
|
3021
|
-
wm.set(el, {
|
|
3022
|
-
present: true,
|
|
3023
|
-
value: out.value,
|
|
3024
|
-
mechanism: out.mechanism,
|
|
3025
|
-
flags: out.flags.slice(0)
|
|
3026
|
-
});
|
|
3027
|
-
}
|
|
3028
|
-
} catch {}
|
|
3029
|
-
return out;
|
|
3030
|
-
}
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
const out = { present: false, value: '', mechanism: 'none', flags };
|
|
3034
|
-
try {
|
|
3035
|
-
if (__accessibleDescCacheByKey) {
|
|
3036
|
-
const wm =
|
|
3037
|
-
__accessibleDescCacheByKey.get(key) ||
|
|
3038
|
-
(__accessibleDescCacheByKey.set(key, new WeakMap()), __accessibleDescCacheByKey.get(key));
|
|
3039
|
-
if (wm && wm instanceof WeakMap)
|
|
3040
|
-
wm.set(el, {
|
|
3041
|
-
present: false,
|
|
3042
|
-
value: '',
|
|
3043
|
-
mechanism: 'none',
|
|
3044
|
-
flags: out.flags.slice(0)
|
|
3045
|
-
});
|
|
3046
|
-
}
|
|
3047
|
-
} catch {}
|
|
3048
|
-
return out;
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
// <canvas> fallback content is the element's *children*, not just its
|
|
3052
|
-
// rendered text — a documented HTML5 technique is an equivalent <img
|
|
3053
|
-
// alt="..."> (or similarly self-describing element) inside <canvas>.
|
|
3054
|
-
// textContent alone misses that, since alt text isn't part of it.
|
|
3055
|
-
function __hasMeaningfulCanvasFallbackDescendant(container) {
|
|
3056
|
-
try {
|
|
3057
|
-
if (!container || !container.querySelectorAll) return false;
|
|
3058
|
-
|
|
3059
|
-
const imgs = container.querySelectorAll('img[alt]');
|
|
3060
|
-
for (const img of imgs) {
|
|
3061
|
-
if (trim(img.getAttribute && img.getAttribute('alt'))) return true;
|
|
3062
|
-
}
|
|
3063
|
-
|
|
3064
|
-
const areas = container.querySelectorAll('area[alt]');
|
|
3065
|
-
for (const area of areas) {
|
|
3066
|
-
if (trim(area.getAttribute && area.getAttribute('alt'))) return true;
|
|
3067
|
-
}
|
|
3068
|
-
|
|
3069
|
-
const named = container.querySelectorAll('[aria-label]');
|
|
3070
|
-
for (const n of named) {
|
|
3071
|
-
if (trim(n.getAttribute && n.getAttribute('aria-label'))) return true;
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
return false;
|
|
3075
|
-
} catch {
|
|
3076
|
-
return false;
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
|
|
3080
|
-
// C) Text alternative helper (mechanism-aware by element/type)
|
|
3081
|
-
function getTextAlternativeInfo(el, _ctx, opts) {
|
|
3082
|
-
const flags = [];
|
|
3083
|
-
if (!isElement(el)) {
|
|
3084
|
-
return {
|
|
3085
|
-
present: false,
|
|
3086
|
-
value: '',
|
|
3087
|
-
mechanism: 'unsupported',
|
|
3088
|
-
requiredMechanism: 'unknown',
|
|
3089
|
-
flags: ['notElement']
|
|
3090
|
-
};
|
|
3091
|
-
}
|
|
3092
|
-
|
|
3093
|
-
const tag = lower(el.tagName);
|
|
3094
|
-
const type = tag === 'input' ? lower(getAttr(el, 'type')) : '';
|
|
3095
|
-
|
|
3096
|
-
const isImageLike = tag === 'img' || tag === 'area' || (tag === 'input' && type === 'image');
|
|
3097
|
-
|
|
3098
|
-
if (isImageLike) {
|
|
3099
|
-
const altRaw = getAttr(el, 'alt');
|
|
3100
|
-
const altPresent = altRaw != null;
|
|
3101
|
-
const altText = trim(altRaw);
|
|
3102
|
-
|
|
3103
|
-
if (altPresent) {
|
|
3104
|
-
if (!altText) flags.push('alt-empty');
|
|
3105
|
-
return {
|
|
3106
|
-
present: true,
|
|
3107
|
-
value: altText,
|
|
3108
|
-
mechanism: 'alt',
|
|
3109
|
-
requiredMechanism: 'alt',
|
|
3110
|
-
flags
|
|
3111
|
-
};
|
|
3112
|
-
}
|
|
3113
|
-
|
|
3114
|
-
// Missing alt is a real issue even if an accessible name exists.
|
|
3115
|
-
const name = getAccessibleNameInfo(el, _ctx, opts);
|
|
3116
|
-
if (name && name.present && name.value) flags.push('name-present-but-alt-missing');
|
|
3117
|
-
flags.push('alt-missing');
|
|
3118
|
-
|
|
3119
|
-
return {
|
|
3120
|
-
present: false,
|
|
3121
|
-
value: name && name.present ? name.value || '' : '',
|
|
3122
|
-
mechanism: name && name.present ? 'accessible-name' : 'none',
|
|
3123
|
-
requiredMechanism: 'alt',
|
|
3124
|
-
flags: flags.concat(name && name.flags ? name.flags.slice(0) : [])
|
|
3125
|
-
};
|
|
3126
|
-
}
|
|
3127
|
-
|
|
3128
|
-
if (tag === 'canvas') {
|
|
3129
|
-
const fallbackText = trim(el.textContent || '');
|
|
3130
|
-
if (fallbackText || __hasMeaningfulCanvasFallbackDescendant(el)) {
|
|
3131
|
-
return {
|
|
3132
|
-
present: true,
|
|
3133
|
-
value: fallbackText || 'fallback-content',
|
|
3134
|
-
mechanism: 'canvas-fallback',
|
|
3135
|
-
requiredMechanism: 'fallback-or-name',
|
|
3136
|
-
flags
|
|
3137
|
-
};
|
|
3138
|
-
}
|
|
3139
|
-
|
|
3140
|
-
// <canvas> is not a labelable element (no browser computes an
|
|
3141
|
-
// accessible name from <label for="...">), so only ARIA naming
|
|
3142
|
-
// (and title, as a generic last-resort accname source) count —
|
|
3143
|
-
// unlike getAccessibleNameInfo, which also accepts native
|
|
3144
|
-
// <label> associations.
|
|
3145
|
-
const aria = getAriaNameInfo(el, _ctx, opts);
|
|
3146
|
-
if (aria && aria.present && aria.value) {
|
|
3147
|
-
return {
|
|
3148
|
-
present: true,
|
|
3149
|
-
value: aria.value,
|
|
3150
|
-
mechanism: aria.mechanism || 'aria',
|
|
3151
|
-
requiredMechanism: 'fallback-or-name',
|
|
3152
|
-
flags: flags.concat(aria.flags ? aria.flags.slice(0) : [])
|
|
3153
|
-
};
|
|
3154
|
-
}
|
|
3155
|
-
|
|
3156
|
-
const title = trim(getAttr(el, 'title'));
|
|
3157
|
-
if (title) {
|
|
3158
|
-
return {
|
|
3159
|
-
present: true,
|
|
3160
|
-
value: title,
|
|
3161
|
-
mechanism: 'title',
|
|
3162
|
-
requiredMechanism: 'fallback-or-name',
|
|
3163
|
-
flags: flags.concat(['title-used'])
|
|
3164
|
-
};
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
return {
|
|
3168
|
-
present: false,
|
|
3169
|
-
value: '',
|
|
3170
|
-
mechanism: 'none',
|
|
3171
|
-
requiredMechanism: 'fallback-or-name',
|
|
3172
|
-
flags: flags.concat(aria && aria.flags ? aria.flags.slice(0) : [])
|
|
3173
|
-
};
|
|
3174
|
-
}
|
|
3175
|
-
|
|
3176
|
-
return {
|
|
3177
|
-
present: false,
|
|
3178
|
-
value: '',
|
|
3179
|
-
mechanism: 'unsupported',
|
|
3180
|
-
requiredMechanism: 'unknown',
|
|
3181
|
-
flags: ['unsupported-element']
|
|
3182
|
-
};
|
|
3183
|
-
}
|
|
3184
|
-
|
|
3185
|
-
// C.1) "Name from content" — recursive accname-aligned content-name computation.
|
|
3186
|
-
//
|
|
3187
|
-
// Rationale: the accname spec's "name from content" step (2F) is recursive —
|
|
3188
|
-
// for each child node, use that CHILD's own accessible name if it has one
|
|
3189
|
-
// (aria-label/aria-labelledby/native <label>/title, or `alt` for image-like
|
|
3190
|
-
// elements) rather than only concatenating literal text nodes. A naive
|
|
3191
|
-
// TreeWalker(SHOW_TEXT)-only walk (the pattern previously duplicated across
|
|
3192
|
-
// the *-name-present rule family) misses any descendant that gets its name
|
|
3193
|
-
// from an attribute rather than visible text — the single most common
|
|
3194
|
-
// real-world case being `<a href="..."><img alt="Company Name"></a>` (a
|
|
3195
|
-
// logo link) and `<button><span role="img" aria-label="Close"></span></button>`
|
|
3196
|
-
// (an icon-only button). Both were confirmed to false-positive under the
|
|
3197
|
-
// old text-node-only approach before this helper was added.
|
|
3198
|
-
function getContentNameInfo(el, _ctx, opts) {
|
|
3199
|
-
const flags = [];
|
|
3200
|
-
if (!isElement(el))
|
|
3201
|
-
return { present: false, value: '', mechanism: 'unsupported', flags: ['notElement'] };
|
|
3202
|
-
|
|
3203
|
-
// Shares __nameComputationDepth with computeIdRefTargetTextAlternative
|
|
3204
|
-
// — see that function's header comment for why a single per-call
|
|
3205
|
-
// guard isn't enough on its own.
|
|
3206
|
-
if (__nameComputationDepth >= __NAME_COMPUTATION_MAX_DEPTH) {
|
|
3207
|
-
return { present: false, value: '', mechanism: 'none', flags: ['depth-limit'] };
|
|
3208
|
-
}
|
|
3209
|
-
|
|
3210
|
-
const maxNodes =
|
|
3211
|
-
opts && Number.isFinite(opts.maxContentNodes) ? Math.max(1, opts.maxContentNodes) : 5000;
|
|
3212
|
-
let visitedCount = 0;
|
|
3213
|
-
let truncated = false;
|
|
3214
|
-
|
|
3215
|
-
function isImageLikeNode(node) {
|
|
3216
|
-
const tag = lower(node.tagName);
|
|
3217
|
-
const type = tag === 'input' ? lower(getAttr(node, 'type')) : '';
|
|
3218
|
-
return tag === 'img' || tag === 'area' || (tag === 'input' && type === 'image');
|
|
3219
|
-
}
|
|
3220
|
-
|
|
3221
|
-
function collect(node, parts) {
|
|
3222
|
-
if (truncated) return;
|
|
3223
|
-
visitedCount += 1;
|
|
3224
|
-
if (visitedCount > maxNodes) {
|
|
3225
|
-
truncated = true;
|
|
3226
|
-
if (flags.indexOf('truncated') === -1) flags.push('truncated');
|
|
3227
|
-
return;
|
|
3228
|
-
}
|
|
3229
|
-
|
|
3230
|
-
if (node.nodeType === 3) {
|
|
3231
|
-
const t = trim(node.nodeValue);
|
|
3232
|
-
if (t) parts.push(t);
|
|
3233
|
-
return;
|
|
3234
|
-
}
|
|
3235
|
-
|
|
3236
|
-
if (!isElement(node)) return;
|
|
3237
|
-
|
|
3238
|
-
// Skip anything not exposed to the accessibility tree (hidden,
|
|
3239
|
-
// aria-hidden, display:none, inert, etc.) — same scope as
|
|
3240
|
-
// isAccTreeEligible, so a hidden descendant never contributes.
|
|
3241
|
-
//
|
|
3242
|
-
// Exception: opts.includeHidden (set by computeIdRefTargetTextAlternative
|
|
3243
|
-
// when the aria-labelledby/aria-describedby TARGET itself is hidden) skips
|
|
3244
|
-
// this check entirely except for genuinely non-rendered tags. Per the accname
|
|
3245
|
-
// spec, a directly-referenced target's own hidden state doesn't block name
|
|
3246
|
-
// computation, and per a widely-used reference engine's own
|
|
3247
|
-
// prepareContext/context.includeHidden (verified by reading that engine's source
|
|
3248
|
-
// directly), that bypass covers the target's whole
|
|
3249
|
-
// subtree, not just the target element itself -- confirmed via a real page
|
|
3250
|
-
// (Discord's footer, 2026-07-23): four <nav aria-labelledby="...">, each
|
|
3251
|
-
// referencing a CSS-hidden (display:none, a responsive/interaction-gated
|
|
3252
|
-
// dropdown toggle) heading with genuinely distinct text ("Product"/"Company"/
|
|
3253
|
-
// "Resources"/"Policies"). surea11y's own isAccTreeEligible correctly treats
|
|
3254
|
-
// each toggle's hidden text as ineligible on its own terms (nothing wrong with
|
|
3255
|
-
// that check in isolation), but applying it while resolving what a
|
|
3256
|
-
// labelledby-referencing element is NAMED disagreed with that reference engine's real,
|
|
3257
|
-
// spec-aligned "pass" (distinct names) -- surea11y saw all four as unnamed and collapsed them
|
|
3258
|
-
// into one false "not unique" cluster (landmark-unique, and any other
|
|
3259
|
-
// rule resolving an aria-labelledby name through a hidden target, e.g. dialog/
|
|
3260
|
-
// tab/menuitem-name-present).
|
|
3261
|
-
if (opts && opts.includeHidden) {
|
|
3262
|
-
const tag = lower(node.tagName);
|
|
3263
|
-
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') return;
|
|
3264
|
-
} else {
|
|
3265
|
-
let eligible;
|
|
3266
|
-
try {
|
|
3267
|
-
const eligRes = isAccTreeEligible(node);
|
|
3268
|
-
eligible = !!(eligRes && eligRes.eligible);
|
|
3269
|
-
} catch {
|
|
3270
|
-
eligible = true;
|
|
3271
|
-
}
|
|
3272
|
-
if (!eligible) return;
|
|
3273
|
-
}
|
|
3274
|
-
|
|
3275
|
-
if (isImageLikeNode(node)) {
|
|
3276
|
-
// aria-labelledby/aria-label take priority over alt per the
|
|
3277
|
-
// accname spec (HTML-AAM) — checked first. Found via a real
|
|
3278
|
-
// page (eBay's product-card links): an <img alt=""
|
|
3279
|
-
// aria-labelledby="..."> pointing to real product-title text
|
|
3280
|
-
// was contributing nothing to the parent <a>'s content name,
|
|
3281
|
-
// since getTextAlternativeInfo alone only ever looks at alt
|
|
3282
|
-
// (by design, for the separate "is alt present" question
|
|
3283
|
-
// img-alt-present cares about) and an empty-but-present alt
|
|
3284
|
-
// short-circuited before aria-labelledby was ever checked.
|
|
3285
|
-
//
|
|
3286
|
-
// Deliberately uses getAriaNameInfo (aria only), NOT the
|
|
3287
|
-
// general getAccessibleNameInfo -- the latter also falls
|
|
3288
|
-
// back to a native <label>/title, which for an image-like
|
|
3289
|
-
// descendant must rank BELOW alt, not above it. Using
|
|
3290
|
-
// getAccessibleNameInfo here let title win over alt
|
|
3291
|
-
// unconditionally, for every image-like descendant, since
|
|
3292
|
-
// its title fallback doesn't know alt exists at all:
|
|
3293
|
-
// `<a href="/"><img alt="" title="Acme home"></a>` (a
|
|
3294
|
-
// decorative logo image, deliberately marked as
|
|
3295
|
-
// contributing no name via alt="") had "Acme home" wrongly
|
|
3296
|
-
// adopted as the link's whole content name; worse,
|
|
3297
|
-
// `<button><img alt="Real label" title="tooltip"></button>`
|
|
3298
|
-
// -- an image with a legitimate, correct alt AND an
|
|
3299
|
-
// unrelated title tooltip, a common real-world pattern --
|
|
3300
|
-
// silently used the tooltip text instead of the real label.
|
|
3301
|
-
const ariaName = getAriaNameInfo(node, _ctx, opts);
|
|
3302
|
-
if (ariaName && ariaName.present && ariaName.value) {
|
|
3303
|
-
parts.push(ariaName.value);
|
|
3304
|
-
if (flags.indexOf('descendant-name-used:image-aria') === -1)
|
|
3305
|
-
flags.push('descendant-name-used:image-aria');
|
|
3306
|
-
return;
|
|
3307
|
-
}
|
|
3308
|
-
|
|
3309
|
-
// input[type=image] (unlike img/area) is a genuinely
|
|
3310
|
-
// labelable form control -- a native <label> association
|
|
3311
|
-
// still outranks its alt attribute per accname's
|
|
3312
|
-
// element-specific name mapping, so it's checked here,
|
|
3313
|
-
// ahead of alt/title, same relative order getAccessibleNameInfo
|
|
3314
|
-
// itself uses for every other labelable control.
|
|
3315
|
-
if (lower(node.tagName) === 'input') {
|
|
3316
|
-
try {
|
|
3317
|
-
if (node.labels && node.labels.length) {
|
|
3318
|
-
for (const labelEl of Array.from(node.labels)) {
|
|
3319
|
-
const labelInfo = getLabelSubtreeNameInfo(labelEl, node, _ctx, opts);
|
|
3320
|
-
if (labelInfo.present && labelInfo.value) {
|
|
3321
|
-
parts.push(labelInfo.value);
|
|
3322
|
-
if (flags.indexOf('descendant-name-used:image-label') === -1)
|
|
3323
|
-
flags.push('descendant-name-used:image-label');
|
|
3324
|
-
return;
|
|
3325
|
-
}
|
|
3326
|
-
}
|
|
3327
|
-
}
|
|
3328
|
-
} catch {}
|
|
3329
|
-
}
|
|
3330
|
-
|
|
3331
|
-
// alt (even an explicit alt="", a deliberate "decorative,
|
|
3332
|
-
// contributes nothing" marker) always outranks title.
|
|
3333
|
-
// getTextAlternativeInfo already encodes exactly that
|
|
3334
|
-
// precedence: `present` distinguishes a real (possibly
|
|
3335
|
-
// empty) alt attribute -- terminal, contributes its
|
|
3336
|
-
// (possibly empty) value and nothing else -- from one
|
|
3337
|
-
// that's structurally absent, where `value` already
|
|
3338
|
-
// carries getTextAlternativeInfo's own title fallback.
|
|
3339
|
-
const alt = getTextAlternativeInfo(node, _ctx, opts);
|
|
3340
|
-
if (alt && alt.value) {
|
|
3341
|
-
const usedFlag = alt.present
|
|
3342
|
-
? 'descendant-alt-used'
|
|
3343
|
-
: 'descendant-name-used:image-title-fallback';
|
|
3344
|
-
parts.push(alt.value);
|
|
3345
|
-
if (flags.indexOf(usedFlag) === -1) flags.push(usedFlag);
|
|
3346
|
-
}
|
|
3347
|
-
return; // image-like elements have no meaningful children to recurse into
|
|
3348
|
-
}
|
|
3349
|
-
|
|
3350
|
-
const ownName = getAccessibleNameInfo(node, _ctx, opts);
|
|
3351
|
-
if (ownName && ownName.present && ownName.value) {
|
|
3352
|
-
parts.push(ownName.value);
|
|
3353
|
-
const tag = `descendant-name-used:${ownName.mechanism || 'unknown'}`;
|
|
3354
|
-
if (flags.indexOf(tag) === -1) flags.push(tag);
|
|
3355
|
-
return; // this descendant speaks for itself; don't also use its content
|
|
3356
|
-
}
|
|
3357
|
-
|
|
3358
|
-
// A <slot>'s own childNodes are its FALLBACK content only —
|
|
3359
|
-
// rendered solely when nothing is assigned to it. When real
|
|
3360
|
-
// content IS distributed into it, that's what's actually
|
|
3361
|
-
// rendered/exposed to the accessibility tree, and it lives
|
|
3362
|
-
// elsewhere in the light DOM, not as this node's children.
|
|
3363
|
-
// Found via a real page (Shoelace's <sl-button>, whose shadow
|
|
3364
|
-
// root renders <a part="base"><slot name="prefix">...
|
|
3365
|
-
// <slot part="label">...<slot name="suffix">...</a> — walking
|
|
3366
|
-
// the slots' own (empty) childNodes found nothing, when the
|
|
3367
|
-
// button's real accessible name ("Follow") was a plain light-DOM
|
|
3368
|
-
// text node assigned to the unnamed middle slot).
|
|
3369
|
-
if (lower(node.tagName) === 'slot' && typeof node.assignedNodes === 'function') {
|
|
3370
|
-
let assigned;
|
|
3371
|
-
try {
|
|
3372
|
-
assigned = node.assignedNodes({ flatten: true }) || [];
|
|
3373
|
-
} catch {
|
|
3374
|
-
assigned = [];
|
|
3375
|
-
}
|
|
3376
|
-
const kids = assigned.length
|
|
3377
|
-
? assigned
|
|
3378
|
-
: node.childNodes
|
|
3379
|
-
? Array.from(node.childNodes)
|
|
3380
|
-
: [];
|
|
3381
|
-
for (const kid of kids) {
|
|
3382
|
-
collect(kid, parts);
|
|
3383
|
-
if (truncated) break;
|
|
3384
|
-
}
|
|
3385
|
-
return;
|
|
3386
|
-
}
|
|
3387
|
-
|
|
3388
|
-
const kids = node.childNodes ? Array.from(node.childNodes) : [];
|
|
3389
|
-
for (const kid of kids) {
|
|
3390
|
-
collect(kid, parts);
|
|
3391
|
-
if (truncated) break;
|
|
3392
|
-
}
|
|
3393
|
-
}
|
|
3394
|
-
|
|
3395
|
-
const parts = [];
|
|
3396
|
-
__nameComputationDepth += 1;
|
|
3397
|
-
try {
|
|
3398
|
-
const topKids = el.childNodes ? Array.from(el.childNodes) : [];
|
|
3399
|
-
for (const kid of topKids) {
|
|
3400
|
-
collect(kid, parts);
|
|
3401
|
-
if (truncated) break;
|
|
3402
|
-
}
|
|
3403
|
-
} finally {
|
|
3404
|
-
__nameComputationDepth -= 1;
|
|
3405
|
-
}
|
|
3406
|
-
|
|
3407
|
-
const value = trim(parts.join(' ').replace(/\s+/g, ' '));
|
|
3408
|
-
return {
|
|
3409
|
-
present: !!value,
|
|
3410
|
-
value,
|
|
3411
|
-
mechanism: value ? 'content' : 'none',
|
|
3412
|
-
flags
|
|
3413
|
-
};
|
|
3414
|
-
}
|
|
3415
|
-
|
|
3416
|
-
// D) Role + focusability helpers
|
|
3417
|
-
function getRoleInfo(el, _ctx, opts) {
|
|
3418
|
-
const flags = [];
|
|
3419
|
-
if (!isElement(el)) return { role: '', source: 'none', flags: ['notElement'] };
|
|
3420
|
-
|
|
3421
|
-
const explicit = trim(getAttr(el, 'role'));
|
|
3422
|
-
if (explicit) {
|
|
3423
|
-
const v = explicit;
|
|
3424
|
-
const low = v.toLowerCase();
|
|
3425
|
-
if (low === 'presentation' || low === 'none') flags.push('presentation');
|
|
3426
|
-
// Minimal sanity: role token should not contain spaces beyond role list; keep deterministic
|
|
3427
|
-
if (/\s/.test(v)) flags.push('multiple-roles');
|
|
3428
|
-
return { role: v, source: 'explicit', flags };
|
|
3429
|
-
}
|
|
3430
|
-
|
|
3431
|
-
const allowImplicit = !(opts && opts.disallowImplicit === true);
|
|
3432
|
-
if (!allowImplicit) return { role: '', source: 'none', flags };
|
|
3433
|
-
|
|
3434
|
-
const tag = lower(el.tagName);
|
|
3435
|
-
const type = tag === 'input' ? lower(getAttr(el, 'type')) : '';
|
|
3436
|
-
const href = tag === 'a' || tag === 'area' ? trim(getAttr(el, 'href')) : '';
|
|
3437
|
-
|
|
3438
|
-
// Minimal implicit mapping (expand later if needed, but keep stable and small).
|
|
3439
|
-
let role = '';
|
|
3440
|
-
if ((tag === 'a' || tag === 'area') && href) role = 'link';
|
|
3441
|
-
else if (tag === 'button') role = 'button';
|
|
3442
|
-
else if (tag === 'summary') role = 'button';
|
|
3443
|
-
else if (tag === 'input') {
|
|
3444
|
-
if (type === 'checkbox') role = 'checkbox';
|
|
3445
|
-
else if (type === 'radio') role = 'radio';
|
|
3446
|
-
else if (type === 'range') role = 'slider';
|
|
3447
|
-
else if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image')
|
|
3448
|
-
role = 'button';
|
|
3449
|
-
else if (type !== 'hidden') role = 'textbox';
|
|
3450
|
-
} else if (tag === 'select') role = 'combobox';
|
|
3451
|
-
else if (tag === 'textarea') role = 'textbox';
|
|
3452
|
-
|
|
3453
|
-
if (role) return { role, source: 'implicit', flags };
|
|
3454
|
-
return { role: '', source: 'none', flags };
|
|
3455
|
-
}
|
|
3456
|
-
|
|
3457
|
-
function getFocusableInfo(el, _ctx, opts) {
|
|
3458
|
-
// Allocation-minimal merge: avoid chained concat() which creates intermediate arrays.
|
|
3459
|
-
if (!isElement(el))
|
|
3460
|
-
return { focusable: false, tabbable: false, mechanism: 'none', flags: ['notElement'] };
|
|
3461
|
-
|
|
3462
|
-
const pf = getPlatformFocusability(el); // returns focusable + tabbable + mechanism + flags
|
|
3463
|
-
|
|
3464
|
-
// Merge flags deterministically (stable order: local flags, then pf.flags)
|
|
3465
|
-
const outFlags = [];
|
|
3466
|
-
// (No local flags today; keep structure for forward compatibility without extra allocations.)
|
|
3467
|
-
if (pf && Array.isArray(pf.flags) && pf.flags.length) {
|
|
3468
|
-
for (let i = 0; i < pf.flags.length; i++) outFlags.push(pf.flags[i]);
|
|
3469
|
-
}
|
|
3470
|
-
|
|
3471
|
-
return {
|
|
3472
|
-
focusable: !!(pf && pf.focusable),
|
|
3473
|
-
tabbable: !!(pf && pf.tabbable),
|
|
3474
|
-
mechanism: (pf && pf.mechanism) || 'none',
|
|
3475
|
-
flags: outFlags
|
|
3476
|
-
};
|
|
3477
|
-
}
|
|
3478
|
-
|
|
3479
|
-
function getVisibilityHintsInfo(el, _ctx, opts) {
|
|
3480
|
-
// Deterministic, style-only visibility hints for triage.
|
|
3481
|
-
// Does NOT decide eligibility; checks decide outcomes.
|
|
3482
|
-
// Uses computedStyle() which is already scope-cached.
|
|
3483
|
-
|
|
3484
|
-
if (!isElement(el)) return { hints: [], metrics: {}, flags: ['notElement'] };
|
|
3485
|
-
|
|
3486
|
-
// Cache per element per run
|
|
3487
|
-
try {
|
|
3488
|
-
if (__visibilityHintsCache && __visibilityHintsCache.has(el)) {
|
|
3489
|
-
__perfInc('visibilityHints.hit');
|
|
3490
|
-
const c = __visibilityHintsCache.get(el);
|
|
3491
|
-
if (c && typeof c === 'object') {
|
|
3492
|
-
return {
|
|
3493
|
-
hints: Array.isArray(c.hints) ? c.hints.slice(0) : [],
|
|
3494
|
-
metrics: c.metrics && typeof c.metrics === 'object' ? { ...c.metrics } : {},
|
|
3495
|
-
flags: Array.isArray(c.flags) ? c.flags.slice(0) : []
|
|
3496
|
-
};
|
|
3497
|
-
}
|
|
3498
|
-
}
|
|
3499
|
-
} catch {
|
|
3500
|
-
// ignore
|
|
3501
|
-
}
|
|
3502
|
-
|
|
3503
|
-
__perfInc('visibilityHints.miss');
|
|
3504
|
-
|
|
3505
|
-
const hints = [];
|
|
3506
|
-
const metrics = {};
|
|
3507
|
-
const flags = [];
|
|
3508
|
-
|
|
3509
|
-
const cs = computedStyle(el) || {};
|
|
3510
|
-
|
|
3511
|
-
// opacity:0
|
|
3512
|
-
try {
|
|
3513
|
-
const raw = cs.opacity != null ? String(cs.opacity).trim() : '';
|
|
3514
|
-
const op = raw ? Number.parseFloat(raw) : 1;
|
|
3515
|
-
if (Number.isFinite(op)) metrics.opacity = op;
|
|
3516
|
-
if (Number.isFinite(op) && op <= 0.0001) hints.push('opacityZero');
|
|
3517
|
-
} catch {
|
|
3518
|
-
flags.push('opacity-parse-failed');
|
|
3519
|
-
}
|
|
3520
|
-
|
|
3521
|
-
// clip / clip-path
|
|
3522
|
-
try {
|
|
3523
|
-
const clip = cs.clip != null ? String(cs.clip).trim() : '';
|
|
3524
|
-
const clipPath = cs.clipPath != null ? String(cs.clipPath).trim() : '';
|
|
3525
|
-
|
|
3526
|
-
const clipLow = clip.toLowerCase();
|
|
3527
|
-
const clipPathLow = clipPath.toLowerCase();
|
|
3528
|
-
|
|
3529
|
-
if (clipLow && clipLow !== 'auto') {
|
|
3530
|
-
// Detect common visually-hidden: rect(0,0,0,0)
|
|
3531
|
-
const norm = clipLow.replace(/\s+/g, '');
|
|
3532
|
-
if (norm.indexOf('rect(') !== -1 && norm.indexOf('rect(0') !== -1) hints.push('clipped');
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
|
-
if (clipPathLow && clipPathLow !== 'none') {
|
|
3536
|
-
// Detect common visually-hidden: inset(50%) / inset(100%)
|
|
3537
|
-
if (
|
|
3538
|
-
clipPathLow.indexOf('inset(') !== -1 &&
|
|
3539
|
-
(clipPathLow.indexOf('50%') !== -1 || clipPathLow.indexOf('100%') !== -1)
|
|
3540
|
-
) {
|
|
3541
|
-
hints.push('clipped');
|
|
3542
|
-
}
|
|
3543
|
-
}
|
|
3544
|
-
|
|
3545
|
-
if (clip) metrics.clip = clip;
|
|
3546
|
-
if (clipPath) metrics.clipPath = clipPath;
|
|
3547
|
-
} catch {
|
|
3548
|
-
flags.push('clip-parse-failed');
|
|
3549
|
-
}
|
|
3550
|
-
|
|
3551
|
-
// zero-size + overflow hidden/clip
|
|
3552
|
-
try {
|
|
3553
|
-
const wv = cs.width != null ? String(cs.width).trim() : '';
|
|
3554
|
-
const hv = cs.height != null ? String(cs.height).trim() : '';
|
|
3555
|
-
const ov = cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
|
|
3556
|
-
|
|
3557
|
-
metrics.width = wv || null;
|
|
3558
|
-
metrics.height = hv || null;
|
|
3559
|
-
metrics.overflow = ov || null;
|
|
3560
|
-
|
|
3561
|
-
const isZeroW = wv === '0px' || wv === '0';
|
|
3562
|
-
const isZeroH = hv === '0px' || hv === '0';
|
|
3563
|
-
const hidesOverflow = ov === 'hidden' || ov === 'clip';
|
|
3564
|
-
if ((isZeroW || isZeroH) && hidesOverflow) hints.push('zeroSizeOverflowHidden');
|
|
3565
|
-
} catch {
|
|
3566
|
-
flags.push('size-parse-failed');
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
// offscreen heuristic (string-based; no geometry)
|
|
3570
|
-
try {
|
|
3571
|
-
const pos = cs.position != null ? String(cs.position).trim().toLowerCase() : '';
|
|
3572
|
-
const left = cs.left != null ? String(cs.left).trim().toLowerCase() : '';
|
|
3573
|
-
const top = cs.top != null ? String(cs.top).trim().toLowerCase() : '';
|
|
3574
|
-
const ti = cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
|
|
3575
|
-
|
|
3576
|
-
metrics.position = pos || null;
|
|
3577
|
-
metrics.left = left || null;
|
|
3578
|
-
metrics.top = top || null;
|
|
3579
|
-
metrics.textIndent = ti || null;
|
|
3580
|
-
|
|
3581
|
-
const parsePx = (s) => {
|
|
3582
|
-
if (!s || s === 'auto') return null;
|
|
3583
|
-
const m = String(s).match(/-?\d+(\.\d+)?/);
|
|
3584
|
-
if (!m) return null;
|
|
3585
|
-
const n = Number.parseFloat(m[0]);
|
|
3586
|
-
return Number.isFinite(n) ? n : null;
|
|
3587
|
-
};
|
|
3588
|
-
|
|
3589
|
-
const l = parsePx(left);
|
|
3590
|
-
const t = parsePx(top);
|
|
3591
|
-
const ind = parsePx(ti);
|
|
3592
|
-
|
|
3593
|
-
if (pos === 'absolute' || pos === 'fixed') {
|
|
3594
|
-
if ((l != null && l <= -5000) || (t != null && t <= -5000)) hints.push('offscreen');
|
|
3595
|
-
}
|
|
3596
|
-
if (ind != null && ind <= -5000) hints.push('offscreen');
|
|
3597
|
-
} catch {
|
|
3598
|
-
flags.push('offscreen-parse-failed');
|
|
3599
|
-
}
|
|
3600
|
-
|
|
3601
|
-
// Dedupe hints, stable order
|
|
3602
|
-
const order = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
|
|
3603
|
-
const seen = new Set();
|
|
3604
|
-
const stable = [];
|
|
3605
|
-
for (const k of order) {
|
|
3606
|
-
if (hints.indexOf(k) !== -1 && !seen.has(k)) {
|
|
3607
|
-
seen.add(k);
|
|
3608
|
-
stable.push(k);
|
|
3609
|
-
}
|
|
3610
|
-
}
|
|
3611
|
-
|
|
3612
|
-
const out = { hints: stable, metrics, flags };
|
|
3613
|
-
|
|
3614
|
-
try {
|
|
3615
|
-
if (__visibilityHintsCache) {
|
|
3616
|
-
__visibilityHintsCache.set(el, {
|
|
3617
|
-
hints: stable.slice(0),
|
|
3618
|
-
metrics: { ...metrics },
|
|
3619
|
-
flags: flags.slice(0)
|
|
3620
|
-
});
|
|
3621
|
-
}
|
|
3622
|
-
} catch {
|
|
3623
|
-
__perfInc('visibilityHints.nocache');
|
|
3624
|
-
}
|
|
3625
|
-
|
|
3626
|
-
return out;
|
|
3627
|
-
}
|
|
3628
|
-
|
|
3629
|
-
// Back-compat: keep existing helper but implement via new name helper.
|
|
3630
|
-
function hasAccessibleName(el) {
|
|
3631
|
-
const info = getAccessibleNameInfo(el);
|
|
3632
|
-
return !!(info && info.present && trim(info.value));
|
|
3633
|
-
}
|
|
3634
|
-
|
|
3635
|
-
function createSelectorUniqIndex() {
|
|
3636
|
-
const idCount = new Map();
|
|
3637
|
-
const testIdCount = new Map(); // data-testid + data-test + data-cy + data-qa
|
|
3638
|
-
const nameCount = new Map(); // key: tag|name
|
|
3639
|
-
const ariaLabelCount = new Map(); // key: tag|aria-label
|
|
3640
|
-
const roleAriaLabelCount = new Map(); // key: role|aria-label
|
|
3641
|
-
|
|
3642
|
-
const sel = '[id],[data-testid],[data-test],[data-cy],[data-qa],[name],[aria-label],[role]';
|
|
3643
|
-
let nodes;
|
|
3644
|
-
if (typeof queryAllSmart === 'function') {
|
|
3645
|
-
nodes = queryAllSmart(sel) || [];
|
|
3646
|
-
} else {
|
|
3647
|
-
// Defensive fallback (queryAllSmart is always defined in this
|
|
3648
|
-
// module, so this branch is not expected to run) -- loop every
|
|
3649
|
-
// resolved root rather than assuming a single scope element.
|
|
3650
|
-
nodes = [];
|
|
3651
|
-
const seen = new Set();
|
|
3652
|
-
for (const r of roots) {
|
|
3653
|
-
if (!r || !r.querySelectorAll) continue;
|
|
3654
|
-
for (const el of r.querySelectorAll(sel)) {
|
|
3655
|
-
if (el && !seen.has(el)) {
|
|
3656
|
-
seen.add(el);
|
|
3657
|
-
nodes.push(el);
|
|
3658
|
-
}
|
|
3659
|
-
}
|
|
3660
|
-
}
|
|
3661
|
-
if (!nodes.length && !roots.length && document) {
|
|
3662
|
-
nodes = Array.from(document.querySelectorAll(sel));
|
|
3663
|
-
}
|
|
3664
|
-
}
|
|
3665
|
-
|
|
3666
|
-
const inc = (map, key) => map.set(key, (map.get(key) || 0) + 1);
|
|
3667
|
-
|
|
3668
|
-
for (const el of nodes) {
|
|
3669
|
-
if (!el || el.nodeType !== 1) continue;
|
|
3670
|
-
|
|
3671
|
-
const tag = (el.tagName || '').toLowerCase();
|
|
3672
|
-
|
|
3673
|
-
const elementId = el.getAttribute('id');
|
|
3674
|
-
if (elementId && elementId.trim()) inc(idCount, elementId.trim());
|
|
3675
|
-
|
|
3676
|
-
for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
|
|
3677
|
-
const v = el.getAttribute(a);
|
|
3678
|
-
if (v && v.trim()) inc(testIdCount, a + '=' + v.trim());
|
|
3679
|
-
}
|
|
3680
|
-
|
|
3681
|
-
const name = el.getAttribute('name');
|
|
3682
|
-
if (name && name.trim() && tag) inc(nameCount, tag + '|' + name.trim());
|
|
3683
|
-
|
|
3684
|
-
const aria = el.getAttribute('aria-label');
|
|
3685
|
-
if (aria && aria.trim() && tag) inc(ariaLabelCount, tag + '|' + aria.trim());
|
|
3686
|
-
|
|
3687
|
-
const role = el.getAttribute('role');
|
|
3688
|
-
if (role && role.trim() && aria && aria.trim()) {
|
|
3689
|
-
inc(roleAriaLabelCount, role.trim() + '|' + aria.trim());
|
|
3690
|
-
}
|
|
3691
|
-
}
|
|
3692
|
-
|
|
3693
|
-
return { idCount, testIdCount, nameCount, ariaLabelCount, roleAriaLabelCount };
|
|
3694
|
-
}
|
|
3695
|
-
|
|
3696
|
-
function buildSimpleSelector(el, fallbackTag) {
|
|
3697
|
-
try {
|
|
3698
|
-
if (!el || el.nodeType !== 1) return fallbackTag || 'html';
|
|
3699
|
-
|
|
3700
|
-
const tag = (el.tagName || fallbackTag || 'html').toLowerCase();
|
|
3701
|
-
|
|
3702
|
-
const cssEscapeIdent = __cssEscapeIdent;
|
|
3703
|
-
|
|
3704
|
-
const escapeAttrValue = __escapeAttrValue;
|
|
3705
|
-
|
|
3706
|
-
const elementId = el.getAttribute && el.getAttribute('id');
|
|
3707
|
-
if (elementId && elementId.trim()) return '#' + cssEscapeIdent(elementId.trim());
|
|
3708
|
-
|
|
3709
|
-
for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
|
|
3710
|
-
const v = el.getAttribute && el.getAttribute(a);
|
|
3711
|
-
if (v && v.trim()) return '[' + a + '="' + escapeAttrValue(v.trim()) + '"]';
|
|
3712
|
-
}
|
|
3713
|
-
|
|
3714
|
-
const name = el.getAttribute && el.getAttribute('name');
|
|
3715
|
-
if (name && name.trim()) return tag + '[name="' + escapeAttrValue(name.trim()) + '"]';
|
|
3716
|
-
|
|
3717
|
-
return tag;
|
|
3718
|
-
} catch {
|
|
3719
|
-
return fallbackTag || 'html';
|
|
3720
|
-
}
|
|
3721
|
-
}
|
|
3722
|
-
|
|
3723
|
-
function getUniqIndex() {
|
|
3724
|
-
const scopeObj = __getScopeObj();
|
|
3725
|
-
if (!scopeObj || !__uniqIndexByScope) {
|
|
3726
|
-
__perfInc('uniqIndex.nocache');
|
|
3727
|
-
// Fallback: build per call (should be rare; determinism preserved)
|
|
3728
|
-
return createSelectorUniqIndex();
|
|
3729
|
-
}
|
|
3730
|
-
|
|
3731
|
-
// Partitioned by __selectorOptsKey: the index's counts depend on
|
|
3732
|
-
// includeShadowDom/excludeSelectors (via queryAllSmart), so a scope
|
|
3733
|
-
// reused across runs with different options must not share indices.
|
|
3734
|
-
let perScope;
|
|
3735
|
-
try {
|
|
3736
|
-
perScope = __uniqIndexByScope.get(scopeObj);
|
|
3737
|
-
if (!(perScope instanceof Map)) {
|
|
3738
|
-
perScope = new Map();
|
|
3739
|
-
__uniqIndexByScope.set(scopeObj, perScope);
|
|
3740
|
-
}
|
|
3741
|
-
} catch {
|
|
3742
|
-
__perfInc('uniqIndex.nocache');
|
|
3743
|
-
return createSelectorUniqIndex();
|
|
3744
|
-
}
|
|
3745
|
-
|
|
3746
|
-
const key = __getSelectorOptsKey();
|
|
3747
|
-
const cached = perScope.get(key);
|
|
3748
|
-
if (cached) {
|
|
3749
|
-
__perfInc('uniqIndex.hit');
|
|
3750
|
-
return cached;
|
|
3751
|
-
}
|
|
3752
|
-
|
|
3753
|
-
__perfInc('uniqIndex.miss');
|
|
3754
|
-
const idx = createSelectorUniqIndex();
|
|
3755
|
-
try {
|
|
3756
|
-
perScope.set(key, idx);
|
|
3757
|
-
} catch {
|
|
3758
|
-
/* ignore */
|
|
3759
|
-
}
|
|
3760
|
-
__perfInc('uniqIndex.build');
|
|
3761
|
-
return idx;
|
|
3762
|
-
}
|
|
3763
|
-
|
|
3764
|
-
function buildSelectorUncached(el) {
|
|
3765
|
-
const escapeAttrValue = __escapeAttrValue;
|
|
3766
|
-
try {
|
|
3767
|
-
if (!el || el.nodeType !== 1) return 'html';
|
|
3768
|
-
|
|
3769
|
-
const cssEscape = __cssEscapeIdent;
|
|
3770
|
-
|
|
3771
|
-
const idx = getUniqIndex();
|
|
3772
|
-
const tag = (el.tagName || '').toLowerCase();
|
|
3773
|
-
|
|
3774
|
-
// NOTE: every anchor builder below keys its uniqueness-index lookup on
|
|
3775
|
-
// the *trimmed* attribute value (matching how the index itself was
|
|
3776
|
-
// built in createSelectorUniqIndex, so whitespace-only differences
|
|
3777
|
-
// don't spuriously count as "different" values) but embeds the *raw,
|
|
3778
|
-
// untrimmed* attribute value in the actual CSS selector string. A CSS
|
|
3779
|
-
// attribute selector (`[attr="..."]`) requires an exact match against
|
|
3780
|
-
// the real DOM attribute -- trimming the embedded value while the
|
|
3781
|
-
// real attribute keeps its whitespace produces a selector that can
|
|
3782
|
-
// never match. Found 2026-08-02 via the cross-engine comparisons
|
|
3783
|
-
// project on Slack's real homepage: several `role="region"` promo
|
|
3784
|
-
// cards have a templated `aria-label` ending in a trailing ", " (a
|
|
3785
|
-
// string-concatenation artifact, not a typo), which made every one of
|
|
3786
|
-
// these anchor builders silently construct a non-matching selector,
|
|
3787
|
-
// falling through to the document-wide non-unique `buildSimpleSelector`
|
|
3788
|
-
// tag-only fallback (e.g. plain "header") for elements that actually
|
|
3789
|
-
// had a perfectly good, unique aria-label anchor available -- and any
|
|
3790
|
-
// downstream consumer re-resolving that bare-tag selector via
|
|
3791
|
-
// `querySelector` (this comparisons project's own tooling included)
|
|
3792
|
-
// silently gets the *wrong* element instead of an error.
|
|
3793
|
-
const uniqueIdSel = () => {
|
|
3794
|
-
const elementId = el.getAttribute('id');
|
|
3795
|
-
if (!elementId || !elementId.trim()) return null;
|
|
3796
|
-
if (idx && (idx.idCount.get(elementId.trim()) || 0) === 1)
|
|
3797
|
-
return '#' + cssEscape(elementId);
|
|
3798
|
-
return null;
|
|
3799
|
-
};
|
|
3800
|
-
|
|
3801
|
-
const uniqueTestSel = () => {
|
|
3802
|
-
for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
|
|
3803
|
-
const v = el.getAttribute(a);
|
|
3804
|
-
if (!v || !v.trim()) continue;
|
|
3805
|
-
const key = a + '=' + v.trim();
|
|
3806
|
-
if (idx && (idx.testIdCount.get(key) || 0) === 1) {
|
|
3807
|
-
return '[' + a + '="' + escapeAttrValue(v) + '"]';
|
|
3808
|
-
}
|
|
3809
|
-
}
|
|
3810
|
-
return null;
|
|
3811
|
-
};
|
|
3812
|
-
|
|
3813
|
-
const uniqueNameSel = () => {
|
|
3814
|
-
const v = el.getAttribute('name');
|
|
3815
|
-
if (!v || !v.trim() || !tag) return null;
|
|
3816
|
-
const key = tag + '|' + v.trim();
|
|
3817
|
-
if (idx && (idx.nameCount.get(key) || 0) === 1)
|
|
3818
|
-
return tag + '[name="' + escapeAttrValue(v) + '"]';
|
|
3819
|
-
return null;
|
|
3820
|
-
};
|
|
3821
|
-
|
|
3822
|
-
const uniqueAriaSel = () => {
|
|
3823
|
-
const v = el.getAttribute('aria-label');
|
|
3824
|
-
if (!v || !v.trim() || !tag) return null;
|
|
3825
|
-
const key = tag + '|' + v.trim();
|
|
3826
|
-
if (idx && (idx.ariaLabelCount.get(key) || 0) === 1)
|
|
3827
|
-
return tag + '[aria-label="' + escapeAttrValue(v) + '"]';
|
|
3828
|
-
return null;
|
|
3829
|
-
};
|
|
3830
|
-
|
|
3831
|
-
const uniqueRoleAriaSel = () => {
|
|
3832
|
-
const role = el.getAttribute('role');
|
|
3833
|
-
const aria = el.getAttribute('aria-label');
|
|
3834
|
-
if (!role || !role.trim() || !aria || !aria.trim()) return null;
|
|
3835
|
-
const key = role.trim() + '|' + aria.trim();
|
|
3836
|
-
if (idx && (idx.roleAriaLabelCount.get(key) || 0) === 1) {
|
|
3837
|
-
return (
|
|
3838
|
-
'[role="' + escapeAttrValue(role) + '"][aria-label="' + escapeAttrValue(aria) + '"]'
|
|
3839
|
-
);
|
|
3840
|
-
}
|
|
3841
|
-
return null;
|
|
3842
|
-
};
|
|
3843
|
-
|
|
3844
|
-
const direct =
|
|
3845
|
-
uniqueIdSel() ||
|
|
3846
|
-
uniqueTestSel() ||
|
|
3847
|
-
uniqueRoleAriaSel() ||
|
|
3848
|
-
uniqueNameSel() ||
|
|
3849
|
-
uniqueAriaSel();
|
|
3850
|
-
|
|
3851
|
-
if (direct) return direct;
|
|
3852
|
-
|
|
3853
|
-
const parts = [];
|
|
3854
|
-
|
|
3855
|
-
function nthOfType(node) {
|
|
3856
|
-
const t = (node.tagName || '').toLowerCase() || '*';
|
|
3857
|
-
const p = node.parentElement;
|
|
3858
|
-
if (!p) return t;
|
|
3859
|
-
|
|
3860
|
-
let i = 1;
|
|
3861
|
-
let sib = node.previousElementSibling;
|
|
3862
|
-
while (sib) {
|
|
3863
|
-
if ((sib.tagName || '').toLowerCase() === t) i++;
|
|
3864
|
-
sib = sib.previousElementSibling;
|
|
3865
|
-
}
|
|
3866
|
-
|
|
3867
|
-
// A same-tag sibling before this node (i > 1) already means
|
|
3868
|
-
// an unqualified tag selector would be ambiguous — no need
|
|
3869
|
-
// to also scan forward in that case. Only scan
|
|
3870
|
-
// nextElementSibling when this node is the first of its tag
|
|
3871
|
-
// among its siblings, to catch the case where the
|
|
3872
|
-
// disambiguating sibling comes after it instead.
|
|
3873
|
-
let hasSame = i > 1;
|
|
3874
|
-
if (!hasSame) {
|
|
3875
|
-
sib = node.nextElementSibling;
|
|
3876
|
-
while (sib) {
|
|
3877
|
-
if ((sib.tagName || '').toLowerCase() === t) {
|
|
3878
|
-
hasSame = true;
|
|
3879
|
-
break;
|
|
3880
|
-
}
|
|
3881
|
-
sib = sib.nextElementSibling;
|
|
3882
|
-
}
|
|
3883
|
-
}
|
|
3884
|
-
return hasSame ? t + ':nth-of-type(' + i + ')' : t;
|
|
3885
|
-
}
|
|
3886
|
-
|
|
3887
|
-
let node = el;
|
|
3888
|
-
let safety = 0;
|
|
3889
|
-
|
|
3890
|
-
// Only apply the "stop climbing once we reach a contextSelector-
|
|
3891
|
-
// matched root" shortcut when there's a single (or no) matched
|
|
3892
|
-
// root -- resolveContextRoots() falls back to `[documentElement]`
|
|
3893
|
-
// when no contextSelector is given, so this is the overwhelmingly
|
|
3894
|
-
// common case and behaves exactly as before.
|
|
3895
|
-
//
|
|
3896
|
-
// With MULTIPLE matched roots (multi-region contextSelector
|
|
3897
|
-
// scans), stopping there without recording anything about which
|
|
3898
|
-
// root produced an ambiguous, non-unique selector string for two
|
|
3899
|
-
// structurally-identical regions -- a real, confirmed bug (found
|
|
3900
|
-
// 2026-07-29 via the cross-engine comparisons project): two
|
|
3901
|
-
// wrapper <div>s, each containing two identical ".widget"
|
|
3902
|
-
// sections scanned via `contextSelector: '.widget'`, produced the
|
|
3903
|
-
// *same* selector string ("section:nth-of-type(1) > div > div >
|
|
3904
|
-
// button") for the equivalent button in each wrapper --
|
|
3905
|
-
// resolving to 2 elements instead of 1 when queried, and pointing
|
|
3906
|
-
// at the wrong one for at least one of the two occurrences. The
|
|
3907
|
-
// existing `el.matches(candidate)` safety check below couldn't
|
|
3908
|
-
// catch this: it only verifies THIS element matches the string,
|
|
3909
|
-
// never that the string is unique document-wide.
|
|
3910
|
-
//
|
|
3911
|
-
// Fix: when multiple roots are in play, don't stop early --
|
|
3912
|
-
// keep climbing (same as the always-correct no-contextSelector
|
|
3913
|
-
// path) until finding a genuinely unique anchor or reaching the
|
|
3914
|
-
// true document root, which is always singular. That restores
|
|
3915
|
-
// the invariant the final safety-check comment below relies on,
|
|
3916
|
-
// rather than needing a separate (more expensive) document-wide
|
|
3917
|
-
// uniqueness re-check.
|
|
3918
|
-
const stopAtMatchedRoot = roots.length <= 1;
|
|
3919
|
-
|
|
3920
|
-
while (node && node.nodeType === 1 && safety++ < 20) {
|
|
3921
|
-
let anchor = null;
|
|
3922
|
-
|
|
3923
|
-
if (node !== el) {
|
|
3924
|
-
const t = (node.tagName || '').toLowerCase();
|
|
3925
|
-
// Same trimmed-key-lookup / raw-value-embed split as the direct
|
|
3926
|
-
// anchor builders above -- see this function's header comment.
|
|
3927
|
-
const id = node.getAttribute('id');
|
|
3928
|
-
if (id && id.trim() && idx && (idx.idCount.get(id.trim()) || 0) === 1)
|
|
3929
|
-
anchor = '#' + cssEscape(id);
|
|
3930
|
-
if (!anchor) {
|
|
3931
|
-
for (const a of ['data-testid', 'data-test', 'data-cy', 'data-qa']) {
|
|
3932
|
-
const v = node.getAttribute(a);
|
|
3933
|
-
if (v && v.trim() && idx && (idx.testIdCount.get(a + '=' + v.trim()) || 0) === 1) {
|
|
3934
|
-
anchor = '[' + a + '="' + escapeAttrValue(v) + '"]';
|
|
3935
|
-
break;
|
|
3936
|
-
}
|
|
3937
|
-
}
|
|
3938
|
-
}
|
|
3939
|
-
if (!anchor) {
|
|
3940
|
-
const name = node.getAttribute('name');
|
|
3941
|
-
if (
|
|
3942
|
-
name &&
|
|
3943
|
-
name.trim() &&
|
|
3944
|
-
t &&
|
|
3945
|
-
idx &&
|
|
3946
|
-
(idx.nameCount.get(t + '|' + name.trim()) || 0) === 1
|
|
3947
|
-
) {
|
|
3948
|
-
anchor = t + '[name="' + escapeAttrValue(name) + '"]';
|
|
3949
|
-
}
|
|
3950
|
-
}
|
|
3951
|
-
if (!anchor) {
|
|
3952
|
-
const aria = node.getAttribute('aria-label');
|
|
3953
|
-
if (
|
|
3954
|
-
aria &&
|
|
3955
|
-
aria.trim() &&
|
|
3956
|
-
t &&
|
|
3957
|
-
idx &&
|
|
3958
|
-
(idx.ariaLabelCount.get(t + '|' + aria.trim()) || 0) === 1
|
|
3959
|
-
) {
|
|
3960
|
-
anchor = t + '[aria-label="' + escapeAttrValue(aria) + '"]';
|
|
3961
|
-
}
|
|
3962
|
-
}
|
|
3963
|
-
}
|
|
3964
|
-
|
|
3965
|
-
if (node === el) {
|
|
3966
|
-
parts.unshift(nthOfType(node));
|
|
3967
|
-
} else if (anchor) {
|
|
3968
|
-
parts.unshift(anchor);
|
|
3969
|
-
break;
|
|
3970
|
-
} else {
|
|
3971
|
-
parts.unshift(nthOfType(node));
|
|
3972
|
-
}
|
|
3973
|
-
|
|
3974
|
-
if (!node.parentElement || (stopAtMatchedRoot && roots.includes(node))) break;
|
|
3975
|
-
node = node.parentElement;
|
|
3976
|
-
}
|
|
3977
|
-
|
|
3978
|
-
const candidate = parts.join(' > ') || tag || 'html';
|
|
3979
|
-
|
|
3980
|
-
// Verify the constructed selector string actually resolves to
|
|
3981
|
-
// `el` per the CSS engine's own semantics — a real safety net,
|
|
3982
|
-
// since some selector engines (observed in jsdom) disagree with
|
|
3983
|
-
// this function's own :nth-of-type sibling counting in edge
|
|
3984
|
-
// cases. `el.matches(candidate)` checks exactly that (does the
|
|
3985
|
-
// engine agree this element satisfies the string we built) at a
|
|
3986
|
-
// cost bounded by el's own ancestor-chain depth.
|
|
3987
|
-
//
|
|
3988
|
-
// This intentionally does NOT re-verify global uniqueness via a
|
|
3989
|
-
// whole-document query: every path segment above pins an exact
|
|
3990
|
-
// position relative to its own parent via `>` (child, not
|
|
3991
|
-
// descendant) combinators, so a correctly-matching chain can
|
|
3992
|
-
// only resolve to one element short of a malformed document
|
|
3993
|
-
// (e.g. two <html> roots) -- true as long as the walk above
|
|
3994
|
-
// never stops short of a genuinely unique anchor/root, which is
|
|
3995
|
-
// exactly what `stopAtMatchedRoot` now guarantees (see its own
|
|
3996
|
-
// comment above; a multi-root contextSelector scan stopping
|
|
3997
|
-
// early used to violate this invariant silently). Re-deriving
|
|
3998
|
-
// that guarantee via a
|
|
3999
|
-
// document-wide :nth-of-type scan was measured to cost O(total
|
|
4000
|
-
// same-tag siblings) per call — pathological on pages with many
|
|
4001
|
-
// flat, unidentified siblings (e.g. hundreds of unlabeled
|
|
4002
|
-
// <img>s), while contributing no realistic additional safety.
|
|
4003
|
-
try {
|
|
4004
|
-
if (el && typeof el.matches === 'function' && el.matches(candidate)) return candidate;
|
|
4005
|
-
} catch {}
|
|
4006
|
-
|
|
4007
|
-
return buildSimpleSelector(el, tag || 'html');
|
|
4008
|
-
} catch {
|
|
4009
|
-
return 'html';
|
|
4010
|
-
}
|
|
4011
|
-
}
|
|
4012
|
-
|
|
4013
|
-
function buildSelector(el) {
|
|
4014
|
-
const cache = __getSelectorCacheForOpts();
|
|
4015
|
-
try {
|
|
4016
|
-
if (cache && el && typeof el === 'object' && cache.has(el)) {
|
|
4017
|
-
__perfInc('selector.hit');
|
|
4018
|
-
return cache.get(el) || 'html';
|
|
4019
|
-
}
|
|
4020
|
-
} catch {}
|
|
4021
|
-
__perfInc('selector.miss');
|
|
4022
|
-
const sel = buildSelectorUncached(el);
|
|
4023
|
-
try {
|
|
4024
|
-
if (cache && el && typeof el === 'object') cache.set(el, sel);
|
|
4025
|
-
} catch {}
|
|
4026
|
-
return sel;
|
|
4027
|
-
}
|
|
4028
|
-
|
|
4029
|
-
// Sibling-index path from documentElement's descendants down to `el`
|
|
4030
|
-
// ([] if `el` IS the documentElement); null if `el` is falsy or detached
|
|
4031
|
-
// in a way that makes indexing impossible. A more robust element-identity
|
|
4032
|
-
// mechanism than a CSS selector string alone (survives some DOM changes
|
|
4033
|
-
// a selector wouldn't -- e.g. an id/class rename), at the cost of not
|
|
4034
|
-
// being usable as a real CSS selector itself. Deliberately mirrors the
|
|
4035
|
-
// same algorithm used by this project's (external) cross-engine
|
|
4036
|
-
// result-matching tooling exactly, rather than requiring it --
|
|
4037
|
-
// this file must stay self-contained (embedded into the generated
|
|
4038
|
-
// runtime via .toString(), no module requires survive that), so a
|
|
4039
|
-
// correctness fix to the algorithm must be applied to both copies.
|
|
4040
|
-
function structuralPath(el) {
|
|
4041
|
-
if (!el || typeof el !== 'object') return null;
|
|
4042
|
-
const path = [];
|
|
4043
|
-
let node = el;
|
|
4044
|
-
try {
|
|
4045
|
-
while (node && node.parentElement) {
|
|
4046
|
-
const parent = node.parentElement;
|
|
4047
|
-
const idx = Array.prototype.indexOf.call(parent.children, node);
|
|
4048
|
-
if (idx < 0) return null;
|
|
4049
|
-
path.unshift(idx);
|
|
4050
|
-
node = parent;
|
|
4051
|
-
}
|
|
4052
|
-
} catch {
|
|
4053
|
-
return null;
|
|
4054
|
-
}
|
|
4055
|
-
return path;
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
|
-
// Occurrence-level structural path: prefers the actual element reference
|
|
4059
|
-
// (exact, no re-resolution risk) and only falls back to re-resolving via
|
|
4060
|
-
// the occurrence's own selector when no element reference was kept --
|
|
4061
|
-
// the same technique the cross-engine live-DOM adapters already use to
|
|
4062
|
-
// recover an element from a reported selector, with the same accepted
|
|
4063
|
-
// caveat (a non-unique selector could resolve to a different element
|
|
4064
|
-
// than originally intended -- already documented as "structural-path
|
|
4065
|
-
// collisions" for the cross-engine tooling).
|
|
4066
|
-
function buildStructuralPath(node, selector) {
|
|
4067
|
-
if (node && typeof node === 'object') {
|
|
4068
|
-
const p = structuralPath(node);
|
|
4069
|
-
if (p) return p;
|
|
4070
|
-
}
|
|
4071
|
-
if (
|
|
4072
|
-
selector &&
|
|
4073
|
-
typeof selector === 'string' &&
|
|
4074
|
-
document &&
|
|
4075
|
-
typeof document.querySelector === 'function'
|
|
4076
|
-
) {
|
|
4077
|
-
let el;
|
|
4078
|
-
try {
|
|
4079
|
-
el = document.querySelector(selector);
|
|
4080
|
-
} catch {
|
|
4081
|
-
el = null;
|
|
4082
|
-
}
|
|
4083
|
-
if (el) return structuralPath(el);
|
|
4084
|
-
}
|
|
4085
|
-
return null;
|
|
4086
|
-
}
|
|
4087
|
-
|
|
4088
|
-
function getNonEmptyTitle(el) {
|
|
4089
|
-
if (!getAttributeInfo) return null;
|
|
4090
|
-
try {
|
|
4091
|
-
const info = getAttributeInfo(el, 'title');
|
|
4092
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
4093
|
-
return v ? v : null;
|
|
4094
|
-
} catch {
|
|
4095
|
-
return null;
|
|
4096
|
-
}
|
|
4097
|
-
}
|
|
4098
|
-
|
|
4099
|
-
function isPlaceholderCapable(el) {
|
|
4100
|
-
// Per HTML, `placeholder` is only a name/hint source for text-entry
|
|
4101
|
-
// input types and <textarea> — browsers/AT ignore it on other input
|
|
4102
|
-
// types (checkbox, radio, range, color, date, file, ...) and on
|
|
4103
|
-
// <select>, so it must not be treated as an accessible-name source
|
|
4104
|
-
// for those.
|
|
4105
|
-
try {
|
|
4106
|
-
if (!isElement(el)) return false;
|
|
4107
|
-
const tag = (el.tagName || '').toLowerCase();
|
|
4108
|
-
if (tag === 'textarea') return true;
|
|
4109
|
-
if (tag !== 'input') return false;
|
|
4110
|
-
const type = ((el.getAttribute && (el.getAttribute('type') || 'text')) || 'text')
|
|
4111
|
-
.toLowerCase()
|
|
4112
|
-
.trim();
|
|
4113
|
-
const t = type || 'text';
|
|
4114
|
-
return (
|
|
4115
|
-
t === 'text' ||
|
|
4116
|
-
t === 'search' ||
|
|
4117
|
-
t === 'tel' ||
|
|
4118
|
-
t === 'url' ||
|
|
4119
|
-
t === 'email' ||
|
|
4120
|
-
t === 'password' ||
|
|
4121
|
-
t === 'number'
|
|
4122
|
-
);
|
|
4123
|
-
} catch {
|
|
4124
|
-
return false;
|
|
4125
|
-
}
|
|
4126
|
-
}
|
|
4127
|
-
|
|
4128
|
-
function getNonEmptyPlaceholder(el) {
|
|
4129
|
-
if (!getAttributeInfo) return null;
|
|
4130
|
-
if (!isPlaceholderCapable(el)) return null;
|
|
4131
|
-
try {
|
|
4132
|
-
const info = getAttributeInfo(el, 'placeholder');
|
|
4133
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
4134
|
-
return v ? v : null;
|
|
4135
|
-
} catch {
|
|
4136
|
-
return null;
|
|
4137
|
-
}
|
|
4138
|
-
}
|
|
4139
|
-
|
|
4140
|
-
// A <label> contributes a name to its associated control either via its
|
|
4141
|
-
// own aria-label/aria-labelledby (checked first, same precedence any
|
|
4142
|
-
// element's accessible name gives ARIA over content — verified against
|
|
4143
|
-
// a real page: <label aria-label="Toggle Navigation"><svg
|
|
4144
|
-
// aria-hidden="true">...</svg></label> names its control "Toggle
|
|
4145
|
-
// Navigation" even though the label's only child content is aria-
|
|
4146
|
-
// hidden) or, failing that, its rendered content (getContentNameInfo,
|
|
4147
|
-
// which already excludes aria-hidden/display:none/inert descendants —
|
|
4148
|
-
// e.g. <label><input><span aria-hidden="true">Accept</span></label>
|
|
4149
|
-
// gives the control no name despite the DOM association existing).
|
|
4150
|
-
function labelContributesAccessibleName(lab) {
|
|
4151
|
-
try {
|
|
4152
|
-
const aria = getAriaNameInfo(lab, null, {});
|
|
4153
|
-
if (aria && aria.present && trim(aria.value)) return true;
|
|
4154
|
-
} catch {}
|
|
4155
|
-
try {
|
|
4156
|
-
const info = getContentNameInfo(lab, null, {});
|
|
4157
|
-
if (info && info.present && trim(info.value)) return true;
|
|
4158
|
-
} catch {
|
|
4159
|
-
return true; // conservative on error: don't newly fail
|
|
4160
|
-
}
|
|
4161
|
-
// A <label> with no aria-name and empty own content can still
|
|
4162
|
-
// contribute a name via its own title attribute -- accname's title-
|
|
4163
|
-
// fallback step applies to any element, the label itself included.
|
|
4164
|
-
// Found via a full fixtures cross-engine regression 2026-08-02:
|
|
4165
|
-
// slider-name-present-all-scenarios.html's case_22 documents exactly
|
|
4166
|
-
// this (<label for="..." title="Search"></label>, empty content) as an
|
|
4167
|
-
// intentional PASS, and axe's `label` rule agrees -- this helper never
|
|
4168
|
-
// checked the label's own title, so
|
|
4169
|
-
// form-control-programmatic-label-present wrongly failed it.
|
|
4170
|
-
try {
|
|
4171
|
-
return !!getNonEmptyTitle(lab);
|
|
4172
|
-
} catch {
|
|
4173
|
-
return false;
|
|
4174
|
-
}
|
|
4175
|
-
}
|
|
4176
|
-
|
|
4177
|
-
function hasLabelAssociation(el) {
|
|
4178
|
-
// Deterministic, stable subset:
|
|
4179
|
-
// - <label for="id">
|
|
4180
|
-
// - wrapping <label> ... <input> ...
|
|
4181
|
-
// A structural association alone isn't enough — see
|
|
4182
|
-
// labelContributesAccessibleName above for what counts.
|
|
4183
|
-
if (!isElement(el)) return false;
|
|
4184
|
-
|
|
4185
|
-
try {
|
|
4186
|
-
if (
|
|
4187
|
-
__labelAssociationCache &&
|
|
4188
|
-
el &&
|
|
4189
|
-
typeof el === 'object' &&
|
|
4190
|
-
__labelAssociationCache.has(el)
|
|
4191
|
-
) {
|
|
4192
|
-
__perfInc('labelAssociation.hit');
|
|
4193
|
-
return !!__labelAssociationCache.get(el);
|
|
4194
|
-
}
|
|
4195
|
-
} catch {}
|
|
4196
|
-
|
|
4197
|
-
__perfInc('labelAssociation.miss');
|
|
4198
|
-
let out = false;
|
|
4199
|
-
let associatedLabels = [];
|
|
4200
|
-
|
|
4201
|
-
// Prefer the native `.labels` API — resolves both wrapping <label>
|
|
4202
|
-
// and <label for="id"> association in one call, as real elements.
|
|
4203
|
-
try {
|
|
4204
|
-
if (el && 'labels' in el && el.labels && el.labels.length) {
|
|
4205
|
-
associatedLabels = Array.prototype.slice.call(el.labels);
|
|
4206
|
-
}
|
|
4207
|
-
} catch {}
|
|
4208
|
-
|
|
4209
|
-
if (!associatedLabels.length) {
|
|
4210
|
-
// Fallback for environments without a working `.labels` API:
|
|
4211
|
-
// structural-only (pre-existing behavior, no content check —
|
|
4212
|
-
// __lookupLabelForId's cache doesn't retain an element ref).
|
|
4213
|
-
const id = trim(getAttr(el, 'id'));
|
|
4214
|
-
if (id) {
|
|
4215
|
-
const entry = __lookupLabelForId(id, '__default__');
|
|
4216
|
-
if (entry && entry.exists) out = true;
|
|
4217
|
-
}
|
|
4218
|
-
|
|
4219
|
-
if (!out && el.closest) {
|
|
4220
|
-
try {
|
|
4221
|
-
const wrap = el.closest('label');
|
|
4222
|
-
if (wrap && isElement(wrap)) associatedLabels = [wrap];
|
|
4223
|
-
} catch {}
|
|
4224
|
-
}
|
|
4225
|
-
}
|
|
4226
|
-
|
|
4227
|
-
if (!out && associatedLabels.length) {
|
|
4228
|
-
out = associatedLabels.some(labelContributesAccessibleName);
|
|
4229
|
-
}
|
|
4230
|
-
|
|
4231
|
-
try {
|
|
4232
|
-
if (__labelAssociationCache && el && typeof el === 'object')
|
|
4233
|
-
__labelAssociationCache.set(el, !!out);
|
|
4234
|
-
} catch {}
|
|
4235
|
-
|
|
4236
|
-
return out;
|
|
4237
|
-
}
|
|
4238
|
-
|
|
4239
|
-
function getLabelMethod(el, _ctx, opts) {
|
|
4240
|
-
// returns { method, value } where value is best-effort text, deterministically trimmed
|
|
4241
|
-
if (!isElement(el)) return { method: 'none', value: null };
|
|
4242
|
-
|
|
4243
|
-
try {
|
|
4244
|
-
if (__labelMethodCache && el && typeof el === 'object' && __labelMethodCache.has(el)) {
|
|
4245
|
-
__perfInc('labelMethod.hit');
|
|
4246
|
-
const c = __labelMethodCache.get(el);
|
|
4247
|
-
if (c && typeof c === 'object') {
|
|
4248
|
-
return { method: c.method || 'none', value: c.value == null ? null : String(c.value) };
|
|
4249
|
-
}
|
|
4250
|
-
}
|
|
4251
|
-
} catch {}
|
|
4252
|
-
|
|
4253
|
-
__perfInc('labelMethod.miss');
|
|
4254
|
-
let out = { method: 'none', value: null };
|
|
4255
|
-
|
|
4256
|
-
if (hasLabelAssociation(el)) out = { method: 'label', value: null };
|
|
4257
|
-
else if (getAriaLabelledByInfo) {
|
|
4258
|
-
try {
|
|
4259
|
-
const info = getAriaLabelledByInfo(el, _ctx, { maxRefs: 8 });
|
|
4260
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
4261
|
-
if (v) out = { method: 'aria-labelledby', value: v };
|
|
4262
|
-
} catch {}
|
|
4263
|
-
}
|
|
4264
|
-
|
|
4265
|
-
if (out.method === 'none' && getAriaLabelInfo) {
|
|
4266
|
-
try {
|
|
4267
|
-
const info = getAriaLabelInfo(el);
|
|
4268
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
4269
|
-
if (v) out = { method: 'aria-label', value: v };
|
|
4270
|
-
} catch {}
|
|
4271
|
-
}
|
|
4272
|
-
|
|
4273
|
-
if (out.method === 'none') {
|
|
4274
|
-
const titleV = getNonEmptyTitle(el);
|
|
4275
|
-
if (titleV) out = { method: 'title', value: titleV };
|
|
4276
|
-
}
|
|
4277
|
-
|
|
4278
|
-
if (out.method === 'none') {
|
|
4279
|
-
const phV = getNonEmptyPlaceholder(el);
|
|
4280
|
-
if (phV) out = { method: 'placeholder', value: phV };
|
|
4281
|
-
}
|
|
4282
|
-
|
|
4283
|
-
try {
|
|
4284
|
-
if (__labelMethodCache && el && typeof el === 'object') {
|
|
4285
|
-
__labelMethodCache.set(el, { method: out.method, value: out.value });
|
|
4286
|
-
}
|
|
4287
|
-
} catch {}
|
|
4288
|
-
|
|
4289
|
-
return out;
|
|
4290
|
-
}
|
|
4291
|
-
|
|
4292
|
-
function getLabelStrength(method) {
|
|
4293
|
-
// policy choice; this is deterministic and tweakable
|
|
4294
|
-
if (method === 'label' || method === 'aria-labelledby') return 'strong';
|
|
4295
|
-
if (method === 'aria-label') return 'medium';
|
|
4296
|
-
if (method === 'title' || method === 'placeholder') return 'weak';
|
|
4297
|
-
return 'none';
|
|
4298
|
-
}
|
|
4299
|
-
|
|
4300
|
-
function reportOccurrence(node, partial) {
|
|
4301
|
-
const o =
|
|
4302
|
-
partial && typeof partial === 'object' && !Array.isArray(partial) ? { ...partial } : {};
|
|
4303
|
-
// Attach the node for engine-side finalization. This must be removed later before returning results.
|
|
4304
|
-
o.__node = node || null;
|
|
4305
|
-
return o;
|
|
4306
|
-
}
|
|
4307
|
-
|
|
4308
|
-
// Resolves the final {outcome, severity, occurrences} for a rule that
|
|
4309
|
-
// collects two independent confidence tiers during one run — some
|
|
4310
|
-
// findings are confident enough for a hard `fail`, others only warrant
|
|
4311
|
-
// `cantTell` (e.g. "this needs human review"). The naive approach
|
|
4312
|
-
// (`if (failOccurrences.length) return fail(failOccurrences); else if
|
|
4313
|
-
// (cantTellOccurrences.length) return cantTell(cantTellOccurrences);`)
|
|
4314
|
-
// silently drops every cantTell-tier finding whenever at least one
|
|
4315
|
-
// fail-tier finding also exists on the same page — a real information
|
|
4316
|
-
// loss for a real scan, not just a test artifact: a page with one
|
|
4317
|
-
// confident violation and five "needs review" ones would report only
|
|
4318
|
-
// the one. Found via aria-prohibited-attr's roleless-naming widening
|
|
4319
|
-
// (2026-07-31), then confirmed as the same architectural gap in
|
|
4320
|
-
// aria-hidden-focus's runtime-redirect downgrade (same day) via an
|
|
4321
|
-
// explicit audit of every automatic rule for this exact two-bucket
|
|
4322
|
-
// shape.
|
|
4323
|
-
// The correct behavior when a fail-tier finding exists: the overall
|
|
4324
|
-
// outcome is still `fail` (a real, confident violation must still gate
|
|
4325
|
-
// CI), but BOTH buckets' occurrences are returned together, not just
|
|
4326
|
-
// the fail ones — each occurrence already carries its own
|
|
4327
|
-
// distinguishing `data.details.reasonCode`/summary/hint, so nothing
|
|
4328
|
-
// about which findings were confident vs. which need review is lost;
|
|
4329
|
-
// only the single aggregate outcome label stays singular, which was
|
|
4330
|
-
// already this engine's accepted one-outcome-per-rule-run schema
|
|
4331
|
-
// constraint (changing that is a separate, much larger, cross-cutting
|
|
4332
|
-
// decision spanning report.js/baseline.js/explain.js/WCAG rollups —
|
|
4333
|
-
// out of scope for this helper).
|
|
4334
|
-
function resolveTieredOutcome(failOccurrences, cantTellOccurrences, severity) {
|
|
4335
|
-
const fails = Array.isArray(failOccurrences) ? failOccurrences : [];
|
|
4336
|
-
const cantTells = Array.isArray(cantTellOccurrences) ? cantTellOccurrences : [];
|
|
4337
|
-
if (fails.length) {
|
|
4338
|
-
return { outcome: 'fail', severity, occurrences: fails.concat(cantTells) };
|
|
4339
|
-
}
|
|
4340
|
-
if (cantTells.length) {
|
|
4341
|
-
return { outcome: 'cantTell', severity, occurrences: cantTells };
|
|
4342
|
-
}
|
|
4343
|
-
return { outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
4344
|
-
}
|
|
4345
|
-
|
|
4346
|
-
let __contrastSharedCache = {};
|
|
4347
|
-
try {
|
|
4348
|
-
// In Node/JSDOM checks, the harness sets global.window/global.document.
|
|
4349
|
-
// The engine may instantiate helpers per rule without passing opts.window,
|
|
4350
|
-
// so we must be able to recover the stable realm window to share caches.
|
|
4351
|
-
const w =
|
|
4352
|
-
realmWindow ||
|
|
4353
|
-
(document && document.defaultView) ||
|
|
4354
|
-
(typeof global !== 'undefined' && global.window ? global.window : null);
|
|
4355
|
-
|
|
4356
|
-
if (w) {
|
|
4357
|
-
if (!w.__a11ycoreSharedCache) w.__a11ycoreSharedCache = {};
|
|
4358
|
-
if (!w.__a11ycoreSharedCache.contrast) w.__a11ycoreSharedCache.contrast = {};
|
|
4359
|
-
__contrastSharedCache = w.__a11ycoreSharedCache.contrast;
|
|
4360
|
-
}
|
|
4361
|
-
} catch {
|
|
4362
|
-
__contrastSharedCache = {};
|
|
4363
|
-
}
|
|
4364
|
-
|
|
4365
|
-
const __contrastShared = {
|
|
4366
|
-
trim,
|
|
4367
|
-
computedStyle,
|
|
4368
|
-
composedParent,
|
|
4369
|
-
buildSimpleSelector,
|
|
4370
|
-
__contrastSharedCache
|
|
4371
|
-
};
|
|
4372
|
-
|
|
4373
|
-
const contrast = createContrastHelpers(
|
|
4374
|
-
{ window: realmWindow || window, document, root: roots, includeShadowDom, excludeSelectors },
|
|
4375
|
-
__contrastShared
|
|
4376
|
-
);
|
|
4377
|
-
|
|
4378
|
-
// Expose shared cache to checks (deterministic, in-memory only)
|
|
4379
|
-
contrast.sharedCache = __contrastShared.__contrastSharedCache;
|
|
4380
|
-
|
|
4381
|
-
const aria = createAriaHelpers(
|
|
4382
|
-
{ window: realmWindow || window, document, root: roots },
|
|
4383
|
-
{ trim }
|
|
4384
|
-
);
|
|
4385
|
-
|
|
4386
|
-
// For rules whose check is inherently about the WHOLE page (does the
|
|
4387
|
-
// page have a title? a declared language? a way to skip repeated
|
|
4388
|
-
// blocks?) rather than about elements found within a scanned subtree --
|
|
4389
|
-
// these can't be answered correctly by scoping via queryAllSmart/ctx.root
|
|
4390
|
-
// the way per-element checks can, since a subtree that never had (and
|
|
4391
|
-
// was never meant to have) e.g. its own <title> shouldn't be faulted for
|
|
4392
|
-
// lacking one. `false` when `fragment:true` was explicitly set, or when
|
|
4393
|
-
// `contextSelector` scoped this run narrower than the whole document
|
|
4394
|
-
// (roots doesn't include document.documentElement); `true` in the
|
|
4395
|
-
// default/unscoped case, so this is a no-op for the overwhelming
|
|
4396
|
-
// majority of existing (whole-page) scans.
|
|
4397
|
-
function isWholeDocumentScope() {
|
|
4398
|
-
if (fragment) return false;
|
|
4399
|
-
return roots.includes(document.documentElement);
|
|
4400
|
-
}
|
|
4401
|
-
|
|
4402
|
-
return {
|
|
4403
|
-
// Existing query/snippet utilities
|
|
4404
|
-
queryAll,
|
|
4405
|
-
queryAllDeep,
|
|
4406
|
-
queryAllSmart,
|
|
4407
|
-
getOuterHtmlSnippet,
|
|
4408
|
-
buildSimpleSelector,
|
|
4409
|
-
buildSelector,
|
|
4410
|
-
buildStructuralPath,
|
|
4411
|
-
|
|
4412
|
-
// Existing (back-compat)
|
|
4413
|
-
hasAccessibleName,
|
|
4414
|
-
isExcluded,
|
|
4415
|
-
isAccTreeEligible,
|
|
4416
|
-
isDomVisibleEligible,
|
|
4417
|
-
isWholeDocumentScope,
|
|
4418
|
-
|
|
4419
|
-
// Engine-internal: sets which rule's rule-scoped excludeSelectors
|
|
4420
|
-
// (engineOptions.rules[ruleId].excludeSelectors) are currently in
|
|
4421
|
-
// effect. Called by dom-runner.js before each rule invocation, not
|
|
4422
|
-
// intended for use by rule implementations.
|
|
4423
|
-
__setActiveRuleExcludeSelectors,
|
|
4424
|
-
|
|
4425
|
-
// Eligibility info wrapper
|
|
4426
|
-
getEligibilityInfo,
|
|
4427
|
-
|
|
4428
|
-
// IDREF primitives
|
|
4429
|
-
resolveIdRefs,
|
|
4430
|
-
getTextFromIdRefs,
|
|
4431
|
-
getTextFromIdRefsIdrefEligible,
|
|
4432
|
-
|
|
4433
|
-
// ARIA-only name primitives (new)
|
|
4434
|
-
getAriaLabelInfo,
|
|
4435
|
-
getAriaLabelledByInfo,
|
|
4436
|
-
getAriaNameInfo,
|
|
4437
|
-
|
|
4438
|
-
// Landmark-role naming (aria-label -> aria-labelledby -> title; no content fallback --
|
|
4439
|
-
// see getLandmarkNameInfo's own header comment for why this replaced 7 duplicated copies)
|
|
4440
|
-
getLandmarkNameInfo,
|
|
4441
|
-
|
|
4442
|
-
// "Does this element have a landmark-scoping ancestor" (role-aware
|
|
4443
|
-
// sectioning-content/<main> check backing <header>/<footer>/<aside>'s
|
|
4444
|
-
// conditional implicit roles) -- re-exported from aria helpers at
|
|
4445
|
-
// this top level, matching getLandmarkNameInfo just above, so the
|
|
4446
|
-
// manual landmark-check files that used to each carry their own
|
|
4447
|
-
// (buggy, tag-only) copy can call helpers.hasLandmarkScopingAncestor
|
|
4448
|
-
// directly. See aria.hasLandmarkScopingAncestor's own header comment
|
|
4449
|
-
// in src/core/aria-helpers.js for the full algorithm and rationale.
|
|
4450
|
-
hasLandmarkScopingAncestor: aria.hasLandmarkScopingAncestor,
|
|
4451
|
-
|
|
4452
|
-
// Name / description
|
|
4453
|
-
getAccessibleNameInfo,
|
|
4454
|
-
getAccessibleDescriptionInfo,
|
|
4455
|
-
|
|
4456
|
-
// Text alternatives
|
|
4457
|
-
getTextAlternativeInfo,
|
|
4458
|
-
|
|
4459
|
-
// Recursive "name from content" (accname-aligned; see getContentNameInfo header comment)
|
|
4460
|
-
getContentNameInfo,
|
|
4461
|
-
|
|
4462
|
-
// Role / focusability
|
|
4463
|
-
getRoleInfo,
|
|
4464
|
-
getFocusableInfo,
|
|
4465
|
-
getVisibilityHintsInfo,
|
|
4466
|
-
|
|
4467
|
-
getAttributeInfo,
|
|
4468
|
-
|
|
4469
|
-
getLabelMethod,
|
|
4470
|
-
getLabelStrength,
|
|
4471
|
-
|
|
4472
|
-
// Flat-tree ancestor walk (assignedSlot-aware, then shadow host) —
|
|
4473
|
-
// see this function's own definition above for why assignedSlot
|
|
4474
|
-
// must win over parentNode.
|
|
4475
|
-
composedParent,
|
|
4476
|
-
|
|
4477
|
-
// Perf counters (only populated when opts.perfStats === true)
|
|
4478
|
-
getPerfStats,
|
|
4479
|
-
resetPerfStats,
|
|
4480
|
-
|
|
4481
|
-
reportOccurrence,
|
|
4482
|
-
resolveTieredOutcome,
|
|
4483
|
-
|
|
4484
|
-
contrast,
|
|
4485
|
-
aria
|
|
4486
|
-
};
|
|
4487
|
-
}
|
|
4488
|
-
|
|
4489
|
-
module.exports = {
|
|
4490
|
-
normalizeSelectorList,
|
|
4491
|
-
resolveContextRoots,
|
|
4492
|
-
createDomHelpers
|
|
4493
|
-
};
|