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