@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,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -12,7 +14,7 @@
12
14
  * No element with aria-hidden="true" may itself be focusable, and no focusable element
13
15
  * may exist within an aria-hidden="true" subtree.
14
16
  *
15
- * Notes on parity with a widely-used reference engine's aria-hidden-focus check:
17
+ * Notes:
16
18
  * - Focusability is computed via ctx.helpers.getFocusableInfo (native + tabindex + contenteditable).
17
19
  * - Elements that are not rendered (e.g., display:none, visibility:hidden, [hidden]) are excluded.
18
20
  * - Elements hidden via CSS in ways that still allow keyboard focus (e.g., opacity:0, off-screen, clip)
@@ -35,8 +37,20 @@ const meta = {
35
37
  tags: ['wcag2a', 'wcag2aa', 'wcag412', 'structure', 'aria', 'focus', 'atomic', 'automatic'],
36
38
  wcagSc: ['4.1.2'],
37
39
  normativeMappings: [
38
- { standard: 'WCAG', version: '2.2', requirement: '2.4.7', title: 'Focus Visible', conformanceLevel: 'AA' },
39
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
40
+ {
41
+ standard: 'WCAG',
42
+ version: '2.2',
43
+ requirement: '2.4.7',
44
+ title: 'Focus Visible',
45
+ conformanceLevel: 'AA'
46
+ },
47
+ {
48
+ standard: 'WCAG',
49
+ version: '2.2',
50
+ requirement: '4.1.2',
51
+ title: 'Name, Role, Value',
52
+ conformanceLevel: 'A'
53
+ }
40
54
  ],
41
55
  defaultSeverity: 'serious',
42
56
  category: 'robust',
@@ -53,19 +67,24 @@ function runInPage(ctx) {
53
67
  const { document, root, helpers, rule } = ctx;
54
68
  const safeRoot = root || document;
55
69
 
56
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
70
+ const queryAllSmart =
71
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
57
72
 
58
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
73
+ const getFocusableInfo =
74
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
59
75
 
60
76
  // Used only to exclude non-rendered elements; we explicitly DO NOT exclude opacity:0.
61
- const isDomVisibleEligible = helpers && typeof helpers.isDomVisibleEligible === 'function' ? helpers.isDomVisibleEligible : null;
77
+ const isDomVisibleEligible =
78
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
79
+ ? helpers.isDomVisibleEligible
80
+ : null;
62
81
 
63
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
82
+ const getEligibilityInfo =
83
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
64
84
 
65
85
  // Prefer helper occurrence wrapper so the engine can attach selector/snippet later.
66
- const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function'
67
- ? helpers.reportOccurrence
68
- : null;
86
+ const reportOccurrence =
87
+ helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
69
88
 
70
89
  const trim = (v) => (v == null ? '' : String(v)).trim();
71
90
  const lower = (v) => trim(v).toLowerCase();
@@ -81,7 +100,8 @@ function runInPage(ctx) {
81
100
  // fall through
82
101
  }
83
102
  try {
84
- if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
103
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function')
104
+ return Array.from(safeRoot.querySelectorAll(sel));
85
105
  } catch {
86
106
  // fall through
87
107
  }
@@ -92,9 +112,12 @@ function runInPage(ctx) {
92
112
  // host) — shared with every other rule via ctx.helpers.composedParent
93
113
  // (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
94
114
  // canonical definition can't drift out of sync with this rule's copy.
95
- const composedParent = helpers && typeof helpers.composedParent === 'function'
96
- ? helpers.composedParent
97
- : function (n) { return n && n.parentElement ? n.parentElement : null; };
115
+ const composedParent =
116
+ helpers && typeof helpers.composedParent === 'function'
117
+ ? helpers.composedParent
118
+ : function (n) {
119
+ return n && n.parentElement ? n.parentElement : null;
120
+ };
98
121
 
99
122
  function closestAriaHiddenTrue(node) {
100
123
  let cur = node;
@@ -113,15 +136,249 @@ function runInPage(ctx) {
113
136
  return null;
114
137
  }
115
138
 
139
+ function isWithinComposedSubtree(node, ancestor) {
140
+ let cur = node;
141
+ let guard = 0;
142
+ while (cur && guard++ < 400) {
143
+ if (cur === ancestor) return true;
144
+ cur = composedParent(cur);
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function getDeepActiveElement() {
150
+ let cur = document && document.activeElement ? document.activeElement : null;
151
+ let guard = 0;
152
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
153
+ cur = cur.shadowRoot.activeElement;
154
+ }
155
+ return cur;
156
+ }
157
+
158
+ function focusElementSafe(el) {
159
+ if (!el || typeof el.focus !== 'function') return false;
160
+ try {
161
+ el.focus({ preventScroll: true });
162
+ return true;
163
+ } catch {
164
+ try {
165
+ el.focus();
166
+ return true;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ }
172
+
173
+ function buildNodeRef(node, rootEl) {
174
+ if (!node) return null;
175
+ const tag = (() => {
176
+ try {
177
+ return lower(node.tagName || '');
178
+ } catch {
179
+ return '';
180
+ }
181
+ })();
182
+ const idVal = (() => {
183
+ try {
184
+ return trim(node.getAttribute && node.getAttribute('id'));
185
+ } catch {
186
+ return '';
187
+ }
188
+ })();
189
+ return {
190
+ tag: tag || null,
191
+ id: idVal || null,
192
+ withinRoot: !!(rootEl && isWithinComposedSubtree(node, rootEl))
193
+ };
194
+ }
195
+
196
+ function runFocusObservationWindow(fn) {
197
+ const w =
198
+ document && document.defaultView
199
+ ? document.defaultView
200
+ : typeof window !== 'undefined'
201
+ ? window
202
+ : null;
203
+ if (!w || typeof fn !== 'function') return;
204
+
205
+ const originalSetTimeout = typeof w.setTimeout === 'function' ? w.setTimeout.bind(w) : null;
206
+ const originalRequestAnimationFrame =
207
+ typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
208
+ const originalQueueMicrotask =
209
+ typeof w.queueMicrotask === 'function' ? w.queueMicrotask.bind(w) : null;
210
+
211
+ const queuedMicrotasks = [];
212
+ const queuedRaf = [];
213
+ const queuedTimers = [];
214
+ let fakeTimerId = 1;
215
+
216
+ const patchedSetTimeout = function (cb, delay) {
217
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
218
+ if (typeof cb === 'function' && d <= 200) {
219
+ const args = [];
220
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
221
+ queuedTimers.push({ delay: d, cb: () => cb.apply(w, args) });
222
+ return fakeTimerId++;
223
+ }
224
+ if (originalSetTimeout) return originalSetTimeout.apply(w, arguments);
225
+ return fakeTimerId++;
226
+ };
227
+
228
+ const patchedRaf = function (cb) {
229
+ if (typeof cb === 'function') {
230
+ queuedRaf.push(cb);
231
+ return fakeTimerId++;
232
+ }
233
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(w, arguments);
234
+ return fakeTimerId++;
235
+ };
236
+
237
+ const patchedQueueMicrotask = function (cb) {
238
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
239
+ };
240
+
241
+ try {
242
+ if (originalSetTimeout) w.setTimeout = patchedSetTimeout;
243
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = patchedRaf;
244
+ if (originalQueueMicrotask) w.queueMicrotask = patchedQueueMicrotask;
245
+ fn();
246
+
247
+ // Deterministic mini-window: microtasks -> rAF -> short timers (<=200ms).
248
+ let guard = 0;
249
+ while (
250
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
251
+ guard++ < 100
252
+ ) {
253
+ while (queuedMicrotasks.length) {
254
+ const mt = queuedMicrotasks.shift();
255
+ try {
256
+ mt();
257
+ } catch {}
258
+ }
259
+ while (queuedRaf.length) {
260
+ const rf = queuedRaf.shift();
261
+ try {
262
+ rf(16);
263
+ } catch {}
264
+ }
265
+ if (queuedTimers.length) {
266
+ queuedTimers.sort((a, b) => a.delay - b.delay);
267
+ const tt = queuedTimers.shift();
268
+ try {
269
+ tt.cb();
270
+ } catch {}
271
+ }
272
+ }
273
+ } finally {
274
+ if (originalSetTimeout) w.setTimeout = originalSetTimeout;
275
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = originalRequestAnimationFrame;
276
+ if (originalQueueMicrotask) w.queueMicrotask = originalQueueMicrotask;
277
+ }
278
+ }
279
+
280
+ function probeImmediateFocusRedirect(entry) {
281
+ // Conservative downgrade gate:
282
+ // - only probe simple single-offender roots
283
+ // - only downgrade when focus moves immediately OUTSIDE the aria-hidden subtree
284
+ if (!entry || entry.count !== 1 || !entry.probeCandidates || !entry.probeCandidates.length)
285
+ return null;
286
+ const candidate = entry.probeCandidates[0];
287
+ if (!candidate) return null;
288
+
289
+ let focusedByEvent = false;
290
+ const onFocusCapture = () => {
291
+ focusedByEvent = true;
292
+ };
293
+ try {
294
+ candidate.addEventListener('focus', onFocusCapture, true);
295
+ } catch {
296
+ // ignore
297
+ }
298
+
299
+ const focusTrace = [];
300
+ const onFocusInCapture = (ev) => {
301
+ const n = ev && ev.target ? ev.target : null;
302
+ const ref = buildNodeRef(n, entry.rootEl);
303
+ if (ref) focusTrace.push(ref);
304
+ };
305
+ try {
306
+ document.addEventListener('focusin', onFocusInCapture, true);
307
+ } catch {
308
+ // ignore
309
+ }
310
+
311
+ const before = getDeepActiveElement();
312
+ const beforeRef = buildNodeRef(before, entry.rootEl);
313
+ if (beforeRef) focusTrace.push(beforeRef);
314
+
315
+ let focused = false;
316
+ runFocusObservationWindow(() => {
317
+ focused = focusElementSafe(candidate);
318
+ });
319
+
320
+ try {
321
+ document.removeEventListener('focusin', onFocusInCapture, true);
322
+ } catch {
323
+ // ignore
324
+ }
325
+
326
+ try {
327
+ candidate.removeEventListener('focus', onFocusCapture, true);
328
+ } catch {
329
+ // ignore
330
+ }
331
+ if (!focused || !focusedByEvent) return null;
332
+
333
+ const after = getDeepActiveElement();
334
+ const afterRef = buildNodeRef(after, entry.rootEl);
335
+ if (afterRef) focusTrace.push(afterRef);
336
+
337
+ // Best-effort restore to reduce side effects across checks.
338
+ if (before && before !== after) {
339
+ focusElementSafe(before);
340
+ }
341
+
342
+ if (!after || after === candidate) return null;
343
+ if (isWithinComposedSubtree(after, entry.rootEl)) return null;
344
+
345
+ const redirectedTag = (() => {
346
+ try {
347
+ return lower(after.tagName || '');
348
+ } catch {
349
+ return '';
350
+ }
351
+ })();
352
+ const redirectedId = (() => {
353
+ try {
354
+ return trim(after.getAttribute && after.getAttribute('id'));
355
+ } catch {
356
+ return '';
357
+ }
358
+ })();
359
+
360
+ return {
361
+ redirected: true,
362
+ redirectedToTag: redirectedTag || null,
363
+ redirectedToId: redirectedId || null,
364
+ focusTrace
365
+ };
366
+ }
367
+
116
368
  // Lightweight "invisible but still focusable" hints.
117
369
  // Only computed for a capped set of offenders per aria-hidden root.
118
370
  function getVisibilityHints(el) {
119
371
  const out = [];
120
372
  if (!el) return out;
121
373
 
122
- let cs = null;
374
+ let cs;
123
375
  try {
124
- const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
376
+ const w =
377
+ document && document.defaultView
378
+ ? document.defaultView
379
+ : typeof window !== 'undefined'
380
+ ? window
381
+ : null;
125
382
  cs = w && w.getComputedStyle ? w.getComputedStyle(el) : null;
126
383
  } catch {
127
384
  cs = null;
@@ -146,11 +403,15 @@ function runInPage(ctx) {
146
403
  // Common visually-hidden patterns
147
404
  if (clipLow && clipLow !== 'auto') {
148
405
  // Examples: rect(0px, 0px, 0px, 0px) / rect(0,0,0,0)
149
- if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1) out.push('clipped');
406
+ if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1)
407
+ out.push('clipped');
150
408
  }
151
409
  if (clipPathLow && clipPathLow !== 'none') {
152
410
  // Examples: inset(100%) / inset(50%)
153
- if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
411
+ if (
412
+ clipPathLow.indexOf('inset(') !== -1 &&
413
+ (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)
414
+ ) {
154
415
  out.push('clipped');
155
416
  }
156
417
  }
@@ -205,7 +466,10 @@ function runInPage(ctx) {
205
466
  const uniq = [];
206
467
  for (const k of out) {
207
468
  const kk = String(k);
208
- if (!seen.has(kk)) { seen.add(kk); uniq.push(kk); }
469
+ if (!seen.has(kk)) {
470
+ seen.add(kk);
471
+ uniq.push(kk);
472
+ }
209
473
  }
210
474
  return uniq;
211
475
  }
@@ -223,16 +487,17 @@ function runInPage(ctx) {
223
487
  // An explicit negative tabindex removes the element from the keyboard
224
488
  // tab sequence entirely, regardless of tag — the standard, WAI-
225
489
  // recommended technique for safely hiding focusable content behind
226
- // aria-hidden (verified against a widely-used reference engine's own aria-hidden-focus check,
227
- // which requires tabbability — not raw focusability — via its
228
- // `focusable-not-tabbable` sub-check; confirmed via a real page:
229
- // Wikipedia's sticky header uses <button tabindex="-1">/<a tabindex="-1">
230
- // inside aria-hidden divs, a correct pattern this rule was previously
231
- // flagging as a false positive). Such an element is still
232
- // programmatically focusable (script could call .focus()), but that's
233
- // not what "no focusable content behind aria-hidden" cares about.
490
+ // aria-hidden (e.g. <button tabindex="-1"> / <a tabindex="-1"> inside
491
+ // an aria-hidden container). This cares about tabbability, not raw
492
+ // focusability. Such an element is still programmatically focusable
493
+ // (script could call .focus()), but that's not what "no focusable
494
+ // content behind aria-hidden" cares about.
234
495
  const explicitTabindex = trim(el.getAttribute('tabindex'));
235
- if (explicitTabindex !== '' && !Number.isNaN(Number(explicitTabindex)) && Number(explicitTabindex) < 0) {
496
+ if (
497
+ explicitTabindex !== '' &&
498
+ !Number.isNaN(Number(explicitTabindex)) &&
499
+ Number(explicitTabindex) < 0
500
+ ) {
236
501
  return false;
237
502
  }
238
503
 
@@ -240,7 +505,11 @@ function runInPage(ctx) {
240
505
  // Prefer helper for broad coverage, but do not let aria-hidden flip focusable->false.
241
506
  let helperInfo = null;
242
507
  if (getFocusableInfo) {
243
- try { helperInfo = getFocusableInfo(el, ctx); } catch { helperInfo = null; }
508
+ try {
509
+ helperInfo = getFocusableInfo(el, ctx);
510
+ } catch {
511
+ helperInfo = null;
512
+ }
244
513
  }
245
514
 
246
515
  // Local fallback that does NOT care about aria-hidden
@@ -257,7 +526,11 @@ function runInPage(ctx) {
257
526
  fallbackFocusable = type !== 'hidden';
258
527
  } else if (tag === 'iframe') {
259
528
  fallbackFocusable = true;
260
- } else if ((tag === 'audio' || tag === 'video') && el.hasAttribute && el.hasAttribute('controls')) {
529
+ } else if (
530
+ (tag === 'audio' || tag === 'video') &&
531
+ el.hasAttribute &&
532
+ el.hasAttribute('controls')
533
+ ) {
261
534
  fallbackFocusable = true;
262
535
  } else if (el.hasAttribute && el.hasAttribute('contenteditable')) {
263
536
  // contenteditable="false" explicitly disables the editing host and
@@ -282,7 +555,10 @@ function runInPage(ctx) {
282
555
  // IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
283
556
  if (isDomVisibleEligible) {
284
557
  try {
285
- const vis = isDomVisibleEligible(el, ctx, { visibilityMode: 'styleOnly', disableGeometry: true });
558
+ const vis = isDomVisibleEligible(el, ctx, {
559
+ visibilityMode: 'styleOnly',
560
+ disableGeometry: true
561
+ });
286
562
  if (vis && vis.eligible === false) {
287
563
  const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
288
564
  const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
@@ -319,8 +595,16 @@ function runInPage(ctx) {
319
595
  }
320
596
  try {
321
597
  const tag = lower(el.tagName || '');
322
- if ((tag === 'button' || tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'option' || tag === 'optgroup') &&
323
- el.hasAttribute && el.hasAttribute('disabled')) {
598
+ if (
599
+ (tag === 'button' ||
600
+ tag === 'input' ||
601
+ tag === 'select' ||
602
+ tag === 'textarea' ||
603
+ tag === 'option' ||
604
+ tag === 'optgroup') &&
605
+ el.hasAttribute &&
606
+ el.hasAttribute('disabled')
607
+ ) {
324
608
  return true;
325
609
  }
326
610
  } catch {
@@ -341,8 +625,9 @@ function runInPage(ctx) {
341
625
  'a[href],area[href],button,input,select,textarea,summary,iframe,audio[controls],video[controls],[tabindex],[contenteditable]'
342
626
  );
343
627
 
344
- const bucket = new Map(); // ariaHiddenRoot -> { rootEl, count, offenders: [], hints:Set, rootIsFocusable }
628
+ const bucket = new Map(); // ariaHiddenRoot -> { rootEl, count, offenders: [], hints:Set, rootIsFocusable, probeCandidates: [] }
345
629
  const maxOffendersPerRoot = 5;
630
+ const maxProbeCandidatesPerRoot = 3;
346
631
 
347
632
  for (let i = 0; i < focusableCandidates.length; i++) {
348
633
  const el = focusableCandidates[i];
@@ -354,10 +639,9 @@ function runInPage(ctx) {
354
639
  // cheaper first cannot change which elements end up in the bucket —
355
640
  // it only skips the expensive check for the (typically vast) majority
356
641
  // of focusable candidates that were never inside an aria-hidden root
357
- // in the first place. On a real page with a large focusable-candidate
358
- // count and a complex stylesheet (Daily Mail: ~1400 links, ~3600 CSS
359
- // rules), this cut this check's runtime from ~30s to well under 1s —
360
- // a pure ordering change, not a behavior change.
642
+ // in the first place. On pages with many focusable candidates and a
643
+ // large stylesheet this is a big speedup, and a pure ordering change,
644
+ // not a behavior change.
361
645
  const rootEl = closestAriaHiddenTrue(el);
362
646
  if (!rootEl) continue;
363
647
 
@@ -365,22 +649,46 @@ function runInPage(ctx) {
365
649
 
366
650
  let entry = bucket.get(rootEl);
367
651
  if (!entry) {
368
- entry = { rootEl, count: 0, offenders: [], hints: new Set(), rootIsFocusable: false };
652
+ entry = {
653
+ rootEl,
654
+ count: 0,
655
+ offenders: [],
656
+ hints: new Set(),
657
+ rootIsFocusable: false,
658
+ probeCandidates: []
659
+ };
369
660
  bucket.set(rootEl, entry);
370
661
  }
371
662
  entry.count += 1;
663
+ if (entry.probeCandidates.length < maxProbeCandidatesPerRoot) entry.probeCandidates.push(el);
372
664
 
373
665
  // Capture a small, deterministic offender summary + visibility hints.
374
666
  if (entry.offenders.length < maxOffendersPerRoot) {
375
- let tag = '';
376
- let ti = null;
377
- let href = null;
378
- let type = null;
667
+ let tag;
668
+ let ti;
669
+ let href;
670
+ let type;
379
671
 
380
- try { tag = lower(el.tagName || ''); } catch { tag = ''; }
381
- try { ti = el.getAttribute('tabindex'); } catch { ti = null; }
382
- try { href = (tag === 'a' || tag === 'area') ? trim(el.getAttribute('href')) : null; } catch { href = null; }
383
- try { type = tag === 'input' ? lower(el.getAttribute('type') || '') : null; } catch { type = null; }
672
+ try {
673
+ tag = lower(el.tagName || '');
674
+ } catch {
675
+ tag = '';
676
+ }
677
+ try {
678
+ ti = el.getAttribute('tabindex');
679
+ } catch {
680
+ ti = null;
681
+ }
682
+ try {
683
+ href = tag === 'a' || tag === 'area' ? trim(el.getAttribute('href')) : null;
684
+ } catch {
685
+ href = null;
686
+ }
687
+ try {
688
+ type = tag === 'input' ? lower(el.getAttribute('type') || '') : null;
689
+ } catch {
690
+ type = null;
691
+ }
384
692
 
385
693
  const hints = getVisibilityHints(el);
386
694
  for (const h of hints) entry.hints.add(h);
@@ -406,12 +714,19 @@ function runInPage(ctx) {
406
714
  }
407
715
 
408
716
  // 3) Report one occurrence per aria-hidden root that contains focusable content.
409
- const occurrences = [];
717
+ const failOccurrences = [];
718
+ const uncertainOccurrences = [];
410
719
  for (const [, entry] of bucket) {
411
720
  const el = entry.rootEl;
412
721
 
413
722
  const eligInfo = getEligibilityInfo
414
- ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
723
+ ? (() => {
724
+ try {
725
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
726
+ } catch {
727
+ return null;
728
+ }
729
+ })()
415
730
  : null;
416
731
 
417
732
  // stable visibility hint ordering
@@ -419,7 +734,13 @@ function runInPage(ctx) {
419
734
  const hintsArr = [];
420
735
  for (const k of hintOrder) if (entry.hints.has(k)) hintsArr.push(k);
421
736
 
422
- const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
737
+ const tagName = (() => {
738
+ try {
739
+ return lower(el.tagName || '');
740
+ } catch {
741
+ return '';
742
+ }
743
+ })();
423
744
 
424
745
  // Split counts deterministically
425
746
  const selfFocusable = !!entry.rootIsFocusable;
@@ -427,36 +748,56 @@ function runInPage(ctx) {
427
748
  const descendantFocusable = selfFocusable ? Math.max(0, totalFocusable - 1) : totalFocusable;
428
749
 
429
750
  const summaryKey = selfFocusable
430
- ? (descendantFocusable > 0
431
- ? 'ariaHidden_focus_summary_fail_self_and_desc'
432
- : 'ariaHidden_focus_summary_fail_self')
751
+ ? descendantFocusable > 0
752
+ ? 'ariaHidden_focus_summary_fail_self_and_desc'
753
+ : 'ariaHidden_focus_summary_fail_self'
433
754
  : 'ariaHidden_focus_summary_fail_desc';
434
755
 
435
756
  const reasonCode = selfFocusable
436
- ? (descendantFocusable > 0 ? 'ariaHiddenSelfAndDescendantsFocusable' : 'ariaHiddenSelfFocusable')
757
+ ? descendantFocusable > 0
758
+ ? 'ariaHiddenSelfAndDescendantsFocusable'
759
+ : 'ariaHiddenSelfFocusable'
437
760
  : 'ariaHiddenContainsFocusable';
438
761
 
439
762
  const summaryText = selfFocusable
440
- ? (descendantFocusable > 0
441
- ? `aria-hidden ${tagName} is focusable and contains ${descendantFocusable} focusable descendant(s) (${totalFocusable} focusable element(s) total).`
442
- : `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`)
763
+ ? descendantFocusable > 0
764
+ ? `aria-hidden ${tagName} is focusable and contains ${descendantFocusable} focusable descendant(s) (${totalFocusable} focusable element(s) total).`
765
+ : `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`
443
766
  : `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
444
767
 
768
+ const runtimeProbe = probeImmediateFocusRedirect(entry);
769
+ const downgradedToCantTell = !!(runtimeProbe && runtimeProbe.redirected);
770
+
771
+ const cantTellSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
772
+
445
773
  const baseOccurrence = {
446
- summary: summaryText,
447
- hint: 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
774
+ summary: downgradedToCantTell ? cantTellSummary : summaryText,
775
+ hint: downgradedToCantTell
776
+ ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.'
777
+ : 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
778
+ occurrenceOutcome: downgradedToCantTell ? 'cantTell' : 'fail',
448
779
  i18n: {
449
- summaryKey,
450
- hintKey: 'ariaHidden_focus_hint_fail',
780
+ summaryKey: downgradedToCantTell
781
+ ? 'ariaHidden_focus_summary_cantTell_redirect'
782
+ : summaryKey,
783
+ hintKey: downgradedToCantTell
784
+ ? 'ariaHidden_focus_hint_cantTell_redirect'
785
+ : 'ariaHidden_focus_hint_fail',
451
786
  params: {
452
787
  element: tagName,
453
788
  focusableCount: String(totalFocusable),
454
- descendantFocusableCount: String(descendantFocusable)
789
+ descendantFocusableCount: String(descendantFocusable),
790
+ redirectedToTag:
791
+ runtimeProbe && runtimeProbe.redirectedToTag ? runtimeProbe.redirectedToTag : '',
792
+ redirectedToId:
793
+ runtimeProbe && runtimeProbe.redirectedToId ? runtimeProbe.redirectedToId : ''
455
794
  }
456
795
  },
457
796
  data: {
458
797
  details: {
459
- reasonCode,
798
+ reasonCode: downgradedToCantTell
799
+ ? 'ariaHiddenFocusable_runtimeRedirect_needsReview'
800
+ : reasonCode,
460
801
  metrics: {
461
802
  focusableTotal: totalFocusable,
462
803
  focusableDescendants: descendantFocusable,
@@ -464,17 +805,30 @@ function runInPage(ctx) {
464
805
  offendersCaptured: entry.offenders.length,
465
806
  visibilityHints: hintsArr.slice(0)
466
807
  },
808
+ runtimeProbe: runtimeProbe || null,
467
809
  offenders: entry.offenders.slice(0)
468
810
  },
469
811
  visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
470
812
  }
471
813
  };
472
814
 
473
- if (reportOccurrence) occurrences.push(reportOccurrence(el, baseOccurrence));
474
- else occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
815
+ const occurrence = reportOccurrence
816
+ ? reportOccurrence(el, baseOccurrence)
817
+ : { __node: el, selector: '', html: '', ...baseOccurrence };
818
+
819
+ if (downgradedToCantTell) uncertainOccurrences.push(occurrence);
820
+ else failOccurrences.push(occurrence);
475
821
  }
476
822
 
477
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
823
+ // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
824
+ // a fail-tier finding never silently discards cantTell-tier findings from
825
+ // the same run — both are returned together when the outcome is 'fail'.
826
+ const resolved = helpers.resolveTieredOutcome(
827
+ failOccurrences,
828
+ uncertainOccurrences,
829
+ rule.defaultSeverity || 'minor'
830
+ );
831
+ return { ruleId: rule.ruleId, ...resolved };
478
832
  }
479
833
 
480
834
  module.exports = { id, meta, runInPage };