@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
|
/**
|
|
@@ -194,6 +196,163 @@ function runInPage(ctx) {
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
|
|
199
|
+
function getDeepActiveElement(docRef) {
|
|
200
|
+
let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
|
|
201
|
+
let guard = 0;
|
|
202
|
+
while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
|
|
203
|
+
cur = cur.shadowRoot.activeElement;
|
|
204
|
+
}
|
|
205
|
+
return cur;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function focusElementSafe(el) {
|
|
209
|
+
if (!el || typeof el.focus !== 'function') return false;
|
|
210
|
+
try {
|
|
211
|
+
el.focus({ preventScroll: true });
|
|
212
|
+
return true;
|
|
213
|
+
} catch {
|
|
214
|
+
try {
|
|
215
|
+
el.focus();
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function runFocusObservationWindow(docRef, fn) {
|
|
224
|
+
const w =
|
|
225
|
+
docRef && docRef.defaultView
|
|
226
|
+
? docRef.defaultView
|
|
227
|
+
: typeof window !== 'undefined'
|
|
228
|
+
? window
|
|
229
|
+
: null;
|
|
230
|
+
if (!w || typeof fn !== 'function') return;
|
|
231
|
+
|
|
232
|
+
const originalSetTimeout = typeof w.setTimeout === 'function' ? w.setTimeout.bind(w) : null;
|
|
233
|
+
const originalRequestAnimationFrame =
|
|
234
|
+
typeof w.requestAnimationFrame === 'function' ? w.requestAnimationFrame.bind(w) : null;
|
|
235
|
+
const originalQueueMicrotask =
|
|
236
|
+
typeof w.queueMicrotask === 'function' ? w.queueMicrotask.bind(w) : null;
|
|
237
|
+
|
|
238
|
+
const queuedMicrotasks = [];
|
|
239
|
+
const queuedRaf = [];
|
|
240
|
+
const queuedTimers = [];
|
|
241
|
+
let fakeTimerId = 1;
|
|
242
|
+
|
|
243
|
+
const patchedSetTimeout = function (cb, delay) {
|
|
244
|
+
const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
|
|
245
|
+
if (typeof cb === 'function' && d <= 200) {
|
|
246
|
+
const args = [];
|
|
247
|
+
for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
|
|
248
|
+
queuedTimers.push({ delay: d, cb: () => cb.apply(w, args) });
|
|
249
|
+
return fakeTimerId++;
|
|
250
|
+
}
|
|
251
|
+
if (originalSetTimeout) return originalSetTimeout.apply(w, arguments);
|
|
252
|
+
return fakeTimerId++;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const patchedRaf = function (cb) {
|
|
256
|
+
if (typeof cb === 'function') {
|
|
257
|
+
queuedRaf.push(cb);
|
|
258
|
+
return fakeTimerId++;
|
|
259
|
+
}
|
|
260
|
+
if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(w, arguments);
|
|
261
|
+
return fakeTimerId++;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const patchedQueueMicrotask = function (cb) {
|
|
265
|
+
if (typeof cb === 'function') queuedMicrotasks.push(cb);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
if (originalSetTimeout) w.setTimeout = patchedSetTimeout;
|
|
270
|
+
if (originalRequestAnimationFrame) w.requestAnimationFrame = patchedRaf;
|
|
271
|
+
if (originalQueueMicrotask) w.queueMicrotask = patchedQueueMicrotask;
|
|
272
|
+
fn();
|
|
273
|
+
|
|
274
|
+
let guard = 0;
|
|
275
|
+
while (
|
|
276
|
+
(queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
|
|
277
|
+
guard++ < 100
|
|
278
|
+
) {
|
|
279
|
+
while (queuedMicrotasks.length) {
|
|
280
|
+
const mt = queuedMicrotasks.shift();
|
|
281
|
+
try {
|
|
282
|
+
mt();
|
|
283
|
+
} catch {}
|
|
284
|
+
}
|
|
285
|
+
while (queuedRaf.length) {
|
|
286
|
+
const rf = queuedRaf.shift();
|
|
287
|
+
try {
|
|
288
|
+
rf(16);
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
if (queuedTimers.length) {
|
|
292
|
+
queuedTimers.sort((a, b) => a.delay - b.delay);
|
|
293
|
+
const tt = queuedTimers.shift();
|
|
294
|
+
try {
|
|
295
|
+
tt.cb();
|
|
296
|
+
} catch {}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
if (originalSetTimeout) w.setTimeout = originalSetTimeout;
|
|
301
|
+
if (originalRequestAnimationFrame) w.requestAnimationFrame = originalRequestAnimationFrame;
|
|
302
|
+
if (originalQueueMicrotask) w.queueMicrotask = originalQueueMicrotask;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function probeImmediateFocusRedirect(candidate) {
|
|
307
|
+
if (!candidate || typeof candidate.addEventListener !== 'function') return null;
|
|
308
|
+
|
|
309
|
+
let focusedByEvent = false;
|
|
310
|
+
const onFocusCapture = () => {
|
|
311
|
+
focusedByEvent = true;
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
candidate.addEventListener('focus', onFocusCapture, true);
|
|
315
|
+
} catch {}
|
|
316
|
+
|
|
317
|
+
const before = getDeepActiveElement(document);
|
|
318
|
+
let focused = false;
|
|
319
|
+
runFocusObservationWindow(document, () => {
|
|
320
|
+
focused = focusElementSafe(candidate);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
candidate.removeEventListener('focus', onFocusCapture, true);
|
|
325
|
+
} catch {}
|
|
326
|
+
if (!focused || !focusedByEvent) return null;
|
|
327
|
+
|
|
328
|
+
const after = getDeepActiveElement(document);
|
|
329
|
+
|
|
330
|
+
if (before && before !== after) {
|
|
331
|
+
focusElementSafe(before);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!after || after === candidate) return null;
|
|
335
|
+
const redirectedTag = (() => {
|
|
336
|
+
try {
|
|
337
|
+
return lower(after.tagName || '');
|
|
338
|
+
} catch {
|
|
339
|
+
return '';
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
342
|
+
const redirectedId = (() => {
|
|
343
|
+
try {
|
|
344
|
+
return trim(after.getAttribute && after.getAttribute('id'));
|
|
345
|
+
} catch {
|
|
346
|
+
return '';
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
return {
|
|
350
|
+
redirected: true,
|
|
351
|
+
redirectedToTag: redirectedTag || null,
|
|
352
|
+
redirectedToId: redirectedId || null
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
197
356
|
function isTabbable(el, info) {
|
|
198
357
|
const f = info || getFocusableInfoSafe(el);
|
|
199
358
|
return !!(f && f.focusable && f.tabbable);
|
|
@@ -226,6 +385,8 @@ function runInPage(ctx) {
|
|
|
226
385
|
}
|
|
227
386
|
|
|
228
387
|
const occurrences = [];
|
|
388
|
+
const maxRuntimeProbeCount = 3;
|
|
389
|
+
let runtimeProbeCount = 0;
|
|
229
390
|
|
|
230
391
|
for (const el of candidates) {
|
|
231
392
|
if (!el || !el.getAttribute) continue;
|
|
@@ -251,18 +412,32 @@ function runInPage(ctx) {
|
|
|
251
412
|
const hintsArr = [];
|
|
252
413
|
for (const k of hintOrder) if (hints.includes(k)) hintsArr.push(k);
|
|
253
414
|
|
|
415
|
+
const runtimeProbe =
|
|
416
|
+
runtimeProbeCount < maxRuntimeProbeCount ? probeImmediateFocusRedirect(el) : null;
|
|
417
|
+
if (runtimeProbeCount < maxRuntimeProbeCount) runtimeProbeCount += 1;
|
|
418
|
+
const downgradedToRedirectReview = !!(runtimeProbe && runtimeProbe.redirected);
|
|
419
|
+
|
|
254
420
|
const baseOccurrence = {
|
|
255
|
-
summary:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
421
|
+
summary: downgradedToRedirectReview
|
|
422
|
+
? `Focusable ${tagName} appears visually hidden but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`
|
|
423
|
+
: `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
|
|
424
|
+
hint: downgradedToRedirectReview
|
|
425
|
+
? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on visually hidden focus targets.'
|
|
426
|
+
: 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
|
|
427
|
+
i18n: downgradedToRedirectReview
|
|
428
|
+
? null
|
|
429
|
+
: {
|
|
430
|
+
summaryKey: 'cssHidden_focus_summary_cantTell',
|
|
431
|
+
hintKey: 'cssHidden_focus_hint_cantTell',
|
|
432
|
+
params: { element: tagName, visibilityHints: hintsArr.join(',') }
|
|
433
|
+
},
|
|
262
434
|
data: {
|
|
263
435
|
details: {
|
|
264
|
-
reasonCode:
|
|
265
|
-
|
|
436
|
+
reasonCode: downgradedToRedirectReview
|
|
437
|
+
? 'cssHiddenTabbable_runtimeRedirect_needsReview'
|
|
438
|
+
: 'cssHiddenTabbable_needsFocusStateVerification',
|
|
439
|
+
metrics: { visibilityHints: hintsArr.slice(0) },
|
|
440
|
+
runtimeProbe: runtimeProbe || null
|
|
266
441
|
}
|
|
267
442
|
}
|
|
268
443
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -154,23 +156,26 @@ function runInPage(ctx) {
|
|
|
154
156
|
|
|
155
157
|
// Only resolve IDREF text if aria-labelledby is present and aria-label is not already sufficient
|
|
156
158
|
let labelledByText = '';
|
|
157
|
-
let hasLabelledByMechanism = false;
|
|
158
|
-
|
|
159
|
-
if (!ariaLabel && ariaLabelledBy) {
|
|
160
|
-
hasLabelledByMechanism = true;
|
|
161
159
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
labelledByText = '';
|
|
169
|
-
}
|
|
160
|
+
if (!ariaLabel && ariaLabelledBy && getTextFromIdRefs) {
|
|
161
|
+
try {
|
|
162
|
+
const t = getTextFromIdRefs(ariaLabelledBy, ctx);
|
|
163
|
+
labelledByText = trim(t && t.text);
|
|
164
|
+
} catch {
|
|
165
|
+
labelledByText = '';
|
|
170
166
|
}
|
|
171
167
|
}
|
|
172
168
|
|
|
173
|
-
|
|
169
|
+
// A broken/empty-resolving aria-labelledby (e.g. pointing at a
|
|
170
|
+
// nonexistent id) is not a "detected" text alternative to review the
|
|
171
|
+
// QUALITY of -- there's no text here at all, and this element's
|
|
172
|
+
// sibling automatic rule (embed-text-alternative-present) already
|
|
173
|
+
// reports it as a fail (no accessible name). Like object-/svg-/
|
|
174
|
+
// canvas-text-alternative-quality-manual, require aria-labelledby to
|
|
175
|
+
// actually resolve to non-empty text rather than merely being present
|
|
176
|
+
// as an attribute; a present-but-broken aria-labelledby has no text
|
|
177
|
+
// alternative to review.
|
|
178
|
+
const hasNameMechanism = !!(ariaLabel || title || labelledByText);
|
|
174
179
|
if (!hasNameMechanism) continue;
|
|
175
180
|
|
|
176
181
|
const details = {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check empty-heading
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Heading elements must not be empty
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to elements with a heading role: native <h1>-<h6>, or any
|
|
10
12
|
* element with explicit role="heading" (unless overridden by another
|
|
@@ -19,23 +21,21 @@
|
|
|
19
21
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
20
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
23
|
* header comment for the shared rationale/precedent.
|
|
22
|
-
* - This is also the reconciliation point for the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* reach was still flagged.
|
|
31
|
-
* - 2026-07-21: the subtree-text walker's own hand-rolled descendant
|
|
32
|
-
* handling (aria-label/aria-labelledby only) never checked an `<img>`
|
|
33
|
-
* descendant's `alt` text — found via Party City's
|
|
34
|
-
* `<h1><a><div><img alt="..."></div></a></h1>` logo header, a false
|
|
35
|
-
* "empty heading" cantTell. Replaced with the shared, accname-aligned
|
|
24
|
+
* - This is also the reconciliation point for the ACT-rules
|
|
25
|
+
* "heading-name-present" requirement (see ROADMAP.md "Tier 5
|
|
26
|
+
* candidates"): already covered by this pre-existing rule under a
|
|
27
|
+
* different name, not a separate gap. `title` is accepted as a naming
|
|
28
|
+
* fallback, and hidden/aria-hidden/display:none headings are excluded
|
|
29
|
+
* (gated on `isAccTreeEligible`), so an empty heading no AT user could
|
|
30
|
+
* ever reach is not flagged.
|
|
31
|
+
* - Descendant name resolution uses the shared, accname-aligned
|
|
36
32
|
* `helpers.getContentNameInfo` (see dom-helpers.js) — the same "name
|
|
37
|
-
* from content" implementation the 19 `-name-present` rules already
|
|
38
|
-
* —
|
|
33
|
+
* from content" implementation the 19 `-name-present` rules already
|
|
34
|
+
* use — rather than a narrower hand-rolled walker, so an `<img alt="...">`
|
|
35
|
+
* descendant's alt text (e.g. a
|
|
36
|
+
* `<h1><a><div><img alt="..."></div></a></h1>` logo header) is correctly
|
|
37
|
+
* picked up as the heading's name instead of producing a false "empty
|
|
38
|
+
* heading" cantTell.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
const id = 'empty-heading';
|
|
@@ -1,36 +1,30 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check empty-table-header
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Table header cells must not be empty
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to <th> elements that don't carry a conflicting explicit role,
|
|
10
12
|
* plus any element (native <th> or not) with role="columnheader" or
|
|
11
|
-
* role="rowheader"
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* role="columnheader"> in a role="grid"/role="table" widget) is caught too
|
|
18
|
-
* — found missing entirely via the cross-engine diff tool 2026-07-23.
|
|
13
|
+
* role="rowheader" (`th:not([role]), [role="columnheader"], [role="rowheader"]`):
|
|
14
|
+
* a <th> that explicitly restates role="columnheader"/"rowheader" is still
|
|
15
|
+
* covered via the second clause, but a <th role="presentation"> (no longer
|
|
16
|
+
* meaningfully a header) is correctly excluded, and an ARIA-role-only header
|
|
17
|
+
* (e.g. a <div role="columnheader"> in a role="grid"/role="table" widget) is
|
|
18
|
+
* caught too.
|
|
19
19
|
* @expectation
|
|
20
20
|
* The header cell has visible text content. A <th> named only via
|
|
21
21
|
* aria-label/aria-labelledby (no visible text) is ALSO flagged, not
|
|
22
|
-
* treated as equivalent —
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* (only visible text is announced), JAWS+Chrome/IE11 also only announce
|
|
29
|
-
* visible text in the header cell itself. Visible text is the one
|
|
30
|
-
* mechanism confirmed to work across every tested combination. A widely-used
|
|
31
|
-
* reference engine's own equivalent check (`has-visible-text` only, no aria-label/
|
|
32
|
-
* aria-labelledby alternative — confirmed directly against its rule
|
|
33
|
-
* descriptor) reaches the same conclusion.
|
|
22
|
+
* treated as equivalent — aria-label support on <th> is genuinely
|
|
23
|
+
* inconsistent in practice: NVDA+Firefox and iOS VoiceOver+Safari ignore
|
|
24
|
+
* it entirely (only visible text is announced), JAWS+Chrome/IE11 also only
|
|
25
|
+
* announce visible text in the header cell itself. Visible text is the one
|
|
26
|
+
* mechanism confirmed to work across every tested combination. See
|
|
27
|
+
* https://html5accessibility.com/stuff/2024/05/22/not-so-short-note-on-aria-label-usage-big-table-edition/.
|
|
34
28
|
* @implementation-notes
|
|
35
29
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
36
30
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
@@ -72,8 +66,36 @@ function runInPage(ctx) {
|
|
|
72
66
|
.trim();
|
|
73
67
|
}
|
|
74
68
|
|
|
69
|
+
const isAccTreeEligible =
|
|
70
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
71
|
+
|
|
72
|
+
function isEligible(node) {
|
|
73
|
+
if (!isAccTreeEligible) return true;
|
|
74
|
+
try {
|
|
75
|
+
const r = isAccTreeEligible(node, ctx);
|
|
76
|
+
return !!(r && r.eligible);
|
|
77
|
+
} catch {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Plain el.textContent includes text from aria-hidden descendants, which
|
|
83
|
+
// a real screen reader never announces. A <th> whose only text comes from
|
|
84
|
+
// an aria-hidden descendant (e.g. <th><span aria-hidden="true">Name</span
|
|
85
|
+
// ></th>) would otherwise be treated as having visible text, even though
|
|
86
|
+
// AT announces nothing for it.
|
|
75
87
|
function getVisibleText(el) {
|
|
76
|
-
|
|
88
|
+
function walk(node) {
|
|
89
|
+
if (node.nodeType === 3) return node.nodeValue || '';
|
|
90
|
+
if (node.nodeType !== 1) return '';
|
|
91
|
+
if (!isEligible(node)) return '';
|
|
92
|
+
let text = '';
|
|
93
|
+
for (const child of node.childNodes || []) text += walk(child);
|
|
94
|
+
return text;
|
|
95
|
+
}
|
|
96
|
+
let text = '';
|
|
97
|
+
for (const child of el.childNodes || []) text += walk(child);
|
|
98
|
+
return normalizeWs(text);
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
function getAriaOnlyName(el) {
|
|
@@ -97,9 +119,8 @@ function runInPage(ctx) {
|
|
|
97
119
|
return '';
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// columnheader/rowheader role (native or not).
|
|
122
|
+
// A <th> with no conflicting explicit role, plus any element carrying an
|
|
123
|
+
// explicit columnheader/rowheader role (native or not).
|
|
103
124
|
const selector = 'th:not([role]), [role="columnheader"], [role="rowheader"]';
|
|
104
125
|
const nodes = helpers.queryAllSmart
|
|
105
126
|
? helpers.queryAllSmart(selector)
|
|
@@ -110,6 +131,12 @@ function runInPage(ctx) {
|
|
|
110
131
|
|
|
111
132
|
for (const el of nodes) {
|
|
112
133
|
if (!el) continue;
|
|
134
|
+
|
|
135
|
+
// A fully aria-hidden header cell isn't part of the AT-perceived
|
|
136
|
+
// table structure at all -- queryAllSmart's default hidden-content
|
|
137
|
+
// policy only excludes "hard" CSS-based hiding, not aria-hidden.
|
|
138
|
+
if (!isEligible(el)) continue;
|
|
139
|
+
|
|
113
140
|
applicableCount += 1;
|
|
114
141
|
|
|
115
142
|
if (getVisibleText(el)) continue;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check focus-order-semantics
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Elements added to the tab order should have interactive semantics
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Elements with an explicit `tabindex` of `0` or greater (in the tab
|
|
10
12
|
* order) AND an explicit `role` attribute that is one of a curated set
|
|
@@ -16,8 +18,8 @@
|
|
|
16
18
|
* announce, which is confusing for keyboard users who land on it and
|
|
17
19
|
* get no indication of what activating it (if anything) would do.
|
|
18
20
|
* @implementation-notes
|
|
19
|
-
* - Not WCAG-normative
|
|
20
|
-
*
|
|
21
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
22
|
+
* `type: 'manual'` rule.
|
|
21
23
|
* - The non-interactive role list is deliberately curated and
|
|
22
24
|
* conservative (structural/document roles only) — legitimate custom
|
|
23
25
|
* widget patterns using `tabindex` with a genuinely interactive role
|
|
@@ -25,6 +27,17 @@
|
|
|
25
27
|
* `tabindex` and NO role at all are also not flagged: native semantics
|
|
26
28
|
* or an intentionally generic custom-interactive pattern cannot be
|
|
27
29
|
* distinguished from markup alone with the same confidence.
|
|
30
|
+
* - `region` is deliberately NOT in the non-interactive role list: a
|
|
31
|
+
* tabbable `role="region"` is a real, WCAG 2.1.1/2.1.3-grounded pattern
|
|
32
|
+
* this engine's own `scrollable-region-focusable` check exists to
|
|
33
|
+
* RECOMMEND (a scrollable landmark with no other focusable content
|
|
34
|
+
* needs `tabindex="0"` to be keyboard-reachable at all) — flagging it
|
|
35
|
+
* here would be internally inconsistent with that sibling check. A
|
|
36
|
+
* `role="region"` is also commonly made tabbable on its own merits
|
|
37
|
+
* (e.g. a cookie-consent banner or notification/toast region a keyboard
|
|
38
|
+
* user should be able to reach directly, as in
|
|
39
|
+
* `<div role="region" tabindex="0">`). Scoped to `region` only;
|
|
40
|
+
* navigation/status/tabpanel remain flagged pending their own evidence.
|
|
28
41
|
*/
|
|
29
42
|
|
|
30
43
|
const id = 'focus-order-semantics';
|
|
@@ -59,7 +72,6 @@ function runInPage(ctx) {
|
|
|
59
72
|
'none',
|
|
60
73
|
'img',
|
|
61
74
|
'heading',
|
|
62
|
-
'region',
|
|
63
75
|
'article',
|
|
64
76
|
'banner',
|
|
65
77
|
'contentinfo',
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check heading-order
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Heading levels must not skip a level going deeper
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies whenever the page contains two or more heading elements
|
|
10
12
|
* (native <h1>-<h6>, or explicit role="heading" with aria-level —
|
|
@@ -83,6 +85,31 @@ function runInPage(ctx) {
|
|
|
83
85
|
for (const el of nodes) {
|
|
84
86
|
if (!el || seen.has(el)) continue;
|
|
85
87
|
seen.add(el);
|
|
88
|
+
|
|
89
|
+
// queryAllSmart's default hidden-content policy only excludes "hard"
|
|
90
|
+
// CSS-based hiding (display:none, visibility:hidden, etc.) -- it does
|
|
91
|
+
// NOT exclude aria-hidden, a softer/semantic removal from the
|
|
92
|
+
// accessibility tree that still leaves an element visually rendered.
|
|
93
|
+
// A heading order rule whose whole premise is "the document outline
|
|
94
|
+
// assistive technology users rely on" (see this rule's own header
|
|
95
|
+
// comment) must not let an aria-hidden heading participate in that
|
|
96
|
+
// outline at all: it's invisible to exactly the users this rule
|
|
97
|
+
// exists to protect. Without this check an aria-hidden heading is both
|
|
98
|
+
// flagged itself (though it isn't part of the AT-perceived outline)
|
|
99
|
+
// and can mask a real skip immediately after it, by advancing the
|
|
100
|
+
// "highest level reached so far" tracker on a level no AT user
|
|
101
|
+
// actually encountered.
|
|
102
|
+
if (helpers.isAccTreeEligible) {
|
|
103
|
+
const elig = (() => {
|
|
104
|
+
try {
|
|
105
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
106
|
+
} catch {
|
|
107
|
+
return { eligible: true, reasons: [] };
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
if (elig && elig.eligible === false) continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
const level = getHeadingLevel(el);
|
|
87
114
|
if (level > 0) headings.push({ el, level });
|
|
88
115
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check image-redundant-alt
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary An image's alt text must not duplicate adjacent visible text
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to <img> elements with non-empty alt text whose immediate
|
|
10
12
|
* parent element also has other visible text content (i.e. text nodes
|
|
@@ -57,12 +59,30 @@ function runInPage(ctx) {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function getOwnTextExcludingImg(parent, imgEl) {
|
|
62
|
+
const isAccTreeEligible =
|
|
63
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
64
|
+
|
|
60
65
|
let text = '';
|
|
61
66
|
for (const child of parent.childNodes || []) {
|
|
62
67
|
if (child === imgEl) continue;
|
|
63
68
|
if (child.nodeType === 3) {
|
|
64
69
|
text += ' ' + (child.nodeValue || '');
|
|
65
70
|
} else if (child.nodeType === 1 && child !== imgEl) {
|
|
71
|
+
// An aria-hidden sibling is never actually announced to assistive
|
|
72
|
+
// technology, so its text can't cause the "same words twice"
|
|
73
|
+
// double-announcement this rule exists to catch -- counting it
|
|
74
|
+
// anyway flags a redundancy that doesn't exist in what AT users
|
|
75
|
+
// actually hear.
|
|
76
|
+
if (isAccTreeEligible) {
|
|
77
|
+
const elig = (() => {
|
|
78
|
+
try {
|
|
79
|
+
return isAccTreeEligible(child, ctx);
|
|
80
|
+
} catch {
|
|
81
|
+
return { eligible: true, reasons: [] };
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
if (elig && elig.eligible === false) continue;
|
|
85
|
+
}
|
|
66
86
|
text += ' ' + (child.textContent || '');
|
|
67
87
|
}
|
|
68
88
|
}
|