@surea11y/core 1.3.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 +46 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/CI_INTEGRATIONS.md +7 -7
- package/docs/ENGINE_OPTIONS.md +1 -1
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/REPORT.md +1 -1
- package/package.json +50 -16
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +4 -6
- package/src/checks/automatic/aria-allowed-attr.js +15 -51
- package/src/checks/automatic/aria-allowed-role.js +2 -0
- package/src/checks/automatic/aria-braille-equivalent.js +2 -0
- package/src/checks/automatic/aria-conditional-attr.js +8 -7
- package/src/checks/automatic/aria-deprecated-role.js +4 -3
- package/src/checks/automatic/aria-hidden-body.js +6 -4
- package/src/checks/automatic/aria-hidden-focus.js +12 -13
- package/src/checks/automatic/aria-prohibited-attr.js +98 -105
- package/src/checks/automatic/aria-prohibited-children.js +56 -87
- package/src/checks/automatic/aria-required-attr.js +6 -7
- package/src/checks/automatic/aria-required-children.js +7 -10
- package/src/checks/automatic/aria-required-parent.js +20 -25
- package/src/checks/automatic/aria-role-name-present.js +2 -0
- package/src/checks/automatic/aria-roles-valid.js +2 -0
- package/src/checks/automatic/aria-valid-attr-value.js +18 -15
- package/src/checks/automatic/aria-valid-attr.js +2 -0
- package/src/checks/automatic/autocomplete-valid.js +2 -0
- package/src/checks/automatic/avoid-inline-spacing.js +3 -2
- package/src/checks/automatic/binary-control-name-present.js +2 -0
- package/src/checks/automatic/button-name-present.js +7 -7
- package/src/checks/automatic/bypass-blocks-present.js +9 -7
- package/src/checks/automatic/canvas-text-alternative-present.js +2 -0
- package/src/checks/automatic/combobox-name-present.js +2 -0
- package/src/checks/automatic/contrast-computable.js +2 -0
- package/src/checks/automatic/contrast-enhanced.js +2 -0
- package/src/checks/automatic/contrast-minimum.js +2 -0
- package/src/checks/automatic/css-orientation-lock.js +21 -27
- package/src/checks/automatic/definition-list-children-valid.js +6 -6
- package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
- package/src/checks/automatic/dialog-name-present.js +10 -10
- package/src/checks/automatic/dlitem-parent-valid.js +2 -0
- package/src/checks/automatic/duplicate-id-aria.js +4 -3
- package/src/checks/automatic/embed-text-alternative-present.js +2 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +2 -0
- package/src/checks/automatic/form-control-single-label.js +6 -7
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
- package/src/checks/automatic/iframe-focusable-content.js +246 -18
- package/src/checks/automatic/iframe-name-present.js +2 -0
- package/src/checks/automatic/iframe-title-unique.js +3 -1
- package/src/checks/automatic/img-alt-present.js +7 -9
- package/src/checks/automatic/input-image-alt-present.js +4 -6
- package/src/checks/automatic/label-in-name.js +15 -19
- package/src/checks/automatic/language-page-present.js +2 -0
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +2 -0
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +2 -0
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +2 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +4 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +2 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +2 -0
- package/src/checks/automatic/meter-name-present.js +4 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +27 -5
- package/src/checks/automatic/object-text-alternative-present.js +2 -0
- package/src/checks/automatic/option-name-present.js +2 -0
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +8 -10
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +2 -0
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +2 -0
- package/src/checks/automatic/spinbutton-name-present.js +2 -0
- package/src/checks/automatic/summary-name-present.js +2 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
- package/src/checks/automatic/svg-text-alternative-present.js +17 -5
- package/src/checks/automatic/tab-name-present.js +2 -0
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +2 -0
- package/src/checks/automatic/target-size-minimum.js +5 -0
- package/src/checks/automatic/td-has-header.js +24 -1
- package/src/checks/automatic/textbox-name-present.js +2 -0
- package/src/checks/automatic/tooltip-name-present.js +2 -0
- package/src/checks/automatic/treeitem-name-present.js +2 -0
- package/src/checks/automatic/valid-lang.js +2 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
- package/src/checks/manual/accesskeys-manual.js +3 -1
- package/src/checks/manual/area-alt-decorative-manual.js +2 -0
- package/src/checks/manual/area-alt-quality-manual.js +2 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
- package/src/checks/manual/aria-text-manual.js +6 -5
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/css-hidden-focus.js +184 -9
- package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
- package/src/checks/manual/empty-heading-manual.js +17 -17
- package/src/checks/manual/empty-table-header-manual.js +52 -25
- package/src/checks/manual/focus-order-semantics-manual.js +16 -4
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
- package/src/checks/manual/heading-order-manual.js +28 -1
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
- package/src/checks/manual/image-redundant-alt-manual.js +21 -1
- package/src/checks/manual/img-alt-decorative-manual.js +2 -0
- package/src/checks/manual/img-alt-quality-manual.js +2 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +2 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
- package/src/checks/manual/label-title-only-manual.js +29 -22
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +54 -47
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +42 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
- package/src/checks/manual/landmark-one-main-manual.js +12 -23
- package/src/checks/manual/landmark-unique-manual.js +37 -52
- package/src/checks/manual/link-name-quality-manual.js +2 -0
- package/src/checks/manual/media-transcript-present-manual.js +2 -0
- package/src/checks/manual/meta-viewport-large-manual.js +3 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/p-as-heading-manual.js +2 -0
- package/src/checks/manual/page-has-heading-one-manual.js +12 -11
- package/src/checks/manual/page-title-patterns-manual.js +2 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
- package/src/checks/manual/region-manual.js +27 -36
- package/src/checks/manual/scope-attr-valid-manual.js +3 -1
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
- package/src/checks/manual/skip-link-manual.js +7 -6
- package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/tabindex-manual.js +3 -1
- package/src/checks/manual/table-duplicate-name-manual.js +5 -4
- package/src/checks/manual/table-fake-caption-manual.js +24 -3
- package/src/checks/manual/video-caption-manual.js +2 -0
- package/src/checks/manual-review.js +2 -0
- package/src/core.js +6772 -2158
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +18 -3
- package/surea11y.browser.js +2731 -999
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- 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 -1211
- package/src/core/contrast-helpers.js +0 -1302
- package/src/core/dom-helpers.js +0 -4493
- package/src/core/dom-runner.js +0 -787
- package/src/core/frame-messaging.js +0 -261
- package/src/core/frame-scan.js +0 -190
- package/src/core/rollup-composites.js +0 -127
- package/src/core/rule-meta.js +0 -176
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -1228
- package/src/i18n/fr.js +0 -1185
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -59
- 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
|
/**
|
|
@@ -62,10 +64,141 @@ const meta = {
|
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
function runInPage(ctx) {
|
|
65
|
-
const { helpers, rule } = ctx;
|
|
67
|
+
const { helpers, rule, document } = ctx;
|
|
68
|
+
|
|
69
|
+
// Self-contained rendering check for the embedded document (a distinct
|
|
70
|
+
// realm — see this rule's own header comment on why the outer
|
|
71
|
+
// document's shared eligibility helpers can't be reused here).
|
|
72
|
+
// Deliberately checks only genuine non-rendering (display:none,
|
|
73
|
+
// visibility:hidden, the hidden attribute) via the ancestor chain, NOT
|
|
74
|
+
// aria-hidden: aria-hidden alone does not remove an element from a real
|
|
75
|
+
// browser's native tab order (the same anti-pattern this engine's own
|
|
76
|
+
// aria-hidden-focus rule exists to catch), so an aria-hidden-but-
|
|
77
|
+
// visually-rendered focusable element inside the frame is still
|
|
78
|
+
// genuinely reachable by keyboard and must stay flagged.
|
|
79
|
+
function isRenderedInDoc(doc, el) {
|
|
80
|
+
try {
|
|
81
|
+
const view = doc.defaultView;
|
|
82
|
+
if (!view || typeof view.getComputedStyle !== 'function') return true;
|
|
83
|
+
let node = el;
|
|
84
|
+
while (node && node.nodeType === 1) {
|
|
85
|
+
if (node.hasAttribute && node.hasAttribute('hidden')) return false;
|
|
86
|
+
const cs = view.getComputedStyle(node);
|
|
87
|
+
if (cs) {
|
|
88
|
+
if (cs.display === 'none') return false;
|
|
89
|
+
if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
|
|
90
|
+
}
|
|
91
|
+
node = node.parentElement;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getDeepActiveElement(docRef) {
|
|
100
|
+
let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
|
|
101
|
+
let guard = 0;
|
|
102
|
+
while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
|
|
103
|
+
cur = cur.shadowRoot.activeElement;
|
|
104
|
+
}
|
|
105
|
+
return cur;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function focusElementSafe(el) {
|
|
109
|
+
if (!el || typeof el.focus !== 'function') return false;
|
|
110
|
+
try {
|
|
111
|
+
el.focus({ preventScroll: true });
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
try {
|
|
115
|
+
el.focus();
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runFocusObservationWindow(win, fn) {
|
|
124
|
+
if (!win || typeof fn !== 'function') return;
|
|
125
|
+
const originalSetTimeout =
|
|
126
|
+
typeof win.setTimeout === 'function' ? win.setTimeout.bind(win) : null;
|
|
127
|
+
const originalRequestAnimationFrame =
|
|
128
|
+
typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.bind(win) : null;
|
|
129
|
+
const originalQueueMicrotask =
|
|
130
|
+
typeof win.queueMicrotask === 'function' ? win.queueMicrotask.bind(win) : null;
|
|
131
|
+
|
|
132
|
+
const queuedMicrotasks = [];
|
|
133
|
+
const queuedRaf = [];
|
|
134
|
+
const queuedTimers = [];
|
|
135
|
+
let fakeTimerId = 1;
|
|
136
|
+
|
|
137
|
+
const patchedSetTimeout = function (cb, delay) {
|
|
138
|
+
const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
|
|
139
|
+
if (typeof cb === 'function' && d <= 200) {
|
|
140
|
+
const args = [];
|
|
141
|
+
for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
|
|
142
|
+
queuedTimers.push({ delay: d, cb: () => cb.apply(win, args) });
|
|
143
|
+
return fakeTimerId++;
|
|
144
|
+
}
|
|
145
|
+
if (originalSetTimeout) return originalSetTimeout.apply(win, arguments);
|
|
146
|
+
return fakeTimerId++;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const patchedRaf = function (cb) {
|
|
150
|
+
if (typeof cb === 'function') {
|
|
151
|
+
queuedRaf.push(cb);
|
|
152
|
+
return fakeTimerId++;
|
|
153
|
+
}
|
|
154
|
+
if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(win, arguments);
|
|
155
|
+
return fakeTimerId++;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const patchedQueueMicrotask = function (cb) {
|
|
159
|
+
if (typeof cb === 'function') queuedMicrotasks.push(cb);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
if (originalSetTimeout) win.setTimeout = patchedSetTimeout;
|
|
164
|
+
if (originalRequestAnimationFrame) win.requestAnimationFrame = patchedRaf;
|
|
165
|
+
if (originalQueueMicrotask) win.queueMicrotask = patchedQueueMicrotask;
|
|
166
|
+
fn();
|
|
66
167
|
|
|
67
|
-
|
|
68
|
-
|
|
168
|
+
let guard = 0;
|
|
169
|
+
while (
|
|
170
|
+
(queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
|
|
171
|
+
guard++ < 100
|
|
172
|
+
) {
|
|
173
|
+
while (queuedMicrotasks.length) {
|
|
174
|
+
const mt = queuedMicrotasks.shift();
|
|
175
|
+
try {
|
|
176
|
+
mt();
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
while (queuedRaf.length) {
|
|
180
|
+
const rf = queuedRaf.shift();
|
|
181
|
+
try {
|
|
182
|
+
rf(16);
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
if (queuedTimers.length) {
|
|
186
|
+
queuedTimers.sort((a, b) => a.delay - b.delay);
|
|
187
|
+
const tt = queuedTimers.shift();
|
|
188
|
+
try {
|
|
189
|
+
tt.cb();
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} finally {
|
|
194
|
+
if (originalSetTimeout) win.setTimeout = originalSetTimeout;
|
|
195
|
+
if (originalRequestAnimationFrame) win.requestAnimationFrame = originalRequestAnimationFrame;
|
|
196
|
+
if (originalQueueMicrotask) win.queueMicrotask = originalQueueMicrotask;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getFocusableCandidates(doc) {
|
|
201
|
+
if (!doc || !doc.querySelectorAll) return [];
|
|
69
202
|
let els;
|
|
70
203
|
try {
|
|
71
204
|
els = doc.querySelectorAll(
|
|
@@ -73,8 +206,9 @@ function runInPage(ctx) {
|
|
|
73
206
|
'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
|
|
74
207
|
);
|
|
75
208
|
} catch {
|
|
76
|
-
return
|
|
209
|
+
return [];
|
|
77
210
|
}
|
|
211
|
+
const candidates = [];
|
|
78
212
|
for (const el of els) {
|
|
79
213
|
if (!el || !el.getAttribute) continue;
|
|
80
214
|
const raw = el.getAttribute('tabindex');
|
|
@@ -82,9 +216,77 @@ function runInPage(ctx) {
|
|
|
82
216
|
const n = Number(String(raw).trim());
|
|
83
217
|
if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
|
|
84
218
|
}
|
|
85
|
-
|
|
219
|
+
if (!isRenderedInDoc(doc, el)) continue; // display:none/visibility:hidden/[hidden]: never reachable at all
|
|
220
|
+
candidates.push(el);
|
|
86
221
|
}
|
|
87
|
-
return
|
|
222
|
+
return candidates;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function probeImmediateFocusRedirect(frameEl, embeddedDoc, candidate) {
|
|
226
|
+
if (!frameEl || !embeddedDoc || !candidate) return null;
|
|
227
|
+
const embeddedWindow = embeddedDoc.defaultView;
|
|
228
|
+
if (!embeddedWindow) return null;
|
|
229
|
+
|
|
230
|
+
let focusedByEvent = false;
|
|
231
|
+
const onFocusCapture = () => {
|
|
232
|
+
focusedByEvent = true;
|
|
233
|
+
};
|
|
234
|
+
try {
|
|
235
|
+
candidate.addEventListener('focus', onFocusCapture, true);
|
|
236
|
+
} catch {}
|
|
237
|
+
|
|
238
|
+
const innerFocusTrace = [];
|
|
239
|
+
const outerFocusTrace = [];
|
|
240
|
+
const onInnerFocusIn = (ev) => {
|
|
241
|
+
if (ev && ev.target) innerFocusTrace.push(ev.target);
|
|
242
|
+
};
|
|
243
|
+
const onOuterFocusIn = (ev) => {
|
|
244
|
+
if (ev && ev.target) outerFocusTrace.push(ev.target);
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
embeddedDoc.addEventListener('focusin', onInnerFocusIn, true);
|
|
248
|
+
document.addEventListener('focusin', onOuterFocusIn, true);
|
|
249
|
+
} catch {}
|
|
250
|
+
|
|
251
|
+
const beforeInner = getDeepActiveElement(embeddedDoc);
|
|
252
|
+
const beforeOuter = getDeepActiveElement(document);
|
|
253
|
+
let focused = false;
|
|
254
|
+
runFocusObservationWindow(embeddedWindow, () => {
|
|
255
|
+
focused = focusElementSafe(candidate);
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
embeddedDoc.removeEventListener('focusin', onInnerFocusIn, true);
|
|
259
|
+
document.removeEventListener('focusin', onOuterFocusIn, true);
|
|
260
|
+
} catch {}
|
|
261
|
+
try {
|
|
262
|
+
candidate.removeEventListener('focus', onFocusCapture, true);
|
|
263
|
+
} catch {}
|
|
264
|
+
|
|
265
|
+
if (!focused) return null;
|
|
266
|
+
|
|
267
|
+
const afterInner = getDeepActiveElement(embeddedDoc);
|
|
268
|
+
const afterOuter = getDeepActiveElement(document);
|
|
269
|
+
const sawRedirectedInnerTrace = innerFocusTrace.some((n) => n && n !== candidate);
|
|
270
|
+
const sawRedirectedOuterTrace = outerFocusTrace.some(
|
|
271
|
+
(n) => n && n !== frameEl && n !== candidate
|
|
272
|
+
);
|
|
273
|
+
const redirectedWithinFrame =
|
|
274
|
+
!!(afterInner && afterInner !== candidate) || sawRedirectedInnerTrace;
|
|
275
|
+
const redirectedOutOfFrame =
|
|
276
|
+
!!(afterOuter && afterOuter !== frameEl) || sawRedirectedOuterTrace;
|
|
277
|
+
const sawCandidateFocus = focusedByEvent || innerFocusTrace.some((n) => n === candidate);
|
|
278
|
+
|
|
279
|
+
if (beforeInner && beforeInner !== afterInner) focusElementSafe(beforeInner);
|
|
280
|
+
if (beforeOuter && beforeOuter !== afterOuter) focusElementSafe(beforeOuter);
|
|
281
|
+
|
|
282
|
+
if (!sawCandidateFocus) return null;
|
|
283
|
+
if (!redirectedWithinFrame && !redirectedOutOfFrame) return null;
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
redirected: true,
|
|
287
|
+
redirectedWithinFrame,
|
|
288
|
+
redirectedOutOfFrame
|
|
289
|
+
};
|
|
88
290
|
}
|
|
89
291
|
|
|
90
292
|
function getNegativeTabIndex(el) {
|
|
@@ -98,7 +300,8 @@ function runInPage(ctx) {
|
|
|
98
300
|
? helpers.queryAllSmart('iframe, frame')
|
|
99
301
|
: helpers.queryAll('iframe, frame');
|
|
100
302
|
|
|
101
|
-
const
|
|
303
|
+
const failOccurrences = [];
|
|
304
|
+
const cantTellOccurrences = [];
|
|
102
305
|
let applicableCount = 0;
|
|
103
306
|
|
|
104
307
|
for (const el of nodes) {
|
|
@@ -115,13 +318,37 @@ function runInPage(ctx) {
|
|
|
115
318
|
|
|
116
319
|
applicableCount += 1;
|
|
117
320
|
|
|
118
|
-
|
|
321
|
+
const candidates = getFocusableCandidates(contentDoc);
|
|
322
|
+
if (!candidates.length) continue;
|
|
119
323
|
|
|
120
324
|
const tag = el.tagName.toLowerCase();
|
|
121
325
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
122
326
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
327
|
+
const shouldProbe = candidates.length === 1;
|
|
328
|
+
const runtimeProbe = shouldProbe
|
|
329
|
+
? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
|
|
330
|
+
: null;
|
|
331
|
+
|
|
332
|
+
if (runtimeProbe && runtimeProbe.redirected) {
|
|
333
|
+
cantTellOccurrences.push({
|
|
334
|
+
selector: stableSelector,
|
|
335
|
+
html,
|
|
336
|
+
summary:
|
|
337
|
+
'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
|
|
338
|
+
hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
|
|
339
|
+
i18n: null,
|
|
340
|
+
data: {
|
|
341
|
+
details: {
|
|
342
|
+
reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
|
|
343
|
+
element: tag,
|
|
344
|
+
runtimeProbe
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
123
350
|
|
|
124
|
-
|
|
351
|
+
failOccurrences.push({
|
|
125
352
|
selector: stableSelector,
|
|
126
353
|
html,
|
|
127
354
|
summary:
|
|
@@ -141,15 +368,16 @@ function runInPage(ctx) {
|
|
|
141
368
|
if (applicableCount === 0) {
|
|
142
369
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
143
370
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
371
|
+
// helpers.resolveTieredOutcome is unconditionally provided by dom-helpers.js
|
|
372
|
+
// (see its own header comment) -- no fallback needed, matching the same
|
|
373
|
+
// cleanup already applied to aria-hidden-focus.js/aria-prohibited-attr.js/
|
|
374
|
+
// target-size-minimum.js.
|
|
375
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
376
|
+
failOccurrences,
|
|
377
|
+
cantTellOccurrences,
|
|
378
|
+
rule.defaultSeverity || 'moderate'
|
|
379
|
+
);
|
|
380
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
153
381
|
}
|
|
154
382
|
|
|
155
383
|
module.exports = { id, meta, runInPage };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
* a frame can have a non-empty title while still failing uniqueness.
|
|
19
21
|
* - Compares the title ATTRIBUTE specifically, not the full computed
|
|
20
22
|
* accessible name (aria-label could legitimately differ in wording even
|
|
21
|
-
* when title happens to collide)
|
|
23
|
+
* when title happens to collide).
|
|
22
24
|
* - Not rule-gated on isAccTreeEligible: duplicate titles are a static
|
|
23
25
|
* markup property. Engine-level hidden-subtree filtering still applies
|
|
24
26
|
* unless engineOptions.includeHiddenElements is true.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -172,15 +174,11 @@ function runInPage(ctx) {
|
|
|
172
174
|
if (ariaName && ariaName.present) continue;
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
// A non-empty title attribute is HTML-AAM's
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// title as one of its "any" satisfying conditions alongside has-alt/
|
|
181
|
-
// aria-label/aria-labelledby. Found via a real page (AliExpress's
|
|
182
|
-
// logo, 2026-07-23): <img src="..." title="...">, no alt attribute
|
|
183
|
-
// at all -- a real false positive, not a missing text alternative.
|
|
177
|
+
// A non-empty title attribute is HTML-AAM's next fallback naming source
|
|
178
|
+
// for <img> once alt is entirely absent (not merely alt="", which
|
|
179
|
+
// explicitly marks decorative and stays excluded from this branch since
|
|
180
|
+
// hasAlt already short-circuited above). An `<img src="..." title="...">`
|
|
181
|
+
// with no alt attribute at all is not missing a text alternative.
|
|
184
182
|
const title = trim(el.getAttribute('title'));
|
|
185
183
|
if (title) continue;
|
|
186
184
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -129,12 +131,8 @@ function runInPage(ctx) {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
// A non-empty title attribute is HTML-AAM's own next fallback naming
|
|
132
|
-
// source once alt is entirely absent
|
|
133
|
-
//
|
|
134
|
-
// as non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
|
|
135
|
-
// sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
|
|
136
|
-
// the real page this was found via -- same gap, same fix, different
|
|
137
|
-
// element.
|
|
134
|
+
// source once alt is entirely absent. Same gap img-alt-present handles
|
|
135
|
+
// for <img title="..."> with no alt.
|
|
138
136
|
const titleRaw = (() => {
|
|
139
137
|
try {
|
|
140
138
|
return el.getAttribute('title');
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
const id = 'label-in-name';
|
|
@@ -90,20 +92,13 @@ function runInPage(ctx) {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
// Unlike isDomVisible above, this also excludes aria-hidden subtrees.
|
|
93
|
-
// Needed
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// (measuring rendered glyph shapes), not replicable here since this rule
|
|
101
|
-
// runs against static markup with no real canvas/font rendering
|
|
102
|
-
// available; excluding aria-hidden content is a cheaper, static-markup
|
|
103
|
-
// signal that gets the common case (decorative icon fonts) right without
|
|
104
|
-
// needing one. Confirmed via a live-DOM cross-engine run 2026-07-21
|
|
105
|
-
// (Angular Material's theme-picker button, aria-label="Select a theme",
|
|
106
|
-
// aria-hidden icon rendering literally as "format_color_fill").
|
|
95
|
+
// Needed for collectVisibleTextUnder's per-text-node check below: an
|
|
96
|
+
// aria-hidden icon-font glyph (e.g. an <i aria-hidden="true"> ligature
|
|
97
|
+
// rendering as "format_color_fill") is DOM-visible pixels but is never
|
|
98
|
+
// perceived as literal readable words the way real visible text is.
|
|
99
|
+
// Excluding aria-hidden content is a cheap static-markup signal that gets
|
|
100
|
+
// the common case (decorative icon fonts) right — an icon-only button
|
|
101
|
+
// named via aria-label shouldn't have its glyph name counted as text.
|
|
107
102
|
function isAccEligible(el) {
|
|
108
103
|
if (!el) return false;
|
|
109
104
|
const fn =
|
|
@@ -122,11 +117,12 @@ function runInPage(ctx) {
|
|
|
122
117
|
// than referencing the global NodeFilter object directly: runInPage must
|
|
123
118
|
// have zero free vars (see docs/RULE_AUTHORING.md's free-var footgun) and
|
|
124
119
|
// NodeFilter is not itself present in the execution realm this function
|
|
125
|
-
// actually runs in, unlike window/document.
|
|
126
|
-
// silently
|
|
127
|
-
// raw container.textContent (which respects none of
|
|
128
|
-
// isDomVisible/isAccEligible below, since that whole
|
|
129
|
-
// skipped in the fallback path). Same pattern already
|
|
120
|
+
// actually runs in, unlike window/document. Referencing the global
|
|
121
|
+
// directly would silently make createTreeWalker throw on every call,
|
|
122
|
+
// falling back to raw container.textContent (which respects none of
|
|
123
|
+
// isNonRenderedTag/isDomVisible/isAccEligible below, since that whole
|
|
124
|
+
// per-node loop is skipped in the fallback path). Same pattern already
|
|
125
|
+
// used correctly in
|
|
130
126
|
// region-manual.js's own createTreeWalker call.
|
|
131
127
|
const SHOW_TEXT = 4;
|
|
132
128
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -24,28 +26,17 @@
|
|
|
24
26
|
* inverse relationship: does a given <li> have a valid parent).
|
|
25
27
|
* - Direct children that are not exposed to the accessibility tree (e.g.
|
|
26
28
|
* display:none, [hidden], aria-hidden="true") are excluded from
|
|
27
|
-
* consideration entirely —
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* DIFFERENT function from the similarly-named, unused `onlyListitemsEvaluate`
|
|
39
|
-
* elsewhere in that engine's source; don't confuse the two when re-verifying). Its
|
|
40
|
-
* exact rule: if a child has an explicit role, only `validRoles` (here,
|
|
41
|
-
* `['listitem']`) is consulted — the tag name is never checked. Only
|
|
42
|
-
* without an explicit role does the tag name (`validNodeNames`) matter.
|
|
43
|
-
* Found via two real sites: Notion's footer nav lists include a
|
|
44
|
-
* `<li role="none">` used purely to host the list's own visually-hidden
|
|
45
|
-
* label (referenced by the `<ul>`'s `aria-labelledby`), and LinkedIn has
|
|
46
|
-
* both an all-`<li role="menuitem">` menu list and a footer list mixing
|
|
47
|
-
* real `<li>`s with one `<li role="presentation">` — that reference engine
|
|
48
|
-
* correctly flags all of these, surea11y's tag-only check missed them entirely.
|
|
29
|
+
* consideration entirely — an element not reachable by assistive
|
|
30
|
+
* technology can't break the list semantics a screen reader announces.
|
|
31
|
+
* Common cases: a stray `<input type="hidden">` as a direct <ul> child
|
|
32
|
+
* (UA-stylesheet display:none by spec), or `<span style="display:none">`
|
|
33
|
+
* hydration markers interleaved with real `<li>`s.
|
|
34
|
+
* - Explicit-role-overrides-tag: if a child has an explicit role, only
|
|
35
|
+
* `['listitem']` is consulted — the tag name is never checked. Only
|
|
36
|
+
* without an explicit role does the tag name matter. Catches cases a
|
|
37
|
+
* tag-only check misses: `<li role="none">` hosting a list's own
|
|
38
|
+
* visually-hidden label, `<li role="menuitem">` menu items, or a real
|
|
39
|
+
* `<li>` mixed with an `<li role="presentation">`.
|
|
49
40
|
*/
|
|
50
41
|
|
|
51
42
|
const id = 'list-children-valid';
|
|
@@ -120,8 +111,7 @@ function runInPage(ctx) {
|
|
|
120
111
|
const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
|
|
121
112
|
const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
|
|
122
113
|
|
|
123
|
-
// An explicit role always wins over the tag — see
|
|
124
|
-
// comment's Notion/LinkedIn examples.
|
|
114
|
+
// An explicit role always wins over the tag — see header comment.
|
|
125
115
|
const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
|
|
126
116
|
|
|
127
117
|
if (!valid) invalidTags.push(tag);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -25,12 +27,19 @@
|
|
|
25
27
|
* native role is fully replaced by the explicit one — the same "any
|
|
26
28
|
* explicit role overrides the element's native role" ARIA principle
|
|
27
29
|
* applied elsewhere in this engine), so an <li> inside it is invalid
|
|
28
|
-
* despite the <ul> tag
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
|
|
31
|
+
* role="presentation"/"none" on the parent is still a valid
|
|
32
|
+
* (list-semantics-suppressing) parent — the accepted parent roles are
|
|
33
|
+
* presentation, none, and list.
|
|
34
|
+
* - The SAME "explicit role wins" principle applies to the <li> ELEMENT
|
|
35
|
+
* ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
|
|
36
|
+
* is exposed to AT with that role, never "listitem" — the whole point
|
|
37
|
+
* of this check (list items need a valid list-container parent) doesn't
|
|
38
|
+
* apply when the element isn't claiming listitem semantics in the first
|
|
39
|
+
* place. Any `<li>` with an explicit `role` attribute is excluded from
|
|
40
|
+
* candidacy. `role="listitem"` itself is a no-op restatement (not an
|
|
41
|
+
* override), so it still falls through to the normal parent-validity
|
|
42
|
+
* check below.
|
|
34
43
|
*/
|
|
35
44
|
|
|
36
45
|
const id = 'listitem-parent-valid';
|
|
@@ -75,6 +84,20 @@ function runInPage(ctx) {
|
|
|
75
84
|
const parent = el.parentElement;
|
|
76
85
|
if (!parent) continue;
|
|
77
86
|
|
|
87
|
+
// An explicit role on the <li> ITSELF overrides its native "listitem"
|
|
88
|
+
// role entirely, the same "any explicit role wins over the tag's
|
|
89
|
+
// native role" principle this check already applies to the PARENT
|
|
90
|
+
// (see the header comment). An <li role="tab">/
|
|
91
|
+
// role="menuitem">/role="presentation"> etc. is exposed to AT with
|
|
92
|
+
// THAT role, never "listitem" -- so the "list item needs a valid list
|
|
93
|
+
// container parent" concern this rule exists for doesn't apply to it
|
|
94
|
+
// at all; there's no listitem semantics being claimed to validate.
|
|
95
|
+
// role="listitem" itself is a no-op restatement, not an override, so
|
|
96
|
+
// it still falls through to the normal parent check below.
|
|
97
|
+
const ownRoleAttr = el.getAttribute ? String(el.getAttribute('role') || '').trim() : '';
|
|
98
|
+
const ownExplicitRole = ownRoleAttr ? (ownRoleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
|
|
99
|
+
if (ownExplicitRole && ownExplicitRole !== 'listitem') continue;
|
|
100
|
+
|
|
78
101
|
applicableCount += 1;
|
|
79
102
|
|
|
80
103
|
const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '';
|
|
@@ -85,7 +108,7 @@ function runInPage(ctx) {
|
|
|
85
108
|
let valid;
|
|
86
109
|
if (explicitRole) {
|
|
87
110
|
// An explicit role always wins over the tag's native role, in either
|
|
88
|
-
// direction — see the header comment
|
|
111
|
+
// direction — see the header comment.
|
|
89
112
|
valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
|
|
90
113
|
} else {
|
|
91
114
|
valid = parentTag === 'ul' || parentTag === 'ol';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -24,10 +26,8 @@
|
|
|
24
26
|
* - A <meta> nested inside <noscript> is excluded: it only ever takes
|
|
25
27
|
* effect when scripting is disabled, which is never the case for any
|
|
26
28
|
* context capable of running accessibility tooling in the first place
|
|
27
|
-
* (
|
|
28
|
-
*
|
|
29
|
-
* URL=/?nojsmode=1"></noscript> JS-disabled fallback — a widely-used
|
|
30
|
-
* reference engine's own same-named check correctly doesn't flag it either).
|
|
29
|
+
* (e.g. a <noscript><meta http-equiv="refresh" content="0; URL=/?nojs=1">
|
|
30
|
+
* </noscript> JS-disabled fallback).
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
const id = 'meta-refresh-no-exceptions';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
const id = 'meter-name-present';
|
|
@@ -96,9 +98,8 @@ function runInPage(ctx) {
|
|
|
96
98
|
const title = getAttr(el, 'title');
|
|
97
99
|
if (title) return { ok: true, method: 'title' };
|
|
98
100
|
|
|
99
|
-
// role="meter" is name-from-author-only per WAI-ARIA
|
|
100
|
-
//
|
|
101
|
-
// 'aria-labelledby', title] — no content-based naming method at all).
|
|
101
|
+
// role="meter" is name-from-author-only per WAI-ARIA: aria-label,
|
|
102
|
+
// aria-labelledby, or title — no content-based naming method at all.
|
|
102
103
|
// It must NOT fall back to subtree content — visible text near/inside a
|
|
103
104
|
// custom meter widget is not reliably exposed as its accessible name.
|
|
104
105
|
return { ok: false, method: 'none' };
|