pour-engine 1.40.0 → 1.41.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/config/project.config.js +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +69 -63
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +5 -2
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-form-label.js +5 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +8 -2
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +161 -45
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- package/package.json +1 -1
|
@@ -213,14 +213,13 @@ function isInTextLine(element) {
|
|
|
213
213
|
// do not apply now are skipped, since they size nothing now.
|
|
214
214
|
const NATIVE_CONTROL = /^(button|input|select|textarea)$/i;
|
|
215
215
|
const SIZING = /^(width|height|(min|max)-(width|height|inline-size|block-size)|inline-size|block-size|padding(-.+)?|border(-.+)?|font(-.+)?|line-height|box-sizing|transform|scale|zoom|appearance|all)$/;
|
|
216
|
-
const sizingRulesByRoot = new WeakMap();
|
|
217
216
|
|
|
218
217
|
function declaresSizing(style) {
|
|
219
218
|
for (let k = 0; k < style.length; k++) if (SIZING.test(style[k])) return true;
|
|
220
219
|
return false;
|
|
221
220
|
}
|
|
222
221
|
|
|
223
|
-
function authorSizingRules(root) {
|
|
222
|
+
function authorSizingRules(root, sizingRulesByRoot) {
|
|
224
223
|
const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
|
|
225
224
|
const key = sheets.map((s) => { try { return s.cssRules.length; } catch { return 'x'; } }).join(',');
|
|
226
225
|
const cached = sizingRulesByRoot.get(root);
|
|
@@ -254,10 +253,10 @@ function authorSizingRules(root) {
|
|
|
254
253
|
/** 'yes' when the browser provably sized this native control, 'no' when the
|
|
255
254
|
* author touched its box (or it is not a native control), 'open' when a
|
|
256
255
|
* sheet or selector could not be read. */
|
|
257
|
-
function uaSized(element) {
|
|
256
|
+
function uaSized(element, sizingRulesByRoot) {
|
|
258
257
|
if (!NATIVE_CONTROL.test(element.tagName) || element.type === 'image') return 'no';
|
|
259
258
|
if (declaresSizing(element.style)) return 'no';
|
|
260
|
-
const entry = authorSizingRules(element.getRootNode());
|
|
259
|
+
const entry = authorSizingRules(element.getRootNode(), sizingRulesByRoot);
|
|
261
260
|
if (entry.combined && element.matches(entry.combined)) return 'no';
|
|
262
261
|
return entry.open ? 'open' : 'yes';
|
|
263
262
|
}
|
|
@@ -301,7 +300,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
301
300
|
selector: 'button, a[href], input:not([type="hidden"]), select, [role="button"], [role="link"]',
|
|
302
301
|
visibility: 'visual', // pointer targets are visual regardless of aria-hidden
|
|
303
302
|
// Judged as a set: the spacing exception needs the other targets' positions.
|
|
304
|
-
evaluateAll(elements) {
|
|
303
|
+
async evaluateAll(elements, helpers = {}) {
|
|
304
|
+
// Styles can change between audits without changing a sheet's rule
|
|
305
|
+
// count. This cache belongs to this evaluation, not the page lifetime.
|
|
306
|
+
const sizingRulesByRoot = new WeakMap();
|
|
307
|
+
const yieldToMain = helpers.yieldToMain ?? (() => Promise.resolve());
|
|
305
308
|
// A control's <label> is part of its activation area — clicking the
|
|
306
309
|
// label activates the control, so the TARGET is the union of both
|
|
307
310
|
// boxes. Without this, every label-wrapped checkbox list fails on the
|
|
@@ -323,7 +326,11 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
323
326
|
const uaControlled = (element) => element.tagName === 'INPUT'
|
|
324
327
|
&& (element.type === 'checkbox' || element.type === 'radio')
|
|
325
328
|
&& getComputedStyle(element).appearance !== 'none';
|
|
326
|
-
const rects =
|
|
329
|
+
const rects = [];
|
|
330
|
+
for (let i = 0; i < elements.length; i++) {
|
|
331
|
+
if (i % 64 === 0) await yieldToMain();
|
|
332
|
+
rects.push(targetRect(elements[i]));
|
|
333
|
+
}
|
|
327
334
|
const centers = rects.map((r) => ({ x: r.left + r.width / 2, y: r.top + r.height / 2 }));
|
|
328
335
|
// Zero-area rects are degenerate: either not laid out, or an empty
|
|
329
336
|
// control no pointer can hit at all — its real defect (an empty name,
|
|
@@ -352,9 +359,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
352
359
|
// judged nor counted as crowders (bbc.com/news keeps 28 controls of a
|
|
353
360
|
// closed menu in an inert subtree; the browser ignores every click on
|
|
354
361
|
// them, and so must the size judgment).
|
|
355
|
-
const laidOut =
|
|
356
|
-
|
|
357
|
-
|
|
362
|
+
const laidOut = [];
|
|
363
|
+
for (let i = 0; i < elements.length; i++) {
|
|
364
|
+
if (i % 64 === 0) await yieldToMain();
|
|
365
|
+
const r = rects[i], element = elements[i];
|
|
366
|
+
laidOut.push(r.width > 0 && r.height > 0 && !element.matches(':disabled') && !isInert(element)
|
|
367
|
+
&& !isHiddenFromPointer(element, r) && !layoutSkipped(element));
|
|
368
|
+
}
|
|
358
369
|
const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
|
|
359
370
|
// One rect fully inside another is one control drawn twice (stretched-
|
|
360
371
|
// link cards, overlay + inner button) — not two targets crowding.
|
|
@@ -434,9 +445,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
434
445
|
}
|
|
435
446
|
return false; // neither reachable there — a third element's problem, not j's
|
|
436
447
|
};
|
|
437
|
-
/**
|
|
438
|
-
*
|
|
439
|
-
*
|
|
448
|
+
/** All remaining rectangular areas. The largest area is not necessarily
|
|
449
|
+
* the one that contains the required square: 100x23 is larger than
|
|
450
|
+
* 24x50 but only the latter satisfies the AA size requirement.
|
|
451
|
+
* Keep the alternatives until every obscurer has been subtracted. */
|
|
440
452
|
const uncovered = (rect, overlap) => {
|
|
441
453
|
const slabs = [
|
|
442
454
|
{ left: rect.left, top: rect.top, right: overlap.left, bottom: rect.bottom },
|
|
@@ -445,20 +457,20 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
445
457
|
{ left: rect.left, top: overlap.bottom, right: rect.right, bottom: rect.bottom },
|
|
446
458
|
].map((s) => ({ ...s, width: s.right - s.left, height: s.bottom - s.top }))
|
|
447
459
|
.filter((s) => s.width >= 1 && s.height >= 1);
|
|
448
|
-
|
|
449
|
-
// spread — the zero-rect is built from explicit reads.
|
|
450
|
-
if (!slabs.length) {
|
|
451
|
-
return { left: rect.left, top: rect.top, right: rect.left, bottom: rect.top, width: 0, height: 0 };
|
|
452
|
-
}
|
|
453
|
-
return slabs.reduce((best, s) => (s.width * s.height > best.width * best.height ? s : best));
|
|
460
|
+
return slabs;
|
|
454
461
|
};
|
|
455
462
|
// Spatial hash so finding overlap candidates stays near-linear on pages
|
|
456
463
|
// with tens of thousands of targets: only targets sharing a cell can
|
|
457
464
|
// intersect, so each target is tested against its cell-mates only.
|
|
458
465
|
const CELL = 256;
|
|
459
|
-
|
|
466
|
+
const cellIndex = new Map();
|
|
467
|
+
const wideTargets = [];
|
|
460
468
|
const cellsOf = (r) => {
|
|
461
469
|
const keys = [];
|
|
470
|
+
// Huge transformed boxes must not allocate millions of cells. They
|
|
471
|
+
// remain in a separate candidate list, so the bound drops no target.
|
|
472
|
+
if ((Math.floor(r.right / CELL) - Math.floor(r.left / CELL) + 1)
|
|
473
|
+
* (Math.floor(r.bottom / CELL) - Math.floor(r.top / CELL) + 1) > 2048) return null;
|
|
462
474
|
for (let cx = Math.floor(r.left / CELL); cx <= Math.floor(r.right / CELL); cx++) {
|
|
463
475
|
for (let cy = Math.floor(r.top / CELL); cy <= Math.floor(r.bottom / CELL); cy++) {
|
|
464
476
|
keys.push(`${cx}:${cy}`);
|
|
@@ -466,19 +478,21 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
466
478
|
}
|
|
467
479
|
return keys;
|
|
468
480
|
};
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
});
|
|
481
|
+
for (let j = 0; j < rects.length; j++) {
|
|
482
|
+
if (j % 64 === 0) await yieldToMain();
|
|
483
|
+
if (!laidOut[j]) continue;
|
|
484
|
+
const keys = cellsOf(rects[j]);
|
|
485
|
+
if (!keys) { wideTargets.push(j); continue; }
|
|
486
|
+
for (const key of keys) {
|
|
487
|
+
if (!cellIndex.has(key)) cellIndex.set(key, []);
|
|
488
|
+
cellIndex.get(key).push(j);
|
|
479
489
|
}
|
|
480
|
-
|
|
481
|
-
|
|
490
|
+
}
|
|
491
|
+
const nearbyCandidates = (i, box = rects[i]) => {
|
|
492
|
+
const keys = cellsOf(box);
|
|
493
|
+
if (!keys) return elements.map((_, j) => j).filter((j) => j !== i && laidOut[j]);
|
|
494
|
+
const seen = new Set(wideTargets.filter((j) => j !== i));
|
|
495
|
+
for (const key of keys) {
|
|
482
496
|
for (const j of cellIndex.get(key) ?? []) if (j !== i) seen.add(j);
|
|
483
497
|
}
|
|
484
498
|
return seen;
|
|
@@ -486,9 +500,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
486
500
|
/** The pointer-accepting rect of a box-sized-fine target, reduced by
|
|
487
501
|
* every overlapping target painted above it — or null when nothing
|
|
488
502
|
* qualifying overlaps. */
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
503
|
+
const obscuredRects = new Map();
|
|
504
|
+
const calculateObscuredRect = (i) => {
|
|
505
|
+
let areas = null;
|
|
506
|
+
for (const j of nearbyCandidates(i)) {
|
|
492
507
|
if (elements[j].contains(elements[i]) || elements[i].contains(elements[j])) continue; // same control, nested markup
|
|
493
508
|
if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) continue; // one control drawn twice
|
|
494
509
|
// Two links to the same place are one control in two boxes (a
|
|
@@ -496,13 +511,28 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
496
511
|
// land on the same destination, which is the criterion's
|
|
497
512
|
// "equivalent" case, provable here because the hrefs match.
|
|
498
513
|
if (typeof elements[i].href === 'string' && elements[i].href === elements[j].href) continue;
|
|
499
|
-
const overlap = overlapOf(
|
|
514
|
+
const overlap = overlapOf(rects[i], rects[j]);
|
|
500
515
|
if (!overlap) continue;
|
|
501
516
|
if (fixedContextOf(elements[j]) !== fixedContextOf(elements[i])) continue; // scroll-state, not layout
|
|
502
517
|
if (!paintsOver(j, i, overlap)) continue;
|
|
503
|
-
|
|
518
|
+
const next = (areas ?? [rects[i]]).flatMap((area) => {
|
|
519
|
+
const cut = overlapOf(area, rects[j]);
|
|
520
|
+
return cut ? uncovered(area, cut) : [area];
|
|
521
|
+
});
|
|
522
|
+
// Remove only rectangles wholly covered by another available area.
|
|
523
|
+
// No useful square is lost by this pruning.
|
|
524
|
+
areas = next.filter((area, k) => !next.some((other, n) => n !== k
|
|
525
|
+
&& encloses(other, area) && (n < k || !encloses(area, other))));
|
|
526
|
+
if (areas.length > 128) return { uncertain: true };
|
|
504
527
|
}
|
|
505
|
-
return
|
|
528
|
+
if (areas === null) return null;
|
|
529
|
+
return areas.find((area) => area.width >= min && area.height >= min)
|
|
530
|
+
?? areas.reduce((best, area) => area.width * area.height > best.width * best.height ? area : best,
|
|
531
|
+
{ width: 0, height: 0 });
|
|
532
|
+
};
|
|
533
|
+
const obscuredRect = (i) => {
|
|
534
|
+
if (!obscuredRects.has(i)) obscuredRects.set(i, calculateObscuredRect(i));
|
|
535
|
+
return obscuredRects.get(i);
|
|
506
536
|
};
|
|
507
537
|
|
|
508
538
|
// ── Equivalent targets ────────────────────────────────────────────
|
|
@@ -525,21 +555,86 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
525
555
|
const destination = destinationOf(elements[i]);
|
|
526
556
|
if (!destination) return false;
|
|
527
557
|
if (!adequateDestinations) {
|
|
528
|
-
adequateDestinations = new
|
|
558
|
+
adequateDestinations = new Map();
|
|
529
559
|
elements.forEach((other, j) => {
|
|
530
560
|
if (!laidOut[j] || undersized[j] || other.ownerDocument !== elements[i].ownerDocument) return;
|
|
531
561
|
const d = destinationOf(other);
|
|
532
|
-
if (d)
|
|
562
|
+
if (d) {
|
|
563
|
+
if (!adequateDestinations.has(d)) adequateDestinations.set(d, []);
|
|
564
|
+
adequateDestinations.get(d).push(j);
|
|
565
|
+
}
|
|
533
566
|
});
|
|
534
567
|
}
|
|
535
|
-
|
|
568
|
+
let uncertain = false;
|
|
569
|
+
for (const j of adequateDestinations.get(destination) ?? []) {
|
|
570
|
+
if (j === i) continue;
|
|
571
|
+
// An original bounding box cannot prove a usable alternative.
|
|
572
|
+
// Otherwise two separately obscured links rescue one another even
|
|
573
|
+
// when neither retains the required square for pointer activation.
|
|
574
|
+
const area = obscuredRect(j);
|
|
575
|
+
if (area?.uncertain) { uncertain = true; continue; }
|
|
576
|
+
if (!area || (area.width >= min && area.height >= min)) return 'pass';
|
|
577
|
+
}
|
|
578
|
+
return uncertain ? 'incomplete' : null;
|
|
536
579
|
};
|
|
537
580
|
|
|
538
|
-
|
|
581
|
+
// Scripted functions have no URL identity. Matching names, declared
|
|
582
|
+
// controls or handler text identify plausible alternatives, not proven
|
|
583
|
+
// equivalence: a human must verify that both controls do the same thing.
|
|
584
|
+
// Build these indexes only when a failing scripted control needs them.
|
|
585
|
+
let alternativeKeys = null;
|
|
586
|
+
const names = new Map();
|
|
587
|
+
const nameOf = (element) => {
|
|
588
|
+
if (!names.has(element)) names.set(element, helpers.accessibleName?.(element)?.trim() ?? '');
|
|
589
|
+
return names.get(element);
|
|
590
|
+
};
|
|
591
|
+
const signatures = (element) => {
|
|
592
|
+
const keys = [];
|
|
593
|
+
const name = nameOf(element);
|
|
594
|
+
if (name) keys.push(`name:${name}`);
|
|
595
|
+
const handler = element.getAttribute('onclick')?.trim();
|
|
596
|
+
if (handler) keys.push(`handler:${handler}`);
|
|
597
|
+
const controls = element.getAttribute('aria-controls')?.trim().split(/\s+/).sort().join(' ');
|
|
598
|
+
if (controls) keys.push(`controls:${controls}`);
|
|
599
|
+
return keys;
|
|
600
|
+
};
|
|
601
|
+
const plausibleAlternative = (i) => {
|
|
602
|
+
if (destinationOf(elements[i])) return false; // URL equivalence was judged above
|
|
603
|
+
const own = signatures(elements[i]);
|
|
604
|
+
if (!own.length) return false;
|
|
605
|
+
if (!alternativeKeys) {
|
|
606
|
+
alternativeKeys = new Map();
|
|
607
|
+
elements.forEach((other, j) => {
|
|
608
|
+
if (!laidOut[j] || undersized[j] || destinationOf(other)) return;
|
|
609
|
+
const area = obscuredRect(j);
|
|
610
|
+
if (area && !area.uncertain && (area.width < min || area.height < min)) return;
|
|
611
|
+
for (const key of signatures(other)) {
|
|
612
|
+
if (!alternativeKeys.has(key)) alternativeKeys.set(key, []);
|
|
613
|
+
alternativeKeys.get(key).push(j);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
return own.some((key) => alternativeKeys.get(key)?.some((j) => j !== i));
|
|
618
|
+
};
|
|
619
|
+
const alternativeReview = () => ({
|
|
620
|
+
status: 'incomplete',
|
|
621
|
+
message: `This control's measured pointer area is below ${min}×${min}px, but a larger control has a matching name, handler or declared target. Check whether it provides the same function and meets the target-size requirement; if so, the Equivalent exception applies.`,
|
|
622
|
+
fix: 'Verify the larger alternative, or enlarge or separate this target.',
|
|
623
|
+
});
|
|
624
|
+
const equivalentReview = () => ({
|
|
625
|
+
status: 'incomplete',
|
|
626
|
+
message: `Another link reaches the same destination, but its unobscured pointer area could not be established. Check whether that alternative meets ${min}×${min}px before applying the Equivalent exception.`,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const judge = (element, i) => {
|
|
539
630
|
if (!laidOut[i]) return { status: 'pass' }; // not laid out / not a pointer target
|
|
540
631
|
if (!undersized[i]) {
|
|
541
632
|
// Box is big enough — but the pointer only gets the uncovered part.
|
|
542
633
|
const effective = obscuredRect(i);
|
|
634
|
+
if (effective?.uncertain) return {
|
|
635
|
+
status: 'incomplete',
|
|
636
|
+
message: 'Several targets overlap this control. The remaining pointer area could not be resolved within the geometry budget; check whether it contains the required target size.',
|
|
637
|
+
};
|
|
543
638
|
if (!effective || (effective.width >= min && effective.height >= min)) return { status: 'pass' };
|
|
544
639
|
if (isInTextLine(element)) return { status: 'pass' };
|
|
545
640
|
// A target the pointer never actually hits is a keyboard
|
|
@@ -565,6 +660,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
565
660
|
return !tested; // nothing probe-able → don't know → don't silence
|
|
566
661
|
})();
|
|
567
662
|
if (!everHit) return { status: 'pass' };
|
|
663
|
+
const equivalent = equivalentElsewhere(i);
|
|
664
|
+
if (equivalent === 'pass') return { status: 'pass' };
|
|
665
|
+
if (equivalent === 'incomplete') return equivalentReview();
|
|
666
|
+
if (plausibleAlternative(i)) return alternativeReview();
|
|
568
667
|
// The spacing exception cannot apply: the obscuring target is at
|
|
569
668
|
// distance zero. UA-control cannot either: the browser sized the
|
|
570
669
|
// box, but the author's layout took part of it away.
|
|
@@ -583,7 +682,7 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
583
682
|
// The same exception for every other native control, proved from the
|
|
584
683
|
// sheets; an open proof (unreadable sheet) reviews at the end instead
|
|
585
684
|
// of asserting, and only if nothing else rescues the target first.
|
|
586
|
-
const uaProof = uaSized(element);
|
|
685
|
+
const uaProof = uaSized(element, sizingRulesByRoot);
|
|
587
686
|
if (uaProof === 'yes') return { status: 'pass' };
|
|
588
687
|
if (isInTextLine(element)) return { status: 'pass' };
|
|
589
688
|
if (spacingException) {
|
|
@@ -643,7 +742,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
643
742
|
// crowding from its second line or not at all.
|
|
644
743
|
return reachableRects(other, rects[j]).some(within);
|
|
645
744
|
};
|
|
646
|
-
|
|
745
|
+
// A target can crowd this circle only if its rectangle reaches the
|
|
746
|
+
// center's min-radius neighborhood. The larger radius also covers
|
|
747
|
+
// the circle of any undersized neighbor, whose center lies in its
|
|
748
|
+
// rectangle. This index is a superset, retaining the exact tests.
|
|
749
|
+
const center = centers[i];
|
|
750
|
+
const crowded = [...nearbyCandidates(i, {
|
|
751
|
+
left: center.x - min, right: center.x + min,
|
|
752
|
+
top: center.y - min, bottom: center.y + min,
|
|
753
|
+
})].some((j) => crowds(elements[j], j));
|
|
647
754
|
if (!crowded) return { status: 'pass' };
|
|
648
755
|
// Deliberately NOT applied to the target itself. A crowder that
|
|
649
756
|
// can't be clicked breaks the premise of the exception, because it
|
|
@@ -652,7 +759,10 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
652
759
|
// dismissed, and suppressing it would hide a real defect behind a
|
|
653
760
|
// transient overlay, which is the wrong way round.
|
|
654
761
|
}
|
|
655
|
-
|
|
762
|
+
const equivalent = equivalentElsewhere(i);
|
|
763
|
+
if (equivalent === 'pass') return { status: 'pass' }; // Same destination and adequate usable area
|
|
764
|
+
if (equivalent === 'incomplete') return equivalentReview();
|
|
765
|
+
if (plausibleAlternative(i)) return alternativeReview();
|
|
656
766
|
const rect = rects[i];
|
|
657
767
|
if (uaProof === 'open') {
|
|
658
768
|
return {
|
|
@@ -667,7 +777,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
667
777
|
: `This target is ${px(rect.width)}×${px(rect.height)}px — below the ${min}×${min}px minimum, hard to hit for users with motor impairments.`,
|
|
668
778
|
fix: `Increase the element’s size or padding to at least ${min}×${min}px${spacingException ? ', or add spacing between the targets' : ''}.`,
|
|
669
779
|
};
|
|
670
|
-
}
|
|
780
|
+
};
|
|
781
|
+
const outcomes = [];
|
|
782
|
+
for (let i = 0; i < elements.length; i++) {
|
|
783
|
+
if (i % 64 === 0) await yieldToMain();
|
|
784
|
+
outcomes.push(judge(elements[i], i));
|
|
785
|
+
}
|
|
786
|
+
return outcomes;
|
|
671
787
|
},
|
|
672
788
|
};
|
|
673
789
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// page may also offer an Alternative (a passkey, a federated sign-in, an
|
|
11
11
|
// emailed link) that this rule cannot see. Blocked paste is therefore
|
|
12
12
|
// reported for REVIEW, not failed. Both mechanisms obstructed at once is the
|
|
13
|
-
// case worth
|
|
13
|
+
// case worth reviewing together. Neither attribute proves that every
|
|
14
|
+
// alternative mechanism is unavailable.
|
|
14
15
|
//
|
|
15
16
|
// Paste only (2026-08-25 overnight audit): an ondrop handler used to count
|
|
16
17
|
// as a paste blocker. It is not. Drag and drop is a different event and a
|
|
@@ -26,7 +27,7 @@
|
|
|
26
27
|
// the form has the classic sign-in shape: exactly one password field beside
|
|
27
28
|
// a username or email field. Two password fields in one form is the shape
|
|
28
29
|
// of sign-up or a password change, and that field is asked about, never
|
|
29
|
-
// failed.
|
|
30
|
+
// failed. Even a likely login credential still needs its behavior checked.
|
|
30
31
|
export default {
|
|
31
32
|
id: 'auth-field-obstruction',
|
|
32
33
|
name: 'Accessible login fields',
|
|
@@ -43,7 +44,7 @@ export default {
|
|
|
43
44
|
const autocompleteOff = tokens.length === 1 && tokens[0] === 'off';
|
|
44
45
|
if (!blocksPaste && !autocompleteOff) return { status: 'pass' };
|
|
45
46
|
|
|
46
|
-
//
|
|
47
|
+
// Markup suggests an existing credential; it does not prove the process.
|
|
47
48
|
const form = element.form ?? element.closest('form');
|
|
48
49
|
const passwordFields = form ? form.querySelectorAll('input[type="password"]').length : (element.type === 'password' ? 1 : 0);
|
|
49
50
|
const identityField = form?.querySelector('input[autocomplete~="username"], input[autocomplete~="email"], input[type="email"]');
|
|
@@ -55,23 +56,16 @@ export default {
|
|
|
55
56
|
const step = provenLogin ? 'this login' : 'this step';
|
|
56
57
|
|
|
57
58
|
if (blocksPaste && autocompleteOff) {
|
|
58
|
-
if (provenLogin) {
|
|
59
|
-
return {
|
|
60
|
-
status: 'fail',
|
|
61
|
-
message: `This authentication field blocks pasting AND sets autocomplete="off", so both of the mechanisms 3.3.8 names are obstructed at once: copy and paste, and password-manager entry. What is left is typing the credential from memory.`,
|
|
62
|
-
fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
59
|
return {
|
|
66
60
|
status: 'incomplete',
|
|
67
|
-
message: `This
|
|
61
|
+
message: `This field has a paste handler containing cancellation code and sets autocomplete="off". Check whether the handler actually blocks pasting, whether a password manager can fill the field, and whether another authentication method is available. Conditional code may leave pasting enabled, and autocomplete="off" does not establish that password-manager entry is blocked.${provenLogin ? '' : ` ${scopeQuestion}`}`,
|
|
68
62
|
fix: 'Remove the paste blocking, and drop autocomplete="off" so a password manager can fill the field.',
|
|
69
63
|
};
|
|
70
64
|
}
|
|
71
65
|
if (blocksPaste) {
|
|
72
66
|
return {
|
|
73
67
|
status: 'incomplete',
|
|
74
|
-
message: `This field
|
|
68
|
+
message: `This field has a paste handler containing cancellation code. Check whether pasting is actually blocked; the code may run only conditionally. Also check whether a password manager can complete ${step}, or whether the page offers another way in such as a passkey or a federated sign-in.${provenLogin ? '' : ` ${scopeQuestion}`}`,
|
|
75
69
|
fix: 'Remove the paste blocking, or make sure another way to sign in is available that does not rely on recalling the credential.',
|
|
76
70
|
};
|
|
77
71
|
}
|
|
@@ -68,6 +68,10 @@ export default {
|
|
|
68
68
|
for (const { name, value } of attributesOf(element)) {
|
|
69
69
|
if (!name.startsWith('aria-')) continue;
|
|
70
70
|
const attr = name.slice(5);
|
|
71
|
+
// ARIA defines unknown current/invalid tokens as true, not discarded.
|
|
72
|
+
// Empty/undefined current means false. Syntax alone cannot show a
|
|
73
|
+
// state mismatch here. https://www.w3.org/TR/wai-aria-1.2/#aria-current
|
|
74
|
+
if (name === 'aria-current' || name === 'aria-invalid') continue;
|
|
71
75
|
if (!KNOWN.has(attr)) {
|
|
72
76
|
unknown.push(name);
|
|
73
77
|
} else if (ENUMS[name] && value.trim() === '') {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// text inside a listbox/textbox does not name it, only author-provided
|
|
7
7
|
// naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
|
|
8
8
|
import { labelledByName } from '../../lib/accessible-name.js';
|
|
9
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
9
10
|
|
|
10
11
|
// progressbar and meter belong here too: ARIA 1.2 marks both roles
|
|
11
12
|
// "Accessible Name Required: True", they never name from content, and a
|
|
@@ -28,12 +29,12 @@ export default {
|
|
|
28
29
|
tags: ['wcag2a', 'wcag412'],
|
|
29
30
|
help: 'ARIA fields and value widgets must have an accessible name',
|
|
30
31
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
31
|
-
selector: [
|
|
32
|
-
.map((role) => `[role="${role}"]:not(input):not(select):not(textarea)`)
|
|
33
|
-
.join(', '),
|
|
32
|
+
selector: '[role]:not(input):not(select):not(textarea)',
|
|
34
33
|
evaluate(element, { accessibleName }) {
|
|
35
|
-
const role = element
|
|
36
|
-
|
|
34
|
+
const role = effectiveRole(element);
|
|
35
|
+
if (![...AUTHOR_ONLY, ...FROM_CONTENT].includes(role)) return { status: 'pass' };
|
|
36
|
+
const name = FROM_CONTENT.includes(role) || element.labels?.length
|
|
37
|
+
? accessibleName(element) : authorName(element);
|
|
37
38
|
if (name) return { status: 'pass' };
|
|
38
39
|
// ARIA 1.1 combobox pattern: a non-focusable wrapper div carries the
|
|
39
40
|
// role while the real, focusable input inside carries the label. The
|
|
@@ -76,6 +76,10 @@ function inspect(element) {
|
|
|
76
76
|
const missing = [];
|
|
77
77
|
const ambiguous = [];
|
|
78
78
|
for (const attr of REF_ATTRIBUTES) {
|
|
79
|
+
// ARIA requires the error relation to remain unexposed while valid.
|
|
80
|
+
// A lazy error node not yet inserted cannot lose an active message.
|
|
81
|
+
if (attr === 'aria-errormessage' && (!element.hasAttribute('aria-invalid')
|
|
82
|
+
|| ['false', ''].includes(element.getAttribute('aria-invalid').trim().toLowerCase()))) continue;
|
|
79
83
|
for (const id of (element.getAttribute(attr) ?? '').split(/\s+/).filter(Boolean)) {
|
|
80
84
|
if (!root.getElementById?.(id)) {
|
|
81
85
|
if (attr === 'aria-controls' && collapsed) continue;
|
|
@@ -110,8 +114,8 @@ function outcome({ missing, ambiguous }, element, accessibleName) {
|
|
|
110
114
|
// not name, role or a user-set value, and tooltip libraries create
|
|
111
115
|
// the target on hover or focus, so the DOM proves an authoring
|
|
112
116
|
// error at most. Asked, never asserted;
|
|
113
|
-
// -
|
|
114
|
-
//
|
|
117
|
+
// - other relationships: review unless a missing name or active
|
|
118
|
+
// item above independently demonstrates the component's failure.
|
|
115
119
|
const component = isUserInterfaceComponent(element);
|
|
116
120
|
// The resting name for a component is the full computation (a button
|
|
117
121
|
// names itself from its contents). A landmark, list or group is not
|
|
@@ -126,7 +130,8 @@ function outcome({ missing, ambiguous }, element, accessibleName) {
|
|
|
126
130
|
const line = ({ attr, id }) => attr === 'aria-labelledby'
|
|
127
131
|
? `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`
|
|
128
132
|
: `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
|
|
129
|
-
const asserted = relevant.filter(({ attr }) =>
|
|
133
|
+
const asserted = relevant.filter(({ attr }) => component
|
|
134
|
+
&& (attr === 'aria-labelledby' || attr === 'aria-activedescendant'));
|
|
130
135
|
if (asserted.length) {
|
|
131
136
|
return {
|
|
132
137
|
status: 'fail',
|
|
@@ -8,7 +8,7 @@ export default {
|
|
|
8
8
|
tags: ['wcag2a', 'wcag412'],
|
|
9
9
|
help: 'Buttons must have an accessible name',
|
|
10
10
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
11
|
-
selector: 'button, [role
|
|
11
|
+
selector: 'button, [role], input[type="button"], input[type="submit"], input[type="reset"]',
|
|
12
12
|
evaluate(element, { accessibleName }) {
|
|
13
13
|
// Route by EFFECTIVE role (2026-08-25 overnight audit): a <button
|
|
14
14
|
// role="tab"> or <button role="link"> is exposed under that role, so its
|
|
@@ -19,7 +19,7 @@ export default {
|
|
|
19
19
|
tags: ['wcag2a', 'wcag412'],
|
|
20
20
|
help: 'Tabs, menu items, options and tree items need an accessible name',
|
|
21
21
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
22
|
-
selector: '[role
|
|
22
|
+
selector: '[role]',
|
|
23
23
|
evaluate(element, { accessibleName }) {
|
|
24
24
|
// This rule owns every element whose EFFECTIVE role is one of the six,
|
|
25
25
|
// <button role="tab"> and <a href role="tab"> included: button-name and
|
|
@@ -30,6 +30,7 @@ export default {
|
|
|
30
30
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
31
31
|
selector: '[popovertarget], [commandfor]',
|
|
32
32
|
evaluate(element) {
|
|
33
|
+
if (element.matches(':disabled') || !element.matches('button, input[type="button"]')) return { status: 'pass' };
|
|
33
34
|
// Ids resolve within the button's own tree scope (a shadow root's ids
|
|
34
35
|
// are its own), exactly as the browser resolves them.
|
|
35
36
|
const root = element.getRootNode();
|
|
@@ -46,7 +47,7 @@ export default {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
if (element.hasAttribute('commandfor')) {
|
|
50
|
+
if (element.tagName === 'BUTTON' && element.hasAttribute('commandfor')) {
|
|
50
51
|
const id = element.getAttribute('commandfor').trim();
|
|
51
52
|
const command = (element.getAttribute('command') ?? '').trim().toLowerCase();
|
|
52
53
|
const target = lookup(id);
|
|
@@ -29,7 +29,7 @@ export default {
|
|
|
29
29
|
help: 'label[for] must reference a form control that exists',
|
|
30
30
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
31
31
|
selector: 'label[for]',
|
|
32
|
-
evaluate(element) {
|
|
32
|
+
evaluate(element, { accessibleName }) {
|
|
33
33
|
const id = element.getAttribute('for');
|
|
34
34
|
// `control` is the browser's own for-resolution: null when the id is
|
|
35
35
|
// missing or names something that is not labelable. It resolves within
|
|
@@ -41,7 +41,7 @@ export default {
|
|
|
41
41
|
// a label that points at one (standing policy: this shape passes).
|
|
42
42
|
if (target && target.tagName === 'INPUT' && target.type === 'hidden') return { status: 'pass' };
|
|
43
43
|
const wrapped = element.querySelector(WRAPPABLE);
|
|
44
|
-
if (wrapped) {
|
|
44
|
+
if (wrapped && !accessibleName(wrapped)) {
|
|
45
45
|
return {
|
|
46
46
|
status: 'fail',
|
|
47
47
|
message: `This label wraps a <${wrapped.tagName.toLowerCase()}> but its for="${id}" ${target ? `points at a <${target.tagName.toLowerCase()}>, which is not labelable` : 'points at nothing'}. The for attribute overrides the wrapping, so the wrapped control is not labelled by this text.`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
import { attributesOf } from '../../lib/dom.js';
|
|
3
|
+
import { implicitRole } from '../../lib/roles.js';
|
|
3
4
|
// Concrete, usable roles — abstract roles are invalid in markup.
|
|
4
5
|
export const VALID_ROLES = new Set([
|
|
5
6
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
|
|
@@ -108,7 +109,11 @@ export default {
|
|
|
108
109
|
// this is a question, not an assertion. Focusable or state-bearing
|
|
109
110
|
// elements keep failing: a focusable element left generic, or a state
|
|
110
111
|
// riding on a role the browser threw away, is the real 4.1.2 harm.
|
|
111
|
-
|
|
112
|
+
// An invalid role does not erase a native control's semantics. A
|
|
113
|
+
// misspelling on a button is a review question; its actual missing
|
|
114
|
+
// name or state, if any, belongs to the corresponding semantic rule.
|
|
115
|
+
const nativeRole = implicitRole(element);
|
|
116
|
+
if ((!focusable && !hasAriaProps) || (nativeRole && nativeRole !== 'generic')) {
|
|
112
117
|
return {
|
|
113
118
|
status: 'incomplete',
|
|
114
119
|
message: `role="${element.getAttribute('role')}" is not a valid ARIA role${hint}, so assistive technology ignores it and exposes the element's native <${tag}> semantics. Nothing is announced wrongly, but the author reached for a role that does not exist. Is the native <${tag}> role the right one here? If a different role was intended, that role is missing.`,
|
package/engine/wcag22.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// `automation` is an honest assessment:
|
|
6
6
|
// 'auto' — rules can meaningfully test this
|
|
7
|
-
// 'partial' — rules
|
|
8
|
-
// 'manual' —
|
|
7
|
+
// 'partial' — rules check aspects or nominate review candidates; humans still review
|
|
8
|
+
// 'manual' — no active rule; assessed through the human checklist
|
|
9
9
|
// The coverage report (scripts/wcag-coverage.js → src/engine/COVERAGE.md) joins
|
|
10
10
|
// this catalog with the rule registry to show real coverage.
|
|
11
11
|
|
|
@@ -37,11 +37,11 @@ export default [
|
|
|
37
37
|
sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
|
|
38
38
|
sc('1.3.2', 'Meaningful Sequence', 'A', 'partial'), // partial: CSS-reorder divergence is measurable; whether meaning survives is the reader's call
|
|
39
39
|
sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
|
|
40
|
-
sc('1.3.4', 'Orientation', 'AA', 'partial', '2.1'), // orientation-lock
|
|
41
|
-
sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are
|
|
40
|
+
sc('1.3.4', 'Orientation', 'AA', 'partial', '2.1'), // orientation-lock asserts CSS root hides/rotations as ACT b33eff does, essential exception assumed absent; script locks stay a human check
|
|
41
|
+
sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are asserted as ACT 73f2c2 does (search boxes asked); MISSING autocomplete on identity fields needs judgment
|
|
42
42
|
sc('1.3.6', 'Identify Purpose', 'AAA', 'manual', '2.1'),
|
|
43
43
|
sc('1.4.1', 'Use of Color', 'A', 'partial'), // link-in-text-block automates the link case
|
|
44
|
-
sc('1.4.2', 'Audio Control', 'A', 'partial'), //
|
|
44
|
+
sc('1.4.2', 'Audio Control', 'A', 'partial'), // declarative autoplay identifies candidates; audibility and alternative controls need review
|
|
45
45
|
sc('1.4.3', 'Contrast (Minimum)', 'AA', 'partial'),
|
|
46
46
|
sc('1.4.4', 'Resize Text', 'AA', 'partial'),
|
|
47
47
|
sc('1.4.5', 'Images of Text', 'AA', 'manual'),
|
|
@@ -75,9 +75,9 @@ export default [
|
|
|
75
75
|
sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
|
|
76
76
|
sc('2.4.7', 'Focus Visible', 'AA', 'partial'), // partial: focus-visible flags outline suppression; the indicator itself needs eyes
|
|
77
77
|
sc('2.4.8', 'Location', 'AAA', 'manual'),
|
|
78
|
-
sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'partial'), //
|
|
78
|
+
sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'partial'), // generic wording nominates candidates; whether wording explains the destination needs a reader
|
|
79
79
|
sc('2.4.10', 'Section Headings', 'AAA', 'manual'),
|
|
80
|
-
sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), //
|
|
80
|
+
sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // covered resting targets are candidates; actual focus/scroll behavior needs review
|
|
81
81
|
sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'manual', '2.2'),
|
|
82
82
|
sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
|
|
83
83
|
sc('2.5.1', 'Pointer Gestures', 'A', 'manual', '2.1'),
|
package/package.json
CHANGED