@surea11y/core 1.3.0 → 1.4.1
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 +87 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/ARIA_DEPRECATION.md +95 -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/docs/RULE_CATALOG.md +6 -6
- package/package.json +52 -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 +663 -134
- 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 +107 -38
- 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 +33 -6
- 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 +39 -1
- package/src/checks/automatic/avoid-inline-spacing.js +181 -20
- package/src/checks/automatic/binary-control-name-present.js +13 -3
- package/src/checks/automatic/button-name-present.js +54 -21
- package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
- package/src/checks/automatic/combobox-name-present.js +10 -1
- 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 +18 -11
- 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 +50 -4
- package/src/checks/automatic/form-control-single-label.js +110 -43
- 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 +23 -18
- package/src/checks/automatic/input-image-alt-present.js +101 -52
- package/src/checks/automatic/label-in-name.js +97 -27
- package/src/checks/automatic/language-page-present.js +7 -1
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +52 -17
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +10 -1
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +10 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
- package/src/checks/automatic/meter-name-present.js +12 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
- package/src/checks/automatic/object-text-alternative-present.js +16 -7
- package/src/checks/automatic/option-name-present.js +10 -1
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +16 -11
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +10 -1
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +13 -2
- package/src/checks/automatic/spinbutton-name-present.js +10 -1
- package/src/checks/automatic/summary-name-present.js +10 -1
- 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 +10 -1
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +69 -6
- 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 +10 -1
- package/src/checks/automatic/tooltip-name-present.js +10 -1
- package/src/checks/automatic/treeitem-name-present.js +10 -1
- package/src/checks/automatic/valid-lang.js +18 -3
- 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/bypass-blocks-present-manual.js +279 -0
- 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 +26 -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 +51 -55
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
- 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 +11820 -3317
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +20 -5
- package/surea11y.browser.js +4943 -1388
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- package/src/checks/automatic/bypass-blocks-present.js +0 -215
- 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
|
/**
|
|
@@ -7,10 +9,10 @@
|
|
|
7
9
|
* @standard WCAG 2.2
|
|
8
10
|
* @sc 1.1.1
|
|
9
11
|
* @applicability
|
|
10
|
-
* Applies to <img> elements
|
|
11
|
-
* Images with role="presentation" or role="none" are excluded only when they are not focusable
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Applies to <img> elements included in the accessibility tree.
|
|
13
|
+
* Images with role="presentation" or role="none" are excluded only when they are not focusable,
|
|
14
|
+
* since a focusable one reverts to the img role under presentational roles conflict resolution.
|
|
15
|
+
* Hidden images are excluded whether or not they are focusable.
|
|
14
16
|
* @expectation
|
|
15
17
|
* Each applicable <img> element has an alt attribute.
|
|
16
18
|
* The alt attribute may be empty (alt="").
|
|
@@ -58,8 +60,15 @@ function runInPage(ctx) {
|
|
|
58
60
|
const getEligibilityInfo =
|
|
59
61
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
// ACT 23a2a8 exempts programmatically hidden images, and that glossary term
|
|
64
|
+
// has no focusability carve-out, so an aria-hidden image stays out of scope
|
|
65
|
+
// even when tabbable; aria-hidden-focus reports that markup instead.
|
|
66
|
+
const isEligibleHelper =
|
|
67
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
68
|
+
? helpers.isIncludedInAccessibilityTree
|
|
69
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
70
|
+
? helpers.isAccTreeEligible
|
|
71
|
+
: null;
|
|
63
72
|
const getFocusableInfo =
|
|
64
73
|
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
65
74
|
const getAriaNameInfo =
|
|
@@ -111,11 +120,11 @@ function runInPage(ctx) {
|
|
|
111
120
|
const el = imgs[i];
|
|
112
121
|
if (!el || !el.getAttribute) continue;
|
|
113
122
|
|
|
114
|
-
// Eligibility: only imgs exposed to assistive tech
|
|
115
|
-
if (
|
|
123
|
+
// Eligibility: only imgs exposed to assistive tech.
|
|
124
|
+
if (isEligibleHelper) {
|
|
116
125
|
let elig;
|
|
117
126
|
try {
|
|
118
|
-
elig =
|
|
127
|
+
elig = isEligibleHelper(el, ctx);
|
|
119
128
|
} catch {
|
|
120
129
|
elig = null;
|
|
121
130
|
}
|
|
@@ -172,15 +181,11 @@ function runInPage(ctx) {
|
|
|
172
181
|
if (ariaName && ariaName.present) continue;
|
|
173
182
|
}
|
|
174
183
|
|
|
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.
|
|
184
|
+
// A non-empty title attribute is HTML-AAM's next fallback naming source
|
|
185
|
+
// for <img> once alt is entirely absent (not merely alt="", which
|
|
186
|
+
// explicitly marks decorative and stays excluded from this branch since
|
|
187
|
+
// hasAlt already short-circuited above). An `<img src="..." title="...">`
|
|
188
|
+
// with no alt attribute at all is not missing a text alternative.
|
|
184
189
|
const title = trim(el.getAttribute('title'));
|
|
185
190
|
if (title) continue;
|
|
186
191
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -7,11 +9,10 @@
|
|
|
7
9
|
* @standard WCAG 2.2
|
|
8
10
|
* @sc 1.1.1
|
|
9
11
|
* @applicability
|
|
10
|
-
* Applies to <input type="image"> elements
|
|
11
|
-
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
12
|
-
* if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
|
|
12
|
+
* Applies to <input type="image"> elements included in the accessibility tree.
|
|
13
13
|
* @expectation
|
|
14
|
-
* Each applicable <input type="image"> element has an alt attribute
|
|
14
|
+
* Each applicable <input type="image"> element has an alt attribute, and its
|
|
15
|
+
* accessible name is not the browser default for an image button.
|
|
15
16
|
* The alt attribute may be empty (alt="").
|
|
16
17
|
*/
|
|
17
18
|
|
|
@@ -70,8 +71,14 @@ function runInPage(ctx) {
|
|
|
70
71
|
const getEligibilityInfo =
|
|
71
72
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
// ACT 59796f applies to image buttons included in the accessibility tree,
|
|
75
|
+
// which excludes focusable content inside aria-hidden.
|
|
76
|
+
const isEligibleHelper =
|
|
77
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
78
|
+
? helpers.isIncludedInAccessibilityTree
|
|
79
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
80
|
+
? helpers.isAccTreeEligible
|
|
81
|
+
: null;
|
|
75
82
|
|
|
76
83
|
const getAriaNameInfo =
|
|
77
84
|
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
@@ -97,67 +104,109 @@ function runInPage(ctx) {
|
|
|
97
104
|
for (const el of inputs) {
|
|
98
105
|
if (!el || !el.getAttribute) continue;
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
if (isAccTreeEligible) {
|
|
107
|
+
if (isEligibleHelper) {
|
|
102
108
|
const elig = (() => {
|
|
103
109
|
try {
|
|
104
|
-
return
|
|
110
|
+
return isEligibleHelper(el, ctx);
|
|
105
111
|
} catch {
|
|
106
|
-
return
|
|
112
|
+
return true;
|
|
107
113
|
}
|
|
108
114
|
})();
|
|
109
|
-
|
|
115
|
+
const eligible = typeof elig === 'boolean' ? elig : !(elig && elig.eligible === false);
|
|
116
|
+
if (!eligible) continue;
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
applicableCount += 1;
|
|
113
120
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
// The browser's own fallback name for an image button carries no
|
|
122
|
+
// information, so an author-supplied name equal to it is treated as no
|
|
123
|
+
// name at all (ACT 59796f). Only the English defaults are recognised:
|
|
124
|
+
// "Submit Query" from HTML-AAM, "Submit" from Chrome.
|
|
125
|
+
const effectiveName = (() => {
|
|
126
|
+
let v = '';
|
|
127
|
+
if (getAriaNameInfo) {
|
|
128
|
+
try {
|
|
129
|
+
const aria = getAriaNameInfo(el, ctx);
|
|
130
|
+
if (aria && aria.present && aria.value) v = String(aria.value);
|
|
131
|
+
} catch {
|
|
132
|
+
v = '';
|
|
133
|
+
}
|
|
127
134
|
}
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
138
|
-
const titleRaw = (() => {
|
|
139
|
-
try {
|
|
140
|
-
return el.getAttribute('title');
|
|
141
|
-
} catch {
|
|
142
|
-
return null;
|
|
135
|
+
if (!v) {
|
|
136
|
+
const alt = el.getAttribute('alt');
|
|
137
|
+
if (alt != null && String(alt).trim()) v = String(alt);
|
|
138
|
+
}
|
|
139
|
+
if (!v) {
|
|
140
|
+
const t = el.getAttribute('title');
|
|
141
|
+
if (t != null && String(t).trim()) v = String(t);
|
|
143
142
|
}
|
|
143
|
+
return v.trim().toLowerCase();
|
|
144
144
|
})();
|
|
145
|
-
|
|
145
|
+
|
|
146
|
+
if (effectiveName === 'submit query' || effectiveName === 'submit') {
|
|
147
|
+
const eligInfoDefault = getEligibilityInfo
|
|
148
|
+
? getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
149
|
+
: null;
|
|
150
|
+
const defaultNameOccurrence = {
|
|
151
|
+
summary:
|
|
152
|
+
'Accessible name is the browser default for an image button, which conveys nothing.',
|
|
153
|
+
hint: 'Replace it with text describing what the button does, for example "Search".',
|
|
154
|
+
i18n: {
|
|
155
|
+
summaryKey: 'inputImage_altPresent_summary_defaultName',
|
|
156
|
+
hintKey: 'inputImage_altPresent_hint_defaultName',
|
|
157
|
+
params: { element: 'input[type=image]' }
|
|
158
|
+
},
|
|
159
|
+
data: {
|
|
160
|
+
visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
161
|
+
details: { reasonCode: 'default_name' }
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
occurrences.push(
|
|
165
|
+
helpers && typeof helpers.reportOccurrence === 'function'
|
|
166
|
+
? helpers.reportOccurrence(el, defaultNameOccurrence)
|
|
167
|
+
: { selector: '', html: '', ...defaultNameOccurrence }
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// effectiveName already covers the naming sources HTML-AAM allows here,
|
|
173
|
+
// in order: aria-label/aria-labelledby, then alt, then title.
|
|
174
|
+
if (effectiveName) continue;
|
|
175
|
+
|
|
176
|
+
// An image button is a control, so an empty name fails whether alt is
|
|
177
|
+
// absent or present-but-empty. alt="" marks a decorative image, and an
|
|
178
|
+
// image button is never decorative.
|
|
179
|
+
const emptyAlt = el.getAttribute('alt') !== null;
|
|
146
180
|
|
|
147
181
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
148
182
|
|
|
149
|
-
const baseOccurrence =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
183
|
+
const baseOccurrence = emptyAlt
|
|
184
|
+
? {
|
|
185
|
+
summary: 'Empty alt="" on <input type="image"> leaves the control unnamed.',
|
|
186
|
+
hint: 'Describe the action in alt, or name the control with aria-label or aria-labelledby.',
|
|
187
|
+
i18n: {
|
|
188
|
+
summaryKey: 'inputImage_altPresent_summary_emptyAlt',
|
|
189
|
+
hintKey: 'inputImage_altPresent_hint_emptyAlt',
|
|
190
|
+
params: { element: 'input[type=image]' }
|
|
191
|
+
},
|
|
192
|
+
data: {
|
|
193
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
194
|
+
details: { reasonCode: 'empty_alt' }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
: {
|
|
198
|
+
summary: 'Missing alt attribute on <input type="image">.',
|
|
199
|
+
hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
|
|
200
|
+
i18n: {
|
|
201
|
+
summaryKey: 'inputImage_altPresent_summary_fail',
|
|
202
|
+
hintKey: 'inputImage_altPresent_hint_fail',
|
|
203
|
+
params: { element: 'input[type=image]' }
|
|
204
|
+
},
|
|
205
|
+
data: {
|
|
206
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
207
|
+
details: { reasonCode: 'missing_alt' }
|
|
208
|
+
}
|
|
209
|
+
};
|
|
161
210
|
|
|
162
211
|
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
163
212
|
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|