@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
  /**
@@ -21,188 +23,370 @@
21
23
  const id = 'css-hidden-focus';
22
24
 
23
25
  const meta = {
24
- title: 'Focusable elements must not be visually hidden',
25
- description:
26
- 'Checks that keyboard-focusable elements are not visually hidden by CSS techniques that can leave them in the tab order.',
27
- i18n: {
28
- titleKey: 'cssHidden_focus_title',
29
- descriptionKey: 'cssHidden_focus_description'
30
- },
31
- helpUrl: null,
32
- tags: ['wcag2aa', 'wcag247', 'navigation', 'focus', 'css', 'atomic', 'manual'],
33
- wcagSc: ['2.4.7'],
34
- normativeMappings: [
35
- {standard: 'WCAG', version: '2.2', requirement: '2.4.7', title: 'Focus Visible', conformanceLevel: 'AA'}
36
- ],
37
- defaultSeverity: 'serious',
38
- category: 'operable',
39
- type: 'manual',
40
- defaultConfidence: 'low',
41
- coverage: {
42
- facetsBySc: {
43
- '2.4.7': ['css-hidden-focusable']
44
- }
26
+ title: 'Focusable elements must not be visually hidden',
27
+ description:
28
+ 'Checks that keyboard-focusable elements are not visually hidden by CSS techniques that can leave them in the tab order.',
29
+ i18n: {
30
+ titleKey: 'cssHidden_focus_title',
31
+ descriptionKey: 'cssHidden_focus_description'
32
+ },
33
+ helpUrl: null,
34
+ tags: ['wcag2aa', 'wcag247', 'navigation', 'focus', 'css', 'atomic', 'manual'],
35
+ wcagSc: ['2.4.7'],
36
+ normativeMappings: [
37
+ {
38
+ standard: 'WCAG',
39
+ version: '2.2',
40
+ requirement: '2.4.7',
41
+ title: 'Focus Visible',
42
+ conformanceLevel: 'AA'
45
43
  }
44
+ ],
45
+ defaultSeverity: 'serious',
46
+ category: 'operable',
47
+ type: 'manual',
48
+ defaultConfidence: 'low',
49
+ coverage: {
50
+ facetsBySc: {
51
+ '2.4.7': ['css-hidden-focusable']
52
+ }
53
+ }
46
54
  };
47
55
 
48
56
  function runInPage(ctx) {
49
- const {document, root, helpers, rule} = ctx;
50
- const safeRoot = root || document;
51
-
52
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
53
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
54
- const isDomVisibleEligible = helpers && typeof helpers.isDomVisibleEligible === 'function' ? helpers.isDomVisibleEligible : null;
55
- const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
56
-
57
- const trim = (v) => (v == null ? '' : String(v)).trim();
58
- const lower = (v) => trim(v).toLowerCase();
59
-
60
- function qAll(sel) {
61
- try {
62
- if (queryAllSmart) {
63
- const r = queryAllSmart(sel);
64
- if (Array.isArray(r)) return r;
65
- return Array.from(r || []);
66
- }
67
- } catch {
68
- // fall through
69
- }
70
- try {
71
- if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
72
- } catch {
73
- // fall through
74
- }
75
- return [];
57
+ const { document, root, helpers, rule } = ctx;
58
+ const safeRoot = root || document;
59
+
60
+ const queryAllSmart =
61
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
62
+ const getFocusableInfo =
63
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
64
+ const isDomVisibleEligible =
65
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
66
+ ? helpers.isDomVisibleEligible
67
+ : null;
68
+ const reportOccurrence =
69
+ helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
70
+
71
+ const trim = (v) => (v == null ? '' : String(v)).trim();
72
+ const lower = (v) => trim(v).toLowerCase();
73
+
74
+ function qAll(sel) {
75
+ try {
76
+ if (queryAllSmart) {
77
+ const r = queryAllSmart(sel);
78
+ if (Array.isArray(r)) return r;
79
+ return Array.from(r || []);
80
+ }
81
+ } catch {
82
+ // fall through
76
83
  }
77
-
78
- function getComputedStyleSafe(el) {
79
- try {
80
- const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
81
- return w && w.getComputedStyle ? w.getComputedStyle(el) : null;
82
- } catch {
83
- return null;
84
- }
84
+ try {
85
+ if (safeRoot && typeof safeRoot.querySelectorAll === 'function')
86
+ return Array.from(safeRoot.querySelectorAll(sel));
87
+ } catch {
88
+ // fall through
85
89
  }
90
+ return [];
91
+ }
86
92
 
87
- // Returns deterministic "visually hidden but can remain focusable" hints.
88
- function getVisibilityHints(el) {
89
- const out = [];
90
- if (!el) return out;
91
- const cs = getComputedStyleSafe(el);
92
-
93
- // opacity:0
94
- try {
95
- const rawOp = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
96
- const op = rawOp ? Number.parseFloat(rawOp) : 1;
97
- if (Number.isFinite(op) && op <= 0.0001) out.push('opacityZero');
98
- } catch {
99
- }
93
+ function getComputedStyleSafe(el) {
94
+ try {
95
+ const w =
96
+ document && document.defaultView
97
+ ? document.defaultView
98
+ : typeof window !== 'undefined'
99
+ ? window
100
+ : null;
101
+ return w && w.getComputedStyle ? w.getComputedStyle(el) : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
100
106
 
101
- // clip / clip-path
102
- try {
103
- const clip = cs && cs.clip != null ? String(cs.clip).trim() : '';
104
- const clipPath = cs && cs.clipPath != null ? String(cs.clipPath).trim() : '';
105
- const clipLow = (clip || '').toLowerCase();
106
- const clipPathLow = (clipPath || '').toLowerCase();
107
-
108
- if (clipLow && clipLow !== 'auto') {
109
- if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1) out.push('clipped');
110
- }
111
- if (clipPathLow && clipPathLow !== 'none') {
112
- if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
113
- out.push('clipped');
114
- }
115
- }
116
- } catch {
107
+ // Returns deterministic "visually hidden but can remain focusable" hints.
108
+ function getVisibilityHints(el) {
109
+ const out = [];
110
+ if (!el) return out;
111
+ const cs = getComputedStyleSafe(el);
112
+
113
+ // opacity:0
114
+ try {
115
+ const rawOp = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
116
+ const op = rawOp ? Number.parseFloat(rawOp) : 1;
117
+ if (Number.isFinite(op) && op <= 0.0001) out.push('opacityZero');
118
+ } catch {}
119
+
120
+ // clip / clip-path
121
+ try {
122
+ const clip = cs && cs.clip != null ? String(cs.clip).trim() : '';
123
+ const clipPath = cs && cs.clipPath != null ? String(cs.clipPath).trim() : '';
124
+ const clipLow = (clip || '').toLowerCase();
125
+ const clipPathLow = (clipPath || '').toLowerCase();
126
+
127
+ if (clipLow && clipLow !== 'auto') {
128
+ if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1)
129
+ out.push('clipped');
130
+ }
131
+ if (clipPathLow && clipPathLow !== 'none') {
132
+ if (
133
+ clipPathLow.indexOf('inset(') !== -1 &&
134
+ (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)
135
+ ) {
136
+ out.push('clipped');
117
137
  }
138
+ }
139
+ } catch {}
140
+
141
+ // zero-size + overflow hidden/clip
142
+ try {
143
+ const wv = cs && cs.width != null ? String(cs.width).trim() : '';
144
+ const hv = cs && cs.height != null ? String(cs.height).trim() : '';
145
+ const ov = cs && cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
146
+ const isZeroW = wv === '0px' || wv === '0';
147
+ const isZeroH = hv === '0px' || hv === '0';
148
+ const hidesOverflow = ov === 'hidden' || ov === 'clip';
149
+ if ((isZeroW || isZeroH) && hidesOverflow) out.push('zeroSizeOverflowHidden');
150
+ } catch {}
151
+
152
+ // off-screen heuristic (absolute/fixed + left/top <= -5000 OR text-indent <= -5000)
153
+ try {
154
+ const pos = cs && cs.position != null ? String(cs.position).trim().toLowerCase() : '';
155
+ const left = cs && cs.left != null ? String(cs.left).trim().toLowerCase() : '';
156
+ const top = cs && cs.top != null ? String(cs.top).trim().toLowerCase() : '';
157
+ const ti = cs && cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
158
+
159
+ const parsePx = (s) => {
160
+ if (!s || s === 'auto') return null;
161
+ const m = String(s).match(/-?\d+(\.\d+)?/);
162
+ if (!m) return null;
163
+ const n = Number.parseFloat(m[0]);
164
+ return Number.isFinite(n) ? n : null;
165
+ };
166
+
167
+ const l = parsePx(left);
168
+ const t = parsePx(top);
169
+ const ind = parsePx(ti);
170
+
171
+ if (pos === 'absolute' || pos === 'fixed') {
172
+ if ((l != null && l <= -5000) || (t != null && t <= -5000)) out.push('offscreen');
173
+ }
174
+ if (ind != null && ind <= -5000) out.push('offscreen');
175
+ } catch {}
176
+
177
+ // Dedup
178
+ const seen = new Set();
179
+ const uniq = [];
180
+ for (const k of out) {
181
+ const kk = String(k);
182
+ if (!seen.has(kk)) {
183
+ seen.add(kk);
184
+ uniq.push(kk);
185
+ }
186
+ }
187
+ return uniq;
188
+ }
118
189
 
119
- // zero-size + overflow hidden/clip
120
- try {
121
- const wv = cs && cs.width != null ? String(cs.width).trim() : '';
122
- const hv = cs && cs.height != null ? String(cs.height).trim() : '';
123
- const ov = cs && cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
124
- const isZeroW = wv === '0px' || wv === '0';
125
- const isZeroH = hv === '0px' || hv === '0';
126
- const hidesOverflow = ov === 'hidden' || ov === 'clip';
127
- if ((isZeroW || isZeroH) && hidesOverflow) out.push('zeroSizeOverflowHidden');
128
- } catch {
129
- }
190
+ function getFocusableInfoSafe(el) {
191
+ if (!getFocusableInfo) return null;
192
+ try {
193
+ return getFocusableInfo(el, ctx);
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
130
198
 
131
- // off-screen heuristic (absolute/fixed + left/top <= -5000 OR text-indent <= -5000)
132
- try {
133
- const pos = cs && cs.position != null ? String(cs.position).trim().toLowerCase() : '';
134
- const left = cs && cs.left != null ? String(cs.left).trim().toLowerCase() : '';
135
- const top = cs && cs.top != null ? String(cs.top).trim().toLowerCase() : '';
136
- const ti = cs && cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
137
-
138
- const parsePx = (s) => {
139
- if (!s || s === 'auto') return null;
140
- const m = String(s).match(/-?\d+(\.\d+)?/);
141
- if (!m) return null;
142
- const n = Number.parseFloat(m[0]);
143
- return Number.isFinite(n) ? n : null;
144
- };
145
-
146
- const l = parsePx(left);
147
- const t = parsePx(top);
148
- const ind = parsePx(ti);
149
-
150
- if (pos === 'absolute' || pos === 'fixed') {
151
- if ((l != null && l <= -5000) || (t != null && t <= -5000)) out.push('offscreen');
152
- }
153
- if (ind != null && ind <= -5000) out.push('offscreen');
154
- } catch {
155
- }
199
+ function getDeepActiveElement(docRef) {
200
+ let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
201
+ let guard = 0;
202
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
203
+ cur = cur.shadowRoot.activeElement;
204
+ }
205
+ return cur;
206
+ }
156
207
 
157
- // Dedup
158
- const seen = new Set();
159
- const uniq = [];
160
- for (const k of out) {
161
- const kk = String(k);
162
- if (!seen.has(kk)) {
163
- seen.add(kk);
164
- uniq.push(kk);
165
- }
166
- }
167
- return uniq;
208
+ function focusElementSafe(el) {
209
+ if (!el || typeof el.focus !== 'function') return false;
210
+ try {
211
+ el.focus({ preventScroll: true });
212
+ return true;
213
+ } catch {
214
+ try {
215
+ el.focus();
216
+ return true;
217
+ } catch {
218
+ return false;
219
+ }
168
220
  }
221
+ }
222
+
223
+ function runFocusObservationWindow(docRef, fn) {
224
+ const w =
225
+ docRef && docRef.defaultView
226
+ ? docRef.defaultView
227
+ : typeof window !== 'undefined'
228
+ ? window
229
+ : null;
230
+ if (!w || typeof fn !== 'function') return;
231
+
232
+ const originalSetTimeout = typeof w.setTimeout === 'function' ? w.setTimeout.bind(w) : null;
233
+ const originalRequestAnimationFrame =
234
+ typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
235
+ const originalQueueMicrotask =
236
+ typeof w.queueMicrotask === 'function' ? w.queueMicrotask.bind(w) : null;
237
+
238
+ const queuedMicrotasks = [];
239
+ const queuedRaf = [];
240
+ const queuedTimers = [];
241
+ let fakeTimerId = 1;
242
+
243
+ const patchedSetTimeout = function (cb, delay) {
244
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
245
+ if (typeof cb === 'function' && d <= 200) {
246
+ const args = [];
247
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
248
+ queuedTimers.push({ delay: d, cb: () => cb.apply(w, args) });
249
+ return fakeTimerId++;
250
+ }
251
+ if (originalSetTimeout) return originalSetTimeout.apply(w, arguments);
252
+ return fakeTimerId++;
253
+ };
254
+
255
+ const patchedRaf = function (cb) {
256
+ if (typeof cb === 'function') {
257
+ queuedRaf.push(cb);
258
+ return fakeTimerId++;
259
+ }
260
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(w, arguments);
261
+ return fakeTimerId++;
262
+ };
263
+
264
+ const patchedQueueMicrotask = function (cb) {
265
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
266
+ };
169
267
 
170
- function getFocusableInfoSafe(el) {
171
- if (!getFocusableInfo) return null;
172
- try {
173
- return getFocusableInfo(el, ctx);
174
- } catch {
175
- return null;
268
+ try {
269
+ if (originalSetTimeout) w.setTimeout = patchedSetTimeout;
270
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = patchedRaf;
271
+ if (originalQueueMicrotask) w.queueMicrotask = patchedQueueMicrotask;
272
+ fn();
273
+
274
+ let guard = 0;
275
+ while (
276
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
277
+ guard++ < 100
278
+ ) {
279
+ while (queuedMicrotasks.length) {
280
+ const mt = queuedMicrotasks.shift();
281
+ try {
282
+ mt();
283
+ } catch {}
284
+ }
285
+ while (queuedRaf.length) {
286
+ const rf = queuedRaf.shift();
287
+ try {
288
+ rf(16);
289
+ } catch {}
176
290
  }
291
+ if (queuedTimers.length) {
292
+ queuedTimers.sort((a, b) => a.delay - b.delay);
293
+ const tt = queuedTimers.shift();
294
+ try {
295
+ tt.cb();
296
+ } catch {}
297
+ }
298
+ }
299
+ } finally {
300
+ if (originalSetTimeout) w.setTimeout = originalSetTimeout;
301
+ if (originalRequestAnimationFrame) w.requestAnimationFrame = originalRequestAnimationFrame;
302
+ if (originalQueueMicrotask) w.queueMicrotask = originalQueueMicrotask;
177
303
  }
304
+ }
305
+
306
+ function probeImmediateFocusRedirect(candidate) {
307
+ if (!candidate || typeof candidate.addEventListener !== 'function') return null;
308
+
309
+ let focusedByEvent = false;
310
+ const onFocusCapture = () => {
311
+ focusedByEvent = true;
312
+ };
313
+ try {
314
+ candidate.addEventListener('focus', onFocusCapture, true);
315
+ } catch {}
316
+
317
+ const before = getDeepActiveElement(document);
318
+ let focused = false;
319
+ runFocusObservationWindow(document, () => {
320
+ focused = focusElementSafe(candidate);
321
+ });
178
322
 
179
- function isTabbable(el, info) {
180
- const f = info || getFocusableInfoSafe(el);
181
- return !!(f && f.focusable && f.tabbable);
323
+ try {
324
+ candidate.removeEventListener('focus', onFocusCapture, true);
325
+ } catch {}
326
+ if (!focused || !focusedByEvent) return null;
327
+
328
+ const after = getDeepActiveElement(document);
329
+
330
+ if (before && before !== after) {
331
+ focusElementSafe(before);
182
332
  }
183
333
 
184
- // Exclude elements that are not rendered / not in the visual rendering tree.
185
- function isRendered(el) {
186
- if (!isDomVisibleEligible) return true;
187
- try {
188
- const vis = isDomVisibleEligible(el, ctx, {visibilityMode: 'styleOnly', disableGeometry: true});
189
- if (vis && vis.eligible === false) {
190
- const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
191
- // We *include* opacityZero in this rule. Everything else is treated as not rendered for this purpose.
192
- const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
193
- if (nonOpacity.length) return false;
194
- }
195
- } catch {
196
- // If in doubt, keep deterministic behavior and consider it rendered.
197
- }
198
- return true;
334
+ if (!after || after === candidate) return null;
335
+ const redirectedTag = (() => {
336
+ try {
337
+ return lower(after.tagName || '');
338
+ } catch {
339
+ return '';
340
+ }
341
+ })();
342
+ const redirectedId = (() => {
343
+ try {
344
+ return trim(after.getAttribute && after.getAttribute('id'));
345
+ } catch {
346
+ return '';
347
+ }
348
+ })();
349
+ return {
350
+ redirected: true,
351
+ redirectedToTag: redirectedTag || null,
352
+ redirectedToId: redirectedId || null
353
+ };
354
+ }
355
+
356
+ function isTabbable(el, info) {
357
+ const f = info || getFocusableInfoSafe(el);
358
+ return !!(f && f.focusable && f.tabbable);
359
+ }
360
+
361
+ // Exclude elements that are not rendered / not in the visual rendering tree.
362
+ function isRendered(el) {
363
+ if (!isDomVisibleEligible) return true;
364
+ try {
365
+ const vis = isDomVisibleEligible(el, ctx, {
366
+ visibilityMode: 'styleOnly',
367
+ disableGeometry: true
368
+ });
369
+ if (vis && vis.eligible === false) {
370
+ const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
371
+ // We *include* opacityZero in this rule. Everything else is treated as not rendered for this purpose.
372
+ const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
373
+ if (nonOpacity.length) return false;
374
+ }
375
+ } catch {
376
+ // If in doubt, keep deterministic behavior and consider it rendered.
199
377
  }
200
- const candidates = qAll('a[href],area[href],button,input,select,textarea,summary,[tabindex],[contenteditable]');
378
+ return true;
379
+ }
380
+ const candidates = qAll(
381
+ 'a[href],area[href],button,input,select,textarea,summary,[tabindex],[contenteditable]'
382
+ );
201
383
  if (!candidates.length) {
202
384
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
203
385
  }
204
386
 
205
387
  const occurrences = [];
388
+ const maxRuntimeProbeCount = 3;
389
+ let runtimeProbeCount = 0;
206
390
 
207
391
  for (const el of candidates) {
208
392
  if (!el || !el.getAttribute) continue;
@@ -216,30 +400,52 @@ function runInPage(ctx) {
216
400
  const hints = getVisibilityHints(el);
217
401
  if (!hints.length) continue; // <-- applicability gate
218
402
 
219
- const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
403
+ const tagName = (() => {
404
+ try {
405
+ return lower(el.tagName || '');
406
+ } catch {
407
+ return '';
408
+ }
409
+ })();
220
410
 
221
411
  const hintOrder = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
222
412
  const hintsArr = [];
223
413
  for (const k of hintOrder) if (hints.includes(k)) hintsArr.push(k);
224
414
 
415
+ const runtimeProbe =
416
+ runtimeProbeCount < maxRuntimeProbeCount ? probeImmediateFocusRedirect(el) : null;
417
+ if (runtimeProbeCount < maxRuntimeProbeCount) runtimeProbeCount += 1;
418
+ const downgradedToRedirectReview = !!(runtimeProbe && runtimeProbe.redirected);
419
+
225
420
  const baseOccurrence = {
226
- summary: `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
227
- hint: 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
228
- i18n: {
229
- summaryKey: 'cssHidden_focus_summary_cantTell',
230
- hintKey: 'cssHidden_focus_hint_cantTell',
231
- params: { element: tagName, visibilityHints: hintsArr.join(',') }
232
- },
421
+ summary: downgradedToRedirectReview
422
+ ? `Focusable ${tagName} appears visually hidden but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
423
+ : `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
424
+ hint: downgradedToRedirectReview
425
+ ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on visually hidden focus targets.'
426
+ : 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
427
+ i18n: downgradedToRedirectReview
428
+ ? null
429
+ : {
430
+ summaryKey: 'cssHidden_focus_summary_cantTell',
431
+ hintKey: 'cssHidden_focus_hint_cantTell',
432
+ params: { element: tagName, visibilityHints: hintsArr.join(',') }
433
+ },
233
434
  data: {
234
435
  details: {
235
- reasonCode: 'cssHiddenTabbable_needsFocusStateVerification',
236
- metrics: { visibilityHints: hintsArr.slice(0) }
436
+ reasonCode: downgradedToRedirectReview
437
+ ? 'cssHiddenTabbable_runtimeRedirect_needsReview'
438
+ : 'cssHiddenTabbable_needsFocusStateVerification',
439
+ metrics: { visibilityHints: hintsArr.slice(0) },
440
+ runtimeProbe: runtimeProbe || null
237
441
  }
238
442
  }
239
443
  };
240
444
 
241
445
  occurrences.push(
242
- reportOccurrence ? reportOccurrence(el, baseOccurrence) : { __node: el, selector: '', html: '', ...baseOccurrence }
446
+ reportOccurrence
447
+ ? reportOccurrence(el, baseOccurrence)
448
+ : { __node: el, selector: '', html: '', ...baseOccurrence }
243
449
  );
244
450
  }
245
451
 
@@ -256,4 +462,4 @@ function runInPage(ctx) {
256
462
  };
257
463
  }
258
464
 
259
- module.exports = {id, meta, runInPage};
465
+ module.exports = { id, meta, runInPage };