@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.
Files changed (163) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +109 -35
  3. package/bin/surea11y-core.js +20 -0
  4. package/docs/API_STABILITY.md +26 -0
  5. package/docs/CI_INTEGRATIONS.md +7 -7
  6. package/docs/ENGINE_OPTIONS.md +1 -1
  7. package/docs/I18N.md +12 -9
  8. package/docs/INTEGRATION.md +1 -1
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/REPORT.md +1 -1
  11. package/package.json +50 -16
  12. package/src/baseline.js +0 -0
  13. package/src/checks/automatic/area-alt-present.js +4 -6
  14. package/src/checks/automatic/aria-allowed-attr.js +15 -51
  15. package/src/checks/automatic/aria-allowed-role.js +2 -0
  16. package/src/checks/automatic/aria-braille-equivalent.js +2 -0
  17. package/src/checks/automatic/aria-conditional-attr.js +8 -7
  18. package/src/checks/automatic/aria-deprecated-role.js +4 -3
  19. package/src/checks/automatic/aria-hidden-body.js +6 -4
  20. package/src/checks/automatic/aria-hidden-focus.js +12 -13
  21. package/src/checks/automatic/aria-prohibited-attr.js +98 -105
  22. package/src/checks/automatic/aria-prohibited-children.js +56 -87
  23. package/src/checks/automatic/aria-required-attr.js +6 -7
  24. package/src/checks/automatic/aria-required-children.js +7 -10
  25. package/src/checks/automatic/aria-required-parent.js +20 -25
  26. package/src/checks/automatic/aria-role-name-present.js +2 -0
  27. package/src/checks/automatic/aria-roles-valid.js +2 -0
  28. package/src/checks/automatic/aria-valid-attr-value.js +18 -15
  29. package/src/checks/automatic/aria-valid-attr.js +2 -0
  30. package/src/checks/automatic/autocomplete-valid.js +2 -0
  31. package/src/checks/automatic/avoid-inline-spacing.js +3 -2
  32. package/src/checks/automatic/binary-control-name-present.js +2 -0
  33. package/src/checks/automatic/button-name-present.js +7 -7
  34. package/src/checks/automatic/bypass-blocks-present.js +9 -7
  35. package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
  36. package/src/checks/automatic/combobox-name-present.js +2 -0
  37. package/src/checks/automatic/contrast-computable.js +2 -0
  38. package/src/checks/automatic/contrast-enhanced.js +2 -0
  39. package/src/checks/automatic/contrast-minimum.js +2 -0
  40. package/src/checks/automatic/css-orientation-lock.js +21 -27
  41. package/src/checks/automatic/definition-list-children-valid.js +6 -6
  42. package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
  43. package/src/checks/automatic/dialog-name-present.js +10 -10
  44. package/src/checks/automatic/dlitem-parent-valid.js +2 -0
  45. package/src/checks/automatic/duplicate-id-aria.js +4 -3
  46. package/src/checks/automatic/embed-text-alternative-present.js +2 -0
  47. package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
  48. package/src/checks/automatic/form-control-single-label.js +6 -7
  49. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
  50. package/src/checks/automatic/iframe-focusable-content.js +246 -18
  51. package/src/checks/automatic/iframe-name-present.js +2 -0
  52. package/src/checks/automatic/iframe-title-unique.js +3 -1
  53. package/src/checks/automatic/img-alt-present.js +7 -9
  54. package/src/checks/automatic/input-image-alt-present.js +4 -6
  55. package/src/checks/automatic/label-in-name.js +15 -19
  56. package/src/checks/automatic/language-page-present.js +2 -0
  57. package/src/checks/automatic/link-in-text-block.js +2 -0
  58. package/src/checks/automatic/link-name-present.js +2 -0
  59. package/src/checks/automatic/list-children-valid.js +14 -24
  60. package/src/checks/automatic/listbox-name-present.js +2 -0
  61. package/src/checks/automatic/listitem-parent-valid.js +30 -7
  62. package/src/checks/automatic/menuitem-name-present.js +2 -0
  63. package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
  64. package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
  65. package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
  66. package/src/checks/automatic/meter-name-present.js +4 -3
  67. package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
  68. package/src/checks/automatic/object-text-alternative-present.js +2 -0
  69. package/src/checks/automatic/option-name-present.js +2 -0
  70. package/src/checks/automatic/page-title-present.js +2 -0
  71. package/src/checks/automatic/progressbar-name-present.js +8 -10
  72. package/src/checks/automatic/role-img-alt-present.js +4 -4
  73. package/src/checks/automatic/searchbox-name-present.js +2 -0
  74. package/src/checks/automatic/server-side-image-map-absent.js +4 -3
  75. package/src/checks/automatic/slider-name-present.js +2 -0
  76. package/src/checks/automatic/spinbutton-name-present.js +2 -0
  77. package/src/checks/automatic/summary-name-present.js +2 -0
  78. package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
  79. package/src/checks/automatic/svg-text-alternative-present.js +17 -5
  80. package/src/checks/automatic/tab-name-present.js +2 -0
  81. package/src/checks/automatic/table-headers-attr-valid.js +3 -2
  82. package/src/checks/automatic/table-th-has-data-cells.js +2 -0
  83. package/src/checks/automatic/target-size-minimum.js +5 -0
  84. package/src/checks/automatic/td-has-header.js +24 -1
  85. package/src/checks/automatic/textbox-name-present.js +2 -0
  86. package/src/checks/automatic/tooltip-name-present.js +2 -0
  87. package/src/checks/automatic/treeitem-name-present.js +2 -0
  88. package/src/checks/automatic/valid-lang.js +2 -0
  89. package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
  90. package/src/checks/manual/accesskeys-manual.js +3 -1
  91. package/src/checks/manual/area-alt-decorative-manual.js +2 -0
  92. package/src/checks/manual/area-alt-quality-manual.js +2 -0
  93. package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
  94. package/src/checks/manual/aria-text-manual.js +6 -5
  95. package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
  96. package/src/checks/manual/css-hidden-focus.js +184 -9
  97. package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
  98. package/src/checks/manual/empty-heading-manual.js +17 -17
  99. package/src/checks/manual/empty-table-header-manual.js +52 -25
  100. package/src/checks/manual/focus-order-semantics-manual.js +16 -4
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
  102. package/src/checks/manual/heading-order-manual.js +28 -1
  103. package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
  104. package/src/checks/manual/image-redundant-alt-manual.js +21 -1
  105. package/src/checks/manual/img-alt-decorative-manual.js +2 -0
  106. package/src/checks/manual/img-alt-quality-manual.js +2 -0
  107. package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
  108. package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
  109. package/src/checks/manual/label-title-only-manual.js +29 -22
  110. package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
  111. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
  112. package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
  113. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
  114. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
  115. package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
  116. package/src/checks/manual/landmark-one-main-manual.js +12 -23
  117. package/src/checks/manual/landmark-unique-manual.js +37 -52
  118. package/src/checks/manual/link-name-quality-manual.js +2 -0
  119. package/src/checks/manual/media-transcript-present-manual.js +2 -0
  120. package/src/checks/manual/meta-viewport-large-manual.js +3 -1
  121. package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
  122. package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
  123. package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
  124. package/src/checks/manual/p-as-heading-manual.js +2 -0
  125. package/src/checks/manual/page-has-heading-one-manual.js +12 -11
  126. package/src/checks/manual/page-title-patterns-manual.js +2 -0
  127. package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
  128. package/src/checks/manual/region-manual.js +27 -36
  129. package/src/checks/manual/scope-attr-valid-manual.js +3 -1
  130. package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
  131. package/src/checks/manual/skip-link-manual.js +7 -6
  132. package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
  133. package/src/checks/manual/tabindex-manual.js +3 -1
  134. package/src/checks/manual/table-duplicate-name-manual.js +5 -4
  135. package/src/checks/manual/table-fake-caption-manual.js +24 -3
  136. package/src/checks/manual/video-caption-manual.js +2 -0
  137. package/src/checks/manual-review.js +2 -0
  138. package/src/core.js +6772 -2158
  139. package/src/index.js +2 -0
  140. package/src/report.js +51 -9
  141. package/src/sarif.js +18 -3
  142. package/surea11y.browser.js +2731 -999
  143. package/bin/core.js +0 -473
  144. package/docs/CLI.md +0 -128
  145. package/src/catalogs/composites.wcag.js +0 -454
  146. package/src/checks/rules-and-tags.full.csv +0 -19
  147. package/src/checks/rules-and-tags.full.json +0 -259
  148. package/src/core/aria-helpers.js +0 -1211
  149. package/src/core/contrast-helpers.js +0 -1302
  150. package/src/core/dom-helpers.js +0 -4493
  151. package/src/core/dom-runner.js +0 -787
  152. package/src/core/frame-messaging.js +0 -261
  153. package/src/core/frame-scan.js +0 -190
  154. package/src/core/rollup-composites.js +0 -127
  155. package/src/core/rule-meta.js +0 -176
  156. package/src/coverage/wcag-facets.js +0 -1079
  157. package/src/coverage/wcag-version-map.js +0 -84
  158. package/src/i18n/en.js +0 -1228
  159. package/src/i18n/fr.js +0 -1185
  160. package/src/policy/contracts.js +0 -18
  161. package/src/policy/resolvePolicy.js +0 -59
  162. package/src/policy/schemas/engine-options.schema.json +0 -103
  163. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -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
- };