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