@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.
- package/CHANGELOG.md +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +50 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +125 -29
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +50 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +39 -9
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +117 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +26 -7
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -12
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +25 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +11 -9
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- package/src/sarif.js +175 -0
- 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
|
-
{
|
|
39
|
-
|
|
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 =
|
|
68
|
+
const queryAllSmart =
|
|
69
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
57
70
|
|
|
58
|
-
const getFocusableInfo =
|
|
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 =
|
|
75
|
+
const isDomVisibleEligible =
|
|
76
|
+
helpers && typeof helpers.isDomVisibleEligible === 'function'
|
|
77
|
+
? helpers.isDomVisibleEligible
|
|
78
|
+
: null;
|
|
62
79
|
|
|
63
|
-
const getEligibilityInfo =
|
|
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 =
|
|
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')
|
|
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 =
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
372
|
+
let cs;
|
|
123
373
|
try {
|
|
124
|
-
const w =
|
|
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)
|
|
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 (
|
|
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)) {
|
|
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 (
|
|
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 {
|
|
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 (
|
|
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, {
|
|
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 (
|
|
323
|
-
|
|
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 = {
|
|
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
|
|
377
|
-
let href
|
|
378
|
-
let type
|
|
669
|
+
let tag;
|
|
670
|
+
let ti;
|
|
671
|
+
let href;
|
|
672
|
+
let type;
|
|
379
673
|
|
|
380
|
-
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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
|
-
? (() => {
|
|
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 = (() => {
|
|
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
|
-
?
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
?
|
|
759
|
+
? descendantFocusable > 0
|
|
760
|
+
? 'ariaHiddenSelfAndDescendantsFocusable'
|
|
761
|
+
: 'ariaHiddenSelfFocusable'
|
|
437
762
|
: 'ariaHiddenContainsFocusable';
|
|
438
763
|
|
|
439
764
|
const summaryText = selfFocusable
|
|
440
|
-
?
|
|
441
|
-
|
|
442
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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 };
|