@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -35,8 +35,20 @@ const meta = {
35
35
  tags: ['wcag2a', 'wcag2aa', 'wcag412', 'structure', 'aria', 'focus', 'atomic', 'automatic'],
36
36
  wcagSc: ['4.1.2'],
37
37
  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' }
38
+ {
39
+ standard: 'WCAG',
40
+ version: '2.2',
41
+ requirement: '2.4.7',
42
+ title: 'Focus Visible',
43
+ conformanceLevel: 'AA'
44
+ },
45
+ {
46
+ standard: 'WCAG',
47
+ version: '2.2',
48
+ requirement: '4.1.2',
49
+ title: 'Name, Role, Value',
50
+ conformanceLevel: 'A'
51
+ }
40
52
  ],
41
53
  defaultSeverity: 'serious',
42
54
  category: 'robust',
@@ -53,19 +65,24 @@ function runInPage(ctx) {
53
65
  const { document, root, helpers, rule } = ctx;
54
66
  const safeRoot = root || document;
55
67
 
56
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
68
+ const queryAllSmart =
69
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
57
70
 
58
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
71
+ const getFocusableInfo =
72
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
59
73
 
60
74
  // 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;
75
+ const isDomVisibleEligible =
76
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
77
+ ? helpers.isDomVisibleEligible
78
+ : null;
62
79
 
63
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
80
+ const getEligibilityInfo =
81
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
64
82
 
65
83
  // 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;
84
+ const reportOccurrence =
85
+ helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
69
86
 
70
87
  const trim = (v) => (v == null ? '' : String(v)).trim();
71
88
  const lower = (v) => trim(v).toLowerCase();
@@ -81,7 +98,8 @@ function runInPage(ctx) {
81
98
  // fall through
82
99
  }
83
100
  try {
84
- if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
101
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function')
102
+ return Array.from(safeRoot.querySelectorAll(sel));
85
103
  } catch {
86
104
  // fall through
87
105
  }
@@ -92,9 +110,12 @@ function runInPage(ctx) {
92
110
  // host) — shared with every other rule via ctx.helpers.composedParent
93
111
  // (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
94
112
  // 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; };
113
+ const composedParent =
114
+ helpers && typeof helpers.composedParent === 'function'
115
+ ? helpers.composedParent
116
+ : function (n) {
117
+ return n && n.parentElement ? n.parentElement : null;
118
+ };
98
119
 
99
120
  function closestAriaHiddenTrue(node) {
100
121
  let cur = node;
@@ -113,15 +134,249 @@ function runInPage(ctx) {
113
134
  return null;
114
135
  }
115
136
 
137
+ function isWithinComposedSubtree(node, ancestor) {
138
+ let cur = node;
139
+ let guard = 0;
140
+ while (cur && guard++ < 400) {
141
+ if (cur === ancestor) return true;
142
+ cur = composedParent(cur);
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function getDeepActiveElement() {
148
+ let cur = document && document.activeElement ? document.activeElement : null;
149
+ let guard = 0;
150
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
151
+ cur = cur.shadowRoot.activeElement;
152
+ }
153
+ return cur;
154
+ }
155
+
156
+ function focusElementSafe(el) {
157
+ if (!el || typeof el.focus !== 'function') return false;
158
+ try {
159
+ el.focus({ preventScroll: true });
160
+ return true;
161
+ } catch {
162
+ try {
163
+ el.focus();
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ }
170
+
171
+ function buildNodeRef(node, rootEl) {
172
+ if (!node) return null;
173
+ const tag = (() => {
174
+ try {
175
+ return lower(node.tagName || '');
176
+ } catch {
177
+ return '';
178
+ }
179
+ })();
180
+ const idVal = (() => {
181
+ try {
182
+ return trim(node.getAttribute && node.getAttribute('id'));
183
+ } catch {
184
+ return '';
185
+ }
186
+ })();
187
+ return {
188
+ tag: tag || null,
189
+ id: idVal || null,
190
+ withinRoot: !!(rootEl && isWithinComposedSubtree(node, rootEl))
191
+ };
192
+ }
193
+
194
+ function runFocusObservationWindow(fn) {
195
+ const w =
196
+ document && document.defaultView
197
+ ? document.defaultView
198
+ : typeof window !== 'undefined'
199
+ ? window
200
+ : null;
201
+ if (!w || typeof fn !== 'function') return;
202
+
203
+ const originalSetTimeout = typeof w.setTimeout === 'function' ? w.setTimeout.bind(w) : null;
204
+ const originalRequestAnimationFrame =
205
+ typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
206
+ const originalQueueMicrotask =
207
+ typeof w.queueMicrotask === 'function' ? w.queueMicrotask.bind(w) : null;
208
+
209
+ const queuedMicrotasks = [];
210
+ const queuedRaf = [];
211
+ const queuedTimers = [];
212
+ let fakeTimerId = 1;
213
+
214
+ const patchedSetTimeout = function (cb, delay) {
215
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
216
+ if (typeof cb === 'function' && d <= 200) {
217
+ const args = [];
218
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
219
+ queuedTimers.push({ delay: d, cb: () => cb.apply(w, args) });
220
+ return fakeTimerId++;
221
+ }
222
+ if (originalSetTimeout) return originalSetTimeout.apply(w, arguments);
223
+ return fakeTimerId++;
224
+ };
225
+
226
+ const patchedRaf = function (cb) {
227
+ if (typeof cb === 'function') {
228
+ queuedRaf.push(cb);
229
+ return fakeTimerId++;
230
+ }
231
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(w, arguments);
232
+ return fakeTimerId++;
233
+ };
234
+
235
+ const patchedQueueMicrotask = function (cb) {
236
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
237
+ };
238
+
239
+ try {
240
+ if (originalSetTimeout) w.setTimeout = patchedSetTimeout;
241
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = patchedRaf;
242
+ if (originalQueueMicrotask) w.queueMicrotask = patchedQueueMicrotask;
243
+ fn();
244
+
245
+ // Deterministic mini-window: microtasks -> rAF -> short timers (<=200ms).
246
+ let guard = 0;
247
+ while (
248
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
249
+ guard++ < 100
250
+ ) {
251
+ while (queuedMicrotasks.length) {
252
+ const mt = queuedMicrotasks.shift();
253
+ try {
254
+ mt();
255
+ } catch {}
256
+ }
257
+ while (queuedRaf.length) {
258
+ const rf = queuedRaf.shift();
259
+ try {
260
+ rf(16);
261
+ } catch {}
262
+ }
263
+ if (queuedTimers.length) {
264
+ queuedTimers.sort((a, b) => a.delay - b.delay);
265
+ const tt = queuedTimers.shift();
266
+ try {
267
+ tt.cb();
268
+ } catch {}
269
+ }
270
+ }
271
+ } finally {
272
+ if (originalSetTimeout) w.setTimeout = originalSetTimeout;
273
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = originalRequestAnimationFrame;
274
+ if (originalQueueMicrotask) w.queueMicrotask = originalQueueMicrotask;
275
+ }
276
+ }
277
+
278
+ function probeImmediateFocusRedirect(entry) {
279
+ // Conservative downgrade gate:
280
+ // - only probe simple single-offender roots
281
+ // - only downgrade when focus moves immediately OUTSIDE the aria-hidden subtree
282
+ if (!entry || entry.count !== 1 || !entry.probeCandidates || !entry.probeCandidates.length)
283
+ return null;
284
+ const candidate = entry.probeCandidates[0];
285
+ if (!candidate) return null;
286
+
287
+ let focusedByEvent = false;
288
+ const onFocusCapture = () => {
289
+ focusedByEvent = true;
290
+ };
291
+ try {
292
+ candidate.addEventListener('focus', onFocusCapture, true);
293
+ } catch {
294
+ // ignore
295
+ }
296
+
297
+ const focusTrace = [];
298
+ const onFocusInCapture = (ev) => {
299
+ const n = ev && ev.target ? ev.target : null;
300
+ const ref = buildNodeRef(n, entry.rootEl);
301
+ if (ref) focusTrace.push(ref);
302
+ };
303
+ try {
304
+ document.addEventListener('focusin', onFocusInCapture, true);
305
+ } catch {
306
+ // ignore
307
+ }
308
+
309
+ const before = getDeepActiveElement();
310
+ const beforeRef = buildNodeRef(before, entry.rootEl);
311
+ if (beforeRef) focusTrace.push(beforeRef);
312
+
313
+ let focused = false;
314
+ runFocusObservationWindow(() => {
315
+ focused = focusElementSafe(candidate);
316
+ });
317
+
318
+ try {
319
+ document.removeEventListener('focusin', onFocusInCapture, true);
320
+ } catch {
321
+ // ignore
322
+ }
323
+
324
+ try {
325
+ candidate.removeEventListener('focus', onFocusCapture, true);
326
+ } catch {
327
+ // ignore
328
+ }
329
+ if (!focused || !focusedByEvent) return null;
330
+
331
+ const after = getDeepActiveElement();
332
+ const afterRef = buildNodeRef(after, entry.rootEl);
333
+ if (afterRef) focusTrace.push(afterRef);
334
+
335
+ // Best-effort restore to reduce side effects across checks.
336
+ if (before && before !== after) {
337
+ focusElementSafe(before);
338
+ }
339
+
340
+ if (!after || after === candidate) return null;
341
+ if (isWithinComposedSubtree(after, entry.rootEl)) return null;
342
+
343
+ const redirectedTag = (() => {
344
+ try {
345
+ return lower(after.tagName || '');
346
+ } catch {
347
+ return '';
348
+ }
349
+ })();
350
+ const redirectedId = (() => {
351
+ try {
352
+ return trim(after.getAttribute && after.getAttribute('id'));
353
+ } catch {
354
+ return '';
355
+ }
356
+ })();
357
+
358
+ return {
359
+ redirected: true,
360
+ redirectedToTag: redirectedTag || null,
361
+ redirectedToId: redirectedId || null,
362
+ focusTrace
363
+ };
364
+ }
365
+
116
366
  // Lightweight "invisible but still focusable" hints.
117
367
  // Only computed for a capped set of offenders per aria-hidden root.
118
368
  function getVisibilityHints(el) {
119
369
  const out = [];
120
370
  if (!el) return out;
121
371
 
122
- let cs = null;
372
+ let cs;
123
373
  try {
124
- const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
374
+ const w =
375
+ document && document.defaultView
376
+ ? document.defaultView
377
+ : typeof window !== 'undefined'
378
+ ? window
379
+ : null;
125
380
  cs = w && w.getComputedStyle ? w.getComputedStyle(el) : null;
126
381
  } catch {
127
382
  cs = null;
@@ -146,11 +401,15 @@ function runInPage(ctx) {
146
401
  // Common visually-hidden patterns
147
402
  if (clipLow && clipLow !== 'auto') {
148
403
  // 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');
404
+ if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1)
405
+ out.push('clipped');
150
406
  }
151
407
  if (clipPathLow && clipPathLow !== 'none') {
152
408
  // Examples: inset(100%) / inset(50%)
153
- if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
409
+ if (
410
+ clipPathLow.indexOf('inset(') !== -1 &&
411
+ (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)
412
+ ) {
154
413
  out.push('clipped');
155
414
  }
156
415
  }
@@ -205,7 +464,10 @@ function runInPage(ctx) {
205
464
  const uniq = [];
206
465
  for (const k of out) {
207
466
  const kk = String(k);
208
- if (!seen.has(kk)) { seen.add(kk); uniq.push(kk); }
467
+ if (!seen.has(kk)) {
468
+ seen.add(kk);
469
+ uniq.push(kk);
470
+ }
209
471
  }
210
472
  return uniq;
211
473
  }
@@ -232,7 +494,11 @@ function runInPage(ctx) {
232
494
  // programmatically focusable (script could call .focus()), but that's
233
495
  // not what "no focusable content behind aria-hidden" cares about.
234
496
  const explicitTabindex = trim(el.getAttribute('tabindex'));
235
- if (explicitTabindex !== '' && !Number.isNaN(Number(explicitTabindex)) && Number(explicitTabindex) < 0) {
497
+ if (
498
+ explicitTabindex !== '' &&
499
+ !Number.isNaN(Number(explicitTabindex)) &&
500
+ Number(explicitTabindex) < 0
501
+ ) {
236
502
  return false;
237
503
  }
238
504
 
@@ -240,7 +506,11 @@ function runInPage(ctx) {
240
506
  // Prefer helper for broad coverage, but do not let aria-hidden flip focusable->false.
241
507
  let helperInfo = null;
242
508
  if (getFocusableInfo) {
243
- try { helperInfo = getFocusableInfo(el, ctx); } catch { helperInfo = null; }
509
+ try {
510
+ helperInfo = getFocusableInfo(el, ctx);
511
+ } catch {
512
+ helperInfo = null;
513
+ }
244
514
  }
245
515
 
246
516
  // Local fallback that does NOT care about aria-hidden
@@ -257,7 +527,11 @@ function runInPage(ctx) {
257
527
  fallbackFocusable = type !== 'hidden';
258
528
  } else if (tag === 'iframe') {
259
529
  fallbackFocusable = true;
260
- } else if ((tag === 'audio' || tag === 'video') && el.hasAttribute && el.hasAttribute('controls')) {
530
+ } else if (
531
+ (tag === 'audio' || tag === 'video') &&
532
+ el.hasAttribute &&
533
+ el.hasAttribute('controls')
534
+ ) {
261
535
  fallbackFocusable = true;
262
536
  } else if (el.hasAttribute && el.hasAttribute('contenteditable')) {
263
537
  // contenteditable="false" explicitly disables the editing host and
@@ -282,7 +556,10 @@ function runInPage(ctx) {
282
556
  // IMPORTANT: Do NOT exclude opacity-based invisibility; opacity:0 remains in-scope.
283
557
  if (isDomVisibleEligible) {
284
558
  try {
285
- const vis = isDomVisibleEligible(el, ctx, { visibilityMode: 'styleOnly', disableGeometry: true });
559
+ const vis = isDomVisibleEligible(el, ctx, {
560
+ visibilityMode: 'styleOnly',
561
+ disableGeometry: true
562
+ });
286
563
  if (vis && vis.eligible === false) {
287
564
  const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
288
565
  const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
@@ -319,8 +596,16 @@ function runInPage(ctx) {
319
596
  }
320
597
  try {
321
598
  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')) {
599
+ if (
600
+ (tag === 'button' ||
601
+ tag === 'input' ||
602
+ tag === 'select' ||
603
+ tag === 'textarea' ||
604
+ tag === 'option' ||
605
+ tag === 'optgroup') &&
606
+ el.hasAttribute &&
607
+ el.hasAttribute('disabled')
608
+ ) {
324
609
  return true;
325
610
  }
326
611
  } catch {
@@ -341,8 +626,9 @@ function runInPage(ctx) {
341
626
  'a[href],area[href],button,input,select,textarea,summary,iframe,audio[controls],video[controls],[tabindex],[contenteditable]'
342
627
  );
343
628
 
344
- const bucket = new Map(); // ariaHiddenRoot -> { rootEl, count, offenders: [], hints:Set, rootIsFocusable }
629
+ const bucket = new Map(); // ariaHiddenRoot -> { rootEl, count, offenders: [], hints:Set, rootIsFocusable, probeCandidates: [] }
345
630
  const maxOffendersPerRoot = 5;
631
+ const maxProbeCandidatesPerRoot = 3;
346
632
 
347
633
  for (let i = 0; i < focusableCandidates.length; i++) {
348
634
  const el = focusableCandidates[i];
@@ -365,22 +651,46 @@ function runInPage(ctx) {
365
651
 
366
652
  let entry = bucket.get(rootEl);
367
653
  if (!entry) {
368
- entry = { rootEl, count: 0, offenders: [], hints: new Set(), rootIsFocusable: false };
654
+ entry = {
655
+ rootEl,
656
+ count: 0,
657
+ offenders: [],
658
+ hints: new Set(),
659
+ rootIsFocusable: false,
660
+ probeCandidates: []
661
+ };
369
662
  bucket.set(rootEl, entry);
370
663
  }
371
664
  entry.count += 1;
665
+ if (entry.probeCandidates.length < maxProbeCandidatesPerRoot) entry.probeCandidates.push(el);
372
666
 
373
667
  // Capture a small, deterministic offender summary + visibility hints.
374
668
  if (entry.offenders.length < maxOffendersPerRoot) {
375
- let tag = '';
376
- let ti = null;
377
- let href = null;
378
- let type = null;
669
+ let tag;
670
+ let ti;
671
+ let href;
672
+ let type;
379
673
 
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; }
674
+ try {
675
+ tag = lower(el.tagName || '');
676
+ } catch {
677
+ tag = '';
678
+ }
679
+ try {
680
+ ti = el.getAttribute('tabindex');
681
+ } catch {
682
+ ti = null;
683
+ }
684
+ try {
685
+ href = tag === 'a' || tag === 'area' ? trim(el.getAttribute('href')) : null;
686
+ } catch {
687
+ href = null;
688
+ }
689
+ try {
690
+ type = tag === 'input' ? lower(el.getAttribute('type') || '') : null;
691
+ } catch {
692
+ type = null;
693
+ }
384
694
 
385
695
  const hints = getVisibilityHints(el);
386
696
  for (const h of hints) entry.hints.add(h);
@@ -406,12 +716,19 @@ function runInPage(ctx) {
406
716
  }
407
717
 
408
718
  // 3) Report one occurrence per aria-hidden root that contains focusable content.
409
- const occurrences = [];
719
+ const failOccurrences = [];
720
+ const uncertainOccurrences = [];
410
721
  for (const [, entry] of bucket) {
411
722
  const el = entry.rootEl;
412
723
 
413
724
  const eligInfo = getEligibilityInfo
414
- ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
725
+ ? (() => {
726
+ try {
727
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
728
+ } catch {
729
+ return null;
730
+ }
731
+ })()
415
732
  : null;
416
733
 
417
734
  // stable visibility hint ordering
@@ -419,7 +736,13 @@ function runInPage(ctx) {
419
736
  const hintsArr = [];
420
737
  for (const k of hintOrder) if (entry.hints.has(k)) hintsArr.push(k);
421
738
 
422
- const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
739
+ const tagName = (() => {
740
+ try {
741
+ return lower(el.tagName || '');
742
+ } catch {
743
+ return '';
744
+ }
745
+ })();
423
746
 
424
747
  // Split counts deterministically
425
748
  const selfFocusable = !!entry.rootIsFocusable;
@@ -427,36 +750,55 @@ function runInPage(ctx) {
427
750
  const descendantFocusable = selfFocusable ? Math.max(0, totalFocusable - 1) : totalFocusable;
428
751
 
429
752
  const summaryKey = selfFocusable
430
- ? (descendantFocusable > 0
431
- ? 'ariaHidden_focus_summary_fail_self_and_desc'
432
- : 'ariaHidden_focus_summary_fail_self')
753
+ ? descendantFocusable > 0
754
+ ? 'ariaHidden_focus_summary_fail_self_and_desc'
755
+ : 'ariaHidden_focus_summary_fail_self'
433
756
  : 'ariaHidden_focus_summary_fail_desc';
434
757
 
435
758
  const reasonCode = selfFocusable
436
- ? (descendantFocusable > 0 ? 'ariaHiddenSelfAndDescendantsFocusable' : 'ariaHiddenSelfFocusable')
759
+ ? descendantFocusable > 0
760
+ ? 'ariaHiddenSelfAndDescendantsFocusable'
761
+ : 'ariaHiddenSelfFocusable'
437
762
  : 'ariaHiddenContainsFocusable';
438
763
 
439
764
  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)).`)
765
+ ? descendantFocusable > 0
766
+ ? `aria-hidden ${tagName} is focusable and contains ${descendantFocusable} focusable descendant(s) (${totalFocusable} focusable element(s) total).`
767
+ : `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`
443
768
  : `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
444
769
 
770
+ const runtimeProbe = probeImmediateFocusRedirect(entry);
771
+ const downgradedToCantTell = !!(runtimeProbe && runtimeProbe.redirected);
772
+
773
+ const cantTellSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
774
+
445
775
  const baseOccurrence = {
446
- summary: summaryText,
447
- hint: 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
776
+ summary: downgradedToCantTell ? cantTellSummary : summaryText,
777
+ hint: downgradedToCantTell
778
+ ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.'
779
+ : 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
448
780
  i18n: {
449
- summaryKey,
450
- hintKey: 'ariaHidden_focus_hint_fail',
781
+ summaryKey: downgradedToCantTell
782
+ ? 'ariaHidden_focus_summary_cantTell_redirect'
783
+ : summaryKey,
784
+ hintKey: downgradedToCantTell
785
+ ? 'ariaHidden_focus_hint_cantTell_redirect'
786
+ : 'ariaHidden_focus_hint_fail',
451
787
  params: {
452
788
  element: tagName,
453
789
  focusableCount: String(totalFocusable),
454
- descendantFocusableCount: String(descendantFocusable)
790
+ descendantFocusableCount: String(descendantFocusable),
791
+ redirectedToTag:
792
+ runtimeProbe && runtimeProbe.redirectedToTag ? runtimeProbe.redirectedToTag : '',
793
+ redirectedToId:
794
+ runtimeProbe && runtimeProbe.redirectedToId ? runtimeProbe.redirectedToId : ''
455
795
  }
456
796
  },
457
797
  data: {
458
798
  details: {
459
- reasonCode,
799
+ reasonCode: downgradedToCantTell
800
+ ? 'ariaHiddenFocusable_runtimeRedirect_needsReview'
801
+ : reasonCode,
460
802
  metrics: {
461
803
  focusableTotal: totalFocusable,
462
804
  focusableDescendants: descendantFocusable,
@@ -464,17 +806,30 @@ function runInPage(ctx) {
464
806
  offendersCaptured: entry.offenders.length,
465
807
  visibilityHints: hintsArr.slice(0)
466
808
  },
809
+ runtimeProbe: runtimeProbe || null,
467
810
  offenders: entry.offenders.slice(0)
468
811
  },
469
812
  visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
470
813
  }
471
814
  };
472
815
 
473
- if (reportOccurrence) occurrences.push(reportOccurrence(el, baseOccurrence));
474
- else occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
816
+ const occurrence = reportOccurrence
817
+ ? reportOccurrence(el, baseOccurrence)
818
+ : { __node: el, selector: '', html: '', ...baseOccurrence };
819
+
820
+ if (downgradedToCantTell) uncertainOccurrences.push(occurrence);
821
+ else failOccurrences.push(occurrence);
475
822
  }
476
823
 
477
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
824
+ // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
825
+ // a fail-tier finding never silently discards cantTell-tier findings from
826
+ // the same run — both are returned together when the outcome is 'fail'.
827
+ const resolved = helpers.resolveTieredOutcome(
828
+ failOccurrences,
829
+ uncertainOccurrences,
830
+ rule.defaultSeverity || 'minor'
831
+ );
832
+ return { ruleId: rule.ruleId, ...resolved };
478
833
  }
479
834
 
480
835
  module.exports = { id, meta, runInPage };