pour-engine 1.33.0 → 1.34.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/config/project.config.js +1 -1
- package/engine/COVERAGE.md +5 -5
- package/engine/index.js +11 -4
- package/engine/lib/accessible-name.js +15 -4
- package/engine/lib/contrast.js +316 -11
- package/engine/lib/roles.js +30 -6
- package/engine/rules/best-practice/button-type.js +23 -2
- package/engine/rules/best-practice/fieldset-legend.js +7 -2
- package/engine/rules/best-practice/heading-order.js +2 -1
- package/engine/rules/best-practice/landmark-top-level.js +10 -0
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/new-window-link.js +34 -1
- package/engine/rules/best-practice/no-autofocus.js +15 -3
- package/engine/rules/best-practice/page-heading-one.js +15 -1
- package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
- package/engine/rules/best-practice/redundant-aria-label.js +24 -0
- package/engine/rules/best-practice/redundant-role.js +71 -20
- package/engine/rules/best-practice/region.js +6 -2
- package/engine/rules/index.js +13 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
- package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
- package/engine/rules/wcag/1.3.1-form-label.js +39 -1
- package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
- package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
- package/engine/rules/wcag/1.3.4-orientation.js +21 -7
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
- package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
- package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
- package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
- package/engine/rules/wcag/2.4.2-document-title.js +8 -2
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
- package/engine/rules/wcag/2.4.4-link-name.js +11 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
- package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
- package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
- package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
- package/engine/rules/wcag/2.5.8-target-size.js +122 -3
- package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
- package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +109 -72
- package/engine/rules/wcag/4.1.2-button-name.js +11 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
- package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
- package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -13,7 +13,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
13
13
|
| SC | Name | Level | Coverage | Rules |
|
|
14
14
|
|---|---|---|---|---|
|
|
15
15
|
| 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, canvas-alt, embed-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
|
|
16
|
-
| 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A |
|
|
16
|
+
| 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Partially automated | audio-transcript |
|
|
17
17
|
| 1.2.2 | [Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html) | A | Partially automated | media-captions |
|
|
18
18
|
| 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A | Manual review | — |
|
|
19
19
|
| 1.2.4 | [Captions (Live)](https://www.w3.org/WAI/WCAG22/Understanding/captions-live.html) | AA | Manual review | — |
|
|
@@ -23,7 +23,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
23
23
|
| 1.2.8 | [Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/media-alternative-prerecorded.html) | AAA | Manual review | — |
|
|
24
24
|
| 1.2.9 | [Audio-only (Live)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-live.html) | AAA | Manual review | — |
|
|
25
25
|
| 1.3.1 | [Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | A | Partially automated | aria-required-children, aria-required-parent, definition-list, dlitem-parent, form-label, list-structure, listitem-parent, p-as-heading, table-headers |
|
|
26
|
-
| 1.3.2 | [Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A |
|
|
26
|
+
| 1.3.2 | [Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A | Partially automated | reading-order-divergence |
|
|
27
27
|
| 1.3.3 | [Sensory Characteristics](https://www.w3.org/WAI/WCAG22/Understanding/sensory-characteristics.html) | A | Manual review | — |
|
|
28
28
|
| 1.3.4 | [Orientation](https://www.w3.org/WAI/WCAG22/Understanding/orientation.html) | AA | Partially automated | orientation-lock |
|
|
29
29
|
| 1.3.5 | [Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html) | AA | Partially automated | autocomplete-valid |
|
|
@@ -78,7 +78,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
78
78
|
| 2.5.4 | [Motion Actuation](https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html) | A | Manual review | — |
|
|
79
79
|
| 2.5.5 | [Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) | AAA | Partially automated | target-size-enhanced |
|
|
80
80
|
| 2.5.6 | [Concurrent Input Mechanisms](https://www.w3.org/WAI/WCAG22/Understanding/concurrent-input-mechanisms.html) | AAA | Manual review | — |
|
|
81
|
-
| 2.5.7 | [Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) | AA |
|
|
81
|
+
| 2.5.7 | [Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) | AA | Partially automated | drag-alternative |
|
|
82
82
|
| 2.5.8 | [Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) | AA | Partially automated | target-size |
|
|
83
83
|
|
|
84
84
|
## Understandable
|
|
@@ -103,7 +103,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
103
103
|
| 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA | Manual review | — |
|
|
104
104
|
| 3.3.5 | [Help](https://www.w3.org/WAI/WCAG22/Understanding/help.html) | AAA | Manual review | — |
|
|
105
105
|
| 3.3.6 | [Error Prevention (All)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-all.html) | AAA | Manual review | — |
|
|
106
|
-
| 3.3.7 | [Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html) | A |
|
|
106
|
+
| 3.3.7 | [Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html) | A | Partially automated | redundant-entry |
|
|
107
107
|
| 3.3.8 | [Accessible Authentication (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html) | AA | Partially automated | auth-field-obstruction |
|
|
108
108
|
| 3.3.9 | [Accessible Authentication (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html) | AAA | Manual review | — |
|
|
109
109
|
|
|
@@ -114,4 +114,4 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
114
114
|
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-label-misuse, aria-valid-refs, button-name, composite-widget-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, summary-name, valid-role |
|
|
115
115
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
116
116
|
|
|
117
|
-
**Totals:**
|
|
117
|
+
**Totals:** 38 partially automated · 48 manual — with 86 rules.
|
package/engine/index.js
CHANGED
|
@@ -22,10 +22,16 @@ function ruleMatchesTags(rule, tags) {
|
|
|
22
22
|
/** Which WCAG version/level was requested, judged from the selected tags. */
|
|
23
23
|
function scopeFromTags(tags) {
|
|
24
24
|
if (!tags?.length) return { version: '2.2', level: 'AAA' }; // no filter → everything
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// Only version/level tags (wcag2a … wcag22aaa) define a scope. Criterion
|
|
26
|
+
// tags such as wcag221 must not read as version markers, and a tag list
|
|
27
|
+
// with no WCAG scope at all (best-practice only) has no checklist: a
|
|
28
|
+
// run that asked for no criteria owes no criteria (2026-08-25 audit).
|
|
29
|
+
const scoped = tags.filter((t) => /^wcag(2|21|22)(a|aa|aaa)$/.test(t));
|
|
30
|
+
if (!scoped.length) return null;
|
|
31
|
+
const version = scoped.some((t) => t.startsWith('wcag22')) ? '2.2'
|
|
32
|
+
: scoped.some((t) => t.startsWith('wcag21')) ? '2.1' : '2.0';
|
|
33
|
+
const level = scoped.some((t) => /aaa$/.test(t)) ? 'AAA'
|
|
34
|
+
: scoped.some((t) => /aa$/.test(t)) ? 'AA' : 'A';
|
|
29
35
|
return { version, level };
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -39,6 +45,7 @@ const LEVEL_ORDER = { A: 0, AA: 1, AAA: 2 };
|
|
|
39
45
|
*/
|
|
40
46
|
function manualReviewCriteria(tags) {
|
|
41
47
|
const scope = scopeFromTags(tags);
|
|
48
|
+
if (!scope) return [];
|
|
42
49
|
return wcagCatalog
|
|
43
50
|
.filter((criterion) => criterion.automation !== 'auto')
|
|
44
51
|
.filter((criterion) => VERSION_ORDER[criterion.since] <= VERSION_ORDER[scope.version])
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
|
|
10
10
|
'datetime-local', 'month', 'time', 'week', '']);
|
|
11
11
|
|
|
12
|
+
/** HTML "labelable elements": the ones whose `.labels` the browser fills
|
|
13
|
+
* (form-associated custom elements are labelable too but expose their
|
|
14
|
+
* labels through ElementInternals, so they are not reachable here). */
|
|
15
|
+
const LABELABLE = new Set(['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress']);
|
|
16
|
+
|
|
12
17
|
export function accessibleName(element) {
|
|
13
18
|
return computeName(element, false);
|
|
14
19
|
}
|
|
@@ -53,11 +58,17 @@ function computeName(element, inLabelledBy) {
|
|
|
53
58
|
if (alt) return alt;
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
// HTML-AAM puts the associated <label> before the element's own subtree
|
|
62
|
+
// for EVERY labelable element (button, meter, output, progress as well as
|
|
63
|
+
// input, select, textarea), and the browser names <label for="b"><button
|
|
64
|
+
// id="b"> from the label. Widened from the three field tags 2026-08-25
|
|
65
|
+
// overnight audit: button-name was asserting a label-named button nameless.
|
|
66
|
+
if (LABELABLE.has(tag) && element.labels?.length) {
|
|
67
|
+
const text = [...element.labels].map((label) => label.textContent).join(' ').trim();
|
|
68
|
+
if (text) return text;
|
|
69
|
+
}
|
|
70
|
+
|
|
56
71
|
if (tag === 'input' || tag === 'select' || tag === 'textarea') {
|
|
57
|
-
if (element.labels?.length) {
|
|
58
|
-
const text = [...element.labels].map((label) => label.textContent).join(' ').trim();
|
|
59
|
-
if (text) return text;
|
|
60
|
-
}
|
|
61
72
|
if (element.type === 'submit' || element.type === 'reset' || element.type === 'button') {
|
|
62
73
|
// The value PROPERTY, not the attribute: scripts set `el.value`
|
|
63
74
|
// without reflecting it, and the property is what names the button.
|
package/engine/lib/contrast.js
CHANGED
|
@@ -131,6 +131,7 @@ let mediaRectsCache = null;
|
|
|
131
131
|
let panelRectsCache = null;
|
|
132
132
|
let pseudoCache = new WeakMap();
|
|
133
133
|
let zeroClipCache = new WeakMap();
|
|
134
|
+
let firstLineRulesCache = new WeakMap();
|
|
134
135
|
const HAS_IMAGE = Symbol('background-image in chain');
|
|
135
136
|
|
|
136
137
|
export function resetAuditCaches() {
|
|
@@ -141,6 +142,7 @@ export function resetAuditCaches() {
|
|
|
141
142
|
panelRectsCache = null;
|
|
142
143
|
pseudoCache = new WeakMap();
|
|
143
144
|
zeroClipCache = new WeakMap();
|
|
145
|
+
firstLineRulesCache = new WeakMap();
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
/**
|
|
@@ -359,6 +361,87 @@ export function cumulativeOpacity(element) {
|
|
|
359
361
|
return cached;
|
|
360
362
|
}
|
|
361
363
|
|
|
364
|
+
/** One node's own opacity at rest: the landing value of a running
|
|
365
|
+
* animation on it, otherwise its computed opacity. */
|
|
366
|
+
function nodeRestingOpacity(node) {
|
|
367
|
+
const animators = opacityAnimators(node.ownerDocument);
|
|
368
|
+
if (animators.has(node)) {
|
|
369
|
+
const rest = animators.get(node);
|
|
370
|
+
return rest === null ? 1 : rest;
|
|
371
|
+
}
|
|
372
|
+
const value = parseFloat(getComputedStyle(node).opacity);
|
|
373
|
+
return Number.isFinite(value) ? value : 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The pair a viewer is presented with when the text sits inside an
|
|
378
|
+
* opacity group that also paints its own background.
|
|
379
|
+
*
|
|
380
|
+
* CSS group opacity renders an element and its subtree together, then
|
|
381
|
+
* blends that whole picture over what lies behind the element. Folding the
|
|
382
|
+
* opacity into the text colour alone and judging it against the group's
|
|
383
|
+
* own (undimmed) background describes a pair the page never paints: black
|
|
384
|
+
* text in a white card at opacity .5 over a mid-grey page is presented as
|
|
385
|
+
* rgb(64) on rgb(191), 5.6:1, while "half-black on white" scores 3.97:1.
|
|
386
|
+
*
|
|
387
|
+
* Walks the flat tree from the text to the OUTERMOST node with opacity
|
|
388
|
+
* below 1, compositing each node's background beneath the accumulated text
|
|
389
|
+
* and background pixels and then scaling both by that node's opacity, which
|
|
390
|
+
* is exactly the order the renderer applies. The result is composited over
|
|
391
|
+
* the background resolved behind the outermost carrier.
|
|
392
|
+
*
|
|
393
|
+
* Returns null when the chain carries no opacity, or no background paint
|
|
394
|
+
* inside the group (the old text-only blend is then correct), or paint this
|
|
395
|
+
* arithmetic cannot follow (an image, a blend mode, a filter inside the
|
|
396
|
+
* group). Returns { unresolved: true, opacity } when the backdrop behind the
|
|
397
|
+
* carrier is itself an image: the pair is then a human call, not a guess.
|
|
398
|
+
* Otherwise { foreground, background, opacity, text, paint }: the presented
|
|
399
|
+
* pair (both opaque) and the group's own pixels before compositing.
|
|
400
|
+
* (2026-08-25 overnight audit, defect 1.)
|
|
401
|
+
*/
|
|
402
|
+
export function opacityGroupPaint(element, foreground) {
|
|
403
|
+
const flatParent = (node) => node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
404
|
+
let top = null;
|
|
405
|
+
let opacity = 1;
|
|
406
|
+
for (let node = element; node && node.nodeType === 1; node = flatParent(node)) {
|
|
407
|
+
const own = nodeRestingOpacity(node);
|
|
408
|
+
if (own < 1) { top = node; opacity *= own; }
|
|
409
|
+
}
|
|
410
|
+
if (!top) return null;
|
|
411
|
+
let text = { ...foreground };
|
|
412
|
+
let paint = { r: 0, g: 0, b: 0, a: 0 };
|
|
413
|
+
let painted = false;
|
|
414
|
+
for (let node = element; node && node.nodeType === 1; node = flatParent(node)) {
|
|
415
|
+
const style = getComputedStyle(node);
|
|
416
|
+
if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
|
|
417
|
+
if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
|
|
418
|
+
|| (style.filter && style.filter !== 'none')
|
|
419
|
+
|| (style.backdropFilter && style.backdropFilter !== 'none')) return null;
|
|
420
|
+
const color = parseColor(style.backgroundColor);
|
|
421
|
+
if (color && color.a > 0) {
|
|
422
|
+
// A node's background lies beneath its content and inside its own
|
|
423
|
+
// opacity group, so it composites in before that group's alpha applies.
|
|
424
|
+
text = composite(text, color);
|
|
425
|
+
paint = composite(paint, color);
|
|
426
|
+
painted = true;
|
|
427
|
+
}
|
|
428
|
+
const own = nodeRestingOpacity(node);
|
|
429
|
+
if (own < 1) {
|
|
430
|
+
text = { ...text, a: text.a * own };
|
|
431
|
+
paint = { ...paint, a: paint.a * own };
|
|
432
|
+
}
|
|
433
|
+
if (node === top) break;
|
|
434
|
+
}
|
|
435
|
+
if (!painted) return null;
|
|
436
|
+
const behindNode = flatParent(top);
|
|
437
|
+
const behind = behindNode ? effectiveBackground(behindNode) : canvasColor(element.ownerDocument);
|
|
438
|
+
if (!behind) return { unresolved: true, opacity };
|
|
439
|
+
// `text` and `paint` are the group's own rendered pixels, alpha intact,
|
|
440
|
+
// so a caller can re-present the pair over a different backdrop (an
|
|
441
|
+
// overlapping panel the walk cannot see) without walking again.
|
|
442
|
+
return { foreground: composite(text, behind), background: composite(paint, behind), opacity, text, paint };
|
|
443
|
+
}
|
|
444
|
+
|
|
362
445
|
/**
|
|
363
446
|
* Is something painted between this element and the ancestor that provides
|
|
364
447
|
* its background colour — a positioned <img>, video, overlay…? If so, the
|
|
@@ -805,17 +888,26 @@ export function paintedBackdrop(element) {
|
|
|
805
888
|
// the stack. Its background-image was already adjudicated upstream by the
|
|
806
889
|
// image-sampling path, so only its colour participates here.
|
|
807
890
|
let acc = null;
|
|
891
|
+
// The translucent layers met on the way down, nearest the text first, in
|
|
892
|
+
// the shape backgroundImageSource hands out as `overlays`. When the walk
|
|
893
|
+
// ends on an image they are the paint BETWEEN the image and the glyphs
|
|
894
|
+
// (the hero pattern: a photo as <img>, a 70% black scrim div, a white
|
|
895
|
+
// heading), and the sampled pixels must be seen through them. Dropping
|
|
896
|
+
// them judged the raw photo and failed text that sat on the dimmed one.
|
|
897
|
+
// (2026-08-25 overnight audit, defect 2.)
|
|
898
|
+
const overlays = [];
|
|
808
899
|
const own = parseColor(getComputedStyle(element).backgroundColor);
|
|
809
900
|
if (own && own.a > 0) {
|
|
810
901
|
if (own.a >= 1) return { color: own, scrim };
|
|
811
902
|
acc = own;
|
|
903
|
+
overlays.push(own);
|
|
812
904
|
}
|
|
813
905
|
if (start === -1) return 'unresolved';
|
|
814
906
|
for (const layer of stack.slice(start + 1)) {
|
|
815
907
|
// Replaced elements paint their content, not a background — a photo or
|
|
816
908
|
// video in the stack is an image backdrop whatever its styles say.
|
|
817
909
|
if (/^(img|video|canvas|svg|picture|object|embed|iframe)$/i.test(layer.tagName)) {
|
|
818
|
-
return { image: layer, scrim };
|
|
910
|
+
return { image: layer, scrim, overlays };
|
|
819
911
|
}
|
|
820
912
|
const style = getComputedStyle(layer);
|
|
821
913
|
// A shadow HOST in the stack hides its shadow tree from the hit-test
|
|
@@ -846,7 +938,7 @@ export function paintedBackdrop(element) {
|
|
|
846
938
|
// layer paints is a reason to ask a human, never to rule it out.
|
|
847
939
|
const covers = (r) => !r
|
|
848
940
|
|| (point.x >= r.left && point.x < r.right && point.y >= r.top && point.y < r.bottom);
|
|
849
|
-
if (!rects.length || rects.some(covers)) return { image: layer, scrim };
|
|
941
|
+
if (!rects.length || rects.some(covers)) return { image: layer, scrim, overlays };
|
|
850
942
|
}
|
|
851
943
|
let color = parseColor(style.backgroundColor);
|
|
852
944
|
if (!color) return 'unresolved';
|
|
@@ -857,6 +949,7 @@ export function paintedBackdrop(element) {
|
|
|
857
949
|
if (color.a === 0) continue;
|
|
858
950
|
acc = acc ? composite(acc, color) : color;
|
|
859
951
|
if (acc.a >= 1) return { color: acc, scrim };
|
|
952
|
+
overlays.push(color);
|
|
860
953
|
}
|
|
861
954
|
// Every layer was translucent: the canvas shows through underneath.
|
|
862
955
|
const canvas = canvasColor(doc);
|
|
@@ -1085,8 +1178,37 @@ const overlayKey = (overlays) =>
|
|
|
1085
1178
|
* one. Cached per URL.
|
|
1086
1179
|
*/
|
|
1087
1180
|
const imageRangeCache = new Map();
|
|
1088
|
-
|
|
1089
|
-
|
|
1181
|
+
/** The largest sampling grid an image is read through, per axis. Above it
|
|
1182
|
+
* the read is `reduced` (see sampleGridFor) and callers must not assert a
|
|
1183
|
+
* pass from the range: detail finer than the grid could hide under the
|
|
1184
|
+
* glyphs. A 1024-square read is a million samples, once per image. */
|
|
1185
|
+
const MAX_SAMPLE_AXIS = 1024;
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* How finely to sample an image painted over `extent` CSS pixels: one sample
|
|
1189
|
+
* per four painted pixels, never fewer than the historical 32 per axis, and
|
|
1190
|
+
* capped at MAX_SAMPLE_AXIS. A 32-square read of a 1024px hero averaged
|
|
1191
|
+
* every 32x32 block into one value, so 16px black squares in a white image
|
|
1192
|
+
* (logos, lettering, texture, anything glyph-sized) vanished from the range
|
|
1193
|
+
* and a pass was asserted from pixels that never contained them. Four
|
|
1194
|
+
* painted pixels per sample is finer than a glyph stroke; whatever is
|
|
1195
|
+
* narrower than that is the dither WCAG's relative-luminance Note 4 lets an
|
|
1196
|
+
* average stand for. `reduced` says the cap bit, so the grid is coarser than
|
|
1197
|
+
* that. (2026-08-25 overnight audit, defect 4.)
|
|
1198
|
+
*/
|
|
1199
|
+
export function sampleGridFor(extent) {
|
|
1200
|
+
if (!extent || !(extent.width > 0) || !(extent.height > 0)) return null;
|
|
1201
|
+
const axis = (value) => Math.min(MAX_SAMPLE_AXIS, Math.max(32, Math.ceil(value / 4)));
|
|
1202
|
+
const width = axis(extent.width);
|
|
1203
|
+
const height = axis(extent.height);
|
|
1204
|
+
return { width, height, reduced: extent.width / width > 4.5 || extent.height / height > 4.5 };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export function imageLuminanceRange(url, overlays = [], grid = null) {
|
|
1208
|
+
const width = grid?.width ?? 32;
|
|
1209
|
+
const height = grid?.height ?? 32;
|
|
1210
|
+
const sizeKey = width === 32 && height === 32 ? '' : `|${width}x${height}`;
|
|
1211
|
+
const cacheKey = `${url}${overlays.length ? `|${overlayKey(overlays)}` : ''}${sizeKey}`;
|
|
1090
1212
|
if (imageRangeCache.has(cacheKey)) return imageRangeCache.get(cacheKey);
|
|
1091
1213
|
const promise = new Promise((resolve) => {
|
|
1092
1214
|
const img = new Image();
|
|
@@ -1095,13 +1217,12 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
1095
1217
|
img.onload = () => {
|
|
1096
1218
|
clearTimeout(timer);
|
|
1097
1219
|
try {
|
|
1098
|
-
const size = 32;
|
|
1099
1220
|
const canvas = document.createElement('canvas');
|
|
1100
|
-
canvas.width =
|
|
1101
|
-
canvas.height =
|
|
1221
|
+
canvas.width = width;
|
|
1222
|
+
canvas.height = height;
|
|
1102
1223
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
1103
|
-
ctx.drawImage(img, 0, 0,
|
|
1104
|
-
const data = ctx.getImageData(0, 0,
|
|
1224
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
1225
|
+
const data = ctx.getImageData(0, 0, width, height).data;
|
|
1105
1226
|
let min = 1;
|
|
1106
1227
|
let max = 0;
|
|
1107
1228
|
// The colours at those extremes, kept for the same reason the
|
|
@@ -1143,8 +1264,13 @@ export function imageLuminanceRange(url, overlays = []) {
|
|
|
1143
1264
|
resolve({ transparent: true, width: img.naturalWidth, height: img.naturalHeight });
|
|
1144
1265
|
return;
|
|
1145
1266
|
}
|
|
1267
|
+
// `reduced`: the grid was capped below four painted pixels per sample
|
|
1268
|
+
// AND the image really holds more pixels than the grid, so detail
|
|
1269
|
+
// was averaged away. An icon stretched across a huge box loses
|
|
1270
|
+
// nothing to the cap and is not flagged.
|
|
1271
|
+
const reduced = Boolean(grid?.reduced) && (img.naturalWidth > width || img.naturalHeight > height);
|
|
1146
1272
|
resolve(min <= max
|
|
1147
|
-
? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight }
|
|
1273
|
+
? { min, max, minColor, maxColor, hasAlpha: alphaSeen, width: img.naturalWidth, height: img.naturalHeight, reduced }
|
|
1148
1274
|
: null);
|
|
1149
1275
|
} catch {
|
|
1150
1276
|
resolve(null); // tainted canvas: cross-origin image without CORS headers
|
|
@@ -1260,7 +1386,10 @@ function splitLayers(value) {
|
|
|
1260
1386
|
|
|
1261
1387
|
export function backgroundImagePaintRect(element, intrinsic) {
|
|
1262
1388
|
const style = getComputedStyle(element);
|
|
1263
|
-
|
|
1389
|
+
// Layers are counted at the top level of the list: an SVG data URI that
|
|
1390
|
+
// references a pattern with url(#p) inside itself is still one layer.
|
|
1391
|
+
// (2026-08-25 overnight audit, defect 8.)
|
|
1392
|
+
if (splitLayers(style.backgroundImage).filter((layer) => layer !== 'none').length !== 1) return null;
|
|
1264
1393
|
const box = element.getBoundingClientRect();
|
|
1265
1394
|
return imagePaintRectInBox(
|
|
1266
1395
|
{ left: box.left, top: box.top, right: box.right, bottom: box.bottom },
|
|
@@ -1357,6 +1486,125 @@ export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
|
|
|
1357
1486
|
};
|
|
1358
1487
|
}
|
|
1359
1488
|
|
|
1489
|
+
/** The layers of a computed background-image list, split on top-level
|
|
1490
|
+
* commas only: commas inside url() and gradient() belong to the layer. */
|
|
1491
|
+
export function splitBackgroundLayers(backgroundImageCss) {
|
|
1492
|
+
return splitLayers(backgroundImageCss);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* The URL inside one url() layer, or null when the layer is not a url().
|
|
1497
|
+
* The whole layer is the function call, so the URL runs to the layer's own
|
|
1498
|
+
* closing paren: a regex that stopped at the FIRST ")" cut an SVG data URI
|
|
1499
|
+
* short at the url(#pattern) reference inside it, and the truncated image
|
|
1500
|
+
* then failed to load. (2026-08-25 overnight audit, defect 8.)
|
|
1501
|
+
*/
|
|
1502
|
+
export function backgroundLayerUrl(layer) {
|
|
1503
|
+
const match = /^url\(([\s\S]*)\)$/.exec((layer ?? '').trim());
|
|
1504
|
+
if (!match) return null;
|
|
1505
|
+
let inner = match[1].trim();
|
|
1506
|
+
const quote = inner[0];
|
|
1507
|
+
if ((quote === '"' || quote === "'") && inner.endsWith(quote)) {
|
|
1508
|
+
inner = inner.slice(1, -1).replace(new RegExp(`\\\\${quote}`, 'g'), quote);
|
|
1509
|
+
}
|
|
1510
|
+
return inner;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Tokens that open a gradient argument without naming a colour: angles,
|
|
1514
|
+
* side keywords, shapes, positions and colour hints. */
|
|
1515
|
+
const GRADIENT_NON_COLOR = /^(?:(?:to|at|from|in|circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner)\b|calc\(|-?\d|\.\d)/i;
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* The colour stops of ONE gradient layer, every stop run through parseColor
|
|
1519
|
+
* so modern syntaxes (oklch(), lab(), color()) count as stops instead of
|
|
1520
|
+
* being skipped by an rgb()-only regex. Returns
|
|
1521
|
+
* { colors, unparsed, translucent, space }
|
|
1522
|
+
* where `unparsed` counts stops no parser could read, `translucent` says at
|
|
1523
|
+
* least one stop has alpha below 1 (the gradient reveals what is beneath
|
|
1524
|
+
* it), and `space` names an interpolation colour space other than sRGB when
|
|
1525
|
+
* the gradient declares one (in-between colours cannot then be computed by
|
|
1526
|
+
* sRGB arithmetic). (2026-08-25 overnight audit, defect 8.)
|
|
1527
|
+
*/
|
|
1528
|
+
export function gradientStops(layer) {
|
|
1529
|
+
const result = { colors: [], unparsed: 0, translucent: false, space: null };
|
|
1530
|
+
const open = layer.indexOf('(');
|
|
1531
|
+
const close = layer.lastIndexOf(')');
|
|
1532
|
+
if (open === -1 || close <= open) return result;
|
|
1533
|
+
for (const part of splitLayers(layer.slice(open + 1, close))) {
|
|
1534
|
+
const space = /(?:^|\s)in\s+([a-z0-9-]+)/i.exec(part);
|
|
1535
|
+
if (space && space[1].toLowerCase() !== 'srgb') result.space = space[1].toLowerCase();
|
|
1536
|
+
// The colour token: the first function call in the part (rgb(), oklch(),
|
|
1537
|
+
// color()...), read to its matching paren, or else the leading word.
|
|
1538
|
+
let token = null;
|
|
1539
|
+
const call = /([a-z][a-z0-9-]*)\(/i.exec(part);
|
|
1540
|
+
if (call) {
|
|
1541
|
+
let depth = 0;
|
|
1542
|
+
for (let i = call.index; i < part.length; i++) {
|
|
1543
|
+
if (part[i] === '(') depth += 1;
|
|
1544
|
+
if (part[i] === ')') { depth -= 1; if (depth === 0) { token = part.slice(call.index, i + 1); break; } }
|
|
1545
|
+
}
|
|
1546
|
+
} else {
|
|
1547
|
+
token = part.trim().split(/\s+/)[0] ?? '';
|
|
1548
|
+
}
|
|
1549
|
+
if (!token || GRADIENT_NON_COLOR.test(token)) continue;
|
|
1550
|
+
// currentcolor survives into some computed serialisations and the canvas
|
|
1551
|
+
// probe would happily paint it black: that is not a parse, it is a guess.
|
|
1552
|
+
const color = /^currentcolor$/i.test(token) ? null : parseColor(token);
|
|
1553
|
+
if (!color) { result.unparsed += 1; continue; }
|
|
1554
|
+
if (color.a < 1) result.translucent = true;
|
|
1555
|
+
result.colors.push(color);
|
|
1556
|
+
}
|
|
1557
|
+
return result;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Luminance range of ONE gradient layer, from the colours it actually
|
|
1562
|
+
* paints: the interpolation between each pair of adjacent stops is sampled
|
|
1563
|
+
* at `steps` points, not just its ends. Interpolated colours do NOT stay
|
|
1564
|
+
* inside the stops' luminance range: sRGB interpolation trades the channels
|
|
1565
|
+
* off against each other, and luminance is convex per channel, so a hue-
|
|
1566
|
+
* crossing gradient (red to blue) is darkest in the middle, where black text
|
|
1567
|
+
* that passes at both ends drops to 3.5:1. Sixteen steps per segment
|
|
1568
|
+
* resolves that curve to well within the ratio's second decimal. Returns
|
|
1569
|
+
* null when the stops cannot be sampled (unparseable, translucent, or a
|
|
1570
|
+
* non-sRGB interpolation space); gradientStops() says which.
|
|
1571
|
+
* Positions and colour hints are ignored: they move colours along the
|
|
1572
|
+
* gradient line, they do not add or remove any, so the sampled set is the
|
|
1573
|
+
* painted set (a hard stop paints fewer colours than sampled, which only
|
|
1574
|
+
* ever widens the range, and a wider range asserts less).
|
|
1575
|
+
* (2026-08-25 overnight audit, defect 3.)
|
|
1576
|
+
*/
|
|
1577
|
+
export function sampledGradientRange(layer, overlays = [], steps = 16) {
|
|
1578
|
+
const { colors, unparsed, translucent, space } = gradientStops(layer);
|
|
1579
|
+
if (!colors.length || unparsed || translucent || space) return null;
|
|
1580
|
+
const samples = [];
|
|
1581
|
+
if (colors.length === 1) samples.push(colors[0]);
|
|
1582
|
+
for (let i = 0; i + 1 < colors.length; i++) {
|
|
1583
|
+
const from = colors[i];
|
|
1584
|
+
const to = colors[i + 1];
|
|
1585
|
+
for (let s = 0; s <= steps; s++) {
|
|
1586
|
+
const t = s / steps;
|
|
1587
|
+
samples.push({
|
|
1588
|
+
r: from.r + (to.r - from.r) * t,
|
|
1589
|
+
g: from.g + (to.g - from.g) * t,
|
|
1590
|
+
b: from.b + (to.b - from.b) * t,
|
|
1591
|
+
a: 1,
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
const composited = samples.map((sample) => (overlays.length ? applyOverlays(sample, overlays) : sample));
|
|
1596
|
+
let min = Infinity;
|
|
1597
|
+
let max = -Infinity;
|
|
1598
|
+
let minColor = null;
|
|
1599
|
+
let maxColor = null;
|
|
1600
|
+
for (const sample of composited) {
|
|
1601
|
+
const l = luminance(sample);
|
|
1602
|
+
if (l < min) { min = l; minColor = sample; }
|
|
1603
|
+
if (l > max) { max = l; maxColor = sample; }
|
|
1604
|
+
}
|
|
1605
|
+
return { min, max, minColor, maxColor, sampled: true };
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1360
1608
|
/** Contrast ratio from two luminances. */
|
|
1361
1609
|
function ratioFromLuminance(l1, l2) {
|
|
1362
1610
|
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
@@ -1379,6 +1627,63 @@ export function rangeVerdict(foreground, range, required) {
|
|
|
1379
1627
|
return { verdict: 'mixed', worst, best };
|
|
1380
1628
|
}
|
|
1381
1629
|
|
|
1630
|
+
/**
|
|
1631
|
+
* Does any stylesheet in this tree scope style ::first-line or
|
|
1632
|
+
* ::first-letter at all? Walked once per root per audit (nested rules and
|
|
1633
|
+
* imports included) so the two extra pseudo computed-style reads below are
|
|
1634
|
+
* paid only on pages that can need them. An unreadable cross-origin sheet
|
|
1635
|
+
* counts as "yes": not knowing is a reason to look, never to skip.
|
|
1636
|
+
*/
|
|
1637
|
+
function rootHasFirstLineRules(root) {
|
|
1638
|
+
let has = firstLineRulesCache.get(root);
|
|
1639
|
+
if (has !== undefined) return has;
|
|
1640
|
+
has = false;
|
|
1641
|
+
const scan = (rules) => {
|
|
1642
|
+
for (const rule of rules) {
|
|
1643
|
+
if (rule.selectorText && /::?first-(?:line|letter)\b/.test(rule.selectorText)) return true;
|
|
1644
|
+
const inner = rule.cssRules ?? rule.styleSheet?.cssRules;
|
|
1645
|
+
if (inner && scan(inner)) return true;
|
|
1646
|
+
}
|
|
1647
|
+
return false;
|
|
1648
|
+
};
|
|
1649
|
+
const sheets = [...(root.styleSheets ?? []), ...(root.adoptedStyleSheets ?? [])];
|
|
1650
|
+
for (const sheet of sheets) {
|
|
1651
|
+
try {
|
|
1652
|
+
if (scan(sheet.cssRules)) { has = true; break; }
|
|
1653
|
+
} catch {
|
|
1654
|
+
has = true;
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
firstLineRulesCache.set(root, has);
|
|
1659
|
+
return has;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Colours that repaint part of this element's own text through ::first-line
|
|
1664
|
+
* or ::first-letter, when they differ from the colour the element itself
|
|
1665
|
+
* computes. Each entry is { pseudo, color, style } with the pseudo's computed
|
|
1666
|
+
* style attached, because a drop cap may also change size and weight and so
|
|
1667
|
+
* its own large-scale threshold. Only block containers get a first line of
|
|
1668
|
+
* their own; inline elements are left to their base colour.
|
|
1669
|
+
* (2026-08-25 overnight audit, defect 6.)
|
|
1670
|
+
*/
|
|
1671
|
+
export function pseudoTextColors(element, style) {
|
|
1672
|
+
if (style.display === 'inline' || style.display === 'contents') return [];
|
|
1673
|
+
if (!rootHasFirstLineRules(element.getRootNode())) return [];
|
|
1674
|
+
const paintedColor = (s) => (s.webkitTextFillColor && s.webkitTextFillColor !== s.color ? s.webkitTextFillColor : s.color);
|
|
1675
|
+
const base = paintedColor(style);
|
|
1676
|
+
const found = [];
|
|
1677
|
+
for (const pseudo of ['::first-line', '::first-letter']) {
|
|
1678
|
+
const pseudoStyle = getComputedStyle(element, pseudo);
|
|
1679
|
+
const css = paintedColor(pseudoStyle);
|
|
1680
|
+
if (!css || css === base) continue;
|
|
1681
|
+
const color = parseColor(css);
|
|
1682
|
+
if (color) found.push({ pseudo, color, style: pseudoStyle });
|
|
1683
|
+
}
|
|
1684
|
+
return found;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1382
1687
|
/** WCAG "large text": ≥24px, or ≥18.66px (14pt) bold. */
|
|
1383
1688
|
export function isLargeText(style) {
|
|
1384
1689
|
const size = parseFloat(style.fontSize);
|
package/engine/lib/roles.js
CHANGED
|
@@ -35,6 +35,10 @@ export const GLOBAL_ARIA = new Set([
|
|
|
35
35
|
* usage is ever worth surfacing, it belongs in best-practice, not in a
|
|
36
36
|
* critical 4.1.2 failure. */
|
|
37
37
|
export const ROLE_ARIA = {
|
|
38
|
+
// aria-colindextext / aria-rowindextext are ARIA 1.3 (Chromium maps them)
|
|
39
|
+
// and belong to cell, gridcell, columnheader, rowheader and row. They were
|
|
40
|
+
// in KNOWN_ARIA without a role that owned them, so the engine recognised
|
|
41
|
+
// the attribute and then said no role supports it. 2026-08-25 overnight audit.
|
|
38
42
|
link: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
|
|
39
43
|
button: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'pressed'],
|
|
40
44
|
checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
|
|
@@ -57,12 +61,12 @@ export const ROLE_ARIA = {
|
|
|
57
61
|
heading: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level'],
|
|
58
62
|
list: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
59
63
|
listitem: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level', 'posinset', 'setsize'],
|
|
60
|
-
row: ['activedescendant', 'colindex', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
|
|
64
|
+
row: ['activedescendant', 'colindex', 'colindextext', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'rowindextext', 'selected', 'setsize'],
|
|
61
65
|
rowgroup: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
62
|
-
cell: ['colindex', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowspan'],
|
|
63
|
-
gridcell: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected'],
|
|
64
|
-
columnheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
|
|
65
|
-
rowheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
|
|
66
|
+
cell: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowindextext', 'rowspan'],
|
|
67
|
+
gridcell: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowindextext', 'rowspan', 'selected'],
|
|
68
|
+
columnheader: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowindextext', 'rowspan', 'selected', 'sort'],
|
|
69
|
+
rowheader: ['colindex', 'colindextext', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowindextext', 'rowspan', 'selected', 'sort'],
|
|
66
70
|
table: ['colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowcount'],
|
|
67
71
|
grid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'readonly', 'rowcount'],
|
|
68
72
|
treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
|
|
@@ -124,6 +128,9 @@ const INPUT_ROLES = {
|
|
|
124
128
|
button: 'button', submit: 'button', reset: 'button', image: 'button',
|
|
125
129
|
};
|
|
126
130
|
|
|
131
|
+
/** Input types that become a combobox when a list attribute is present. */
|
|
132
|
+
const DATALIST_TYPES = new Set(['text', 'search', 'tel', 'url', 'email']);
|
|
133
|
+
|
|
127
134
|
const TAG_ROLES = {
|
|
128
135
|
button: 'button', textarea: 'textbox', img: 'img', article: 'article',
|
|
129
136
|
aside: 'complementary', nav: 'navigation', main: 'main',
|
|
@@ -145,7 +152,24 @@ const TAG_ROLES = {
|
|
|
145
152
|
export function implicitRole(element) {
|
|
146
153
|
const tag = element.tagName.toLowerCase();
|
|
147
154
|
if (tag === 'a' || tag === 'area') return element.hasAttribute('href') ? 'link' : 'generic';
|
|
148
|
-
if (tag === 'input')
|
|
155
|
+
if (tag === 'input') {
|
|
156
|
+
// HTML-AAM: a text, search, tel, url or email input WITH a list attribute
|
|
157
|
+
// maps to combobox (Chromium exposes it so), which is the role that
|
|
158
|
+
// supports aria-expanded. 2026-08-25 overnight audit.
|
|
159
|
+
if (DATALIST_TYPES.has(element.type) && element.hasAttribute('list')) return 'combobox';
|
|
160
|
+
return INPUT_ROLES[element.type] ?? null;
|
|
161
|
+
}
|
|
162
|
+
// HTML-AAM: td is cell in a table, gridcell when the ancestor table has
|
|
163
|
+
// role grid or treegrid; th is columnheader or rowheader by its scope in
|
|
164
|
+
// either. The unconditional td=cell mapping reviewed every valid
|
|
165
|
+
// aria-selected on an APG data grid. 2026-08-25 overnight audit.
|
|
166
|
+
if (tag === 'td' || tag === 'th') {
|
|
167
|
+
if (tag === 'th' && element.getAttribute('scope')?.toLowerCase() === 'row') return 'rowheader';
|
|
168
|
+
if (tag === 'th') return 'columnheader';
|
|
169
|
+
const table = element.closest('table');
|
|
170
|
+
const tableRole = table && effectiveRole(table);
|
|
171
|
+
return tableRole === 'grid' || tableRole === 'treegrid' ? 'gridcell' : 'cell';
|
|
172
|
+
}
|
|
149
173
|
if (tag === 'select') return element.multiple || element.size > 1 ? 'listbox' : 'combobox';
|
|
150
174
|
if (tag === 'img') return element.getAttribute('alt') === '' ? 'presentation' : 'img';
|
|
151
175
|
if (tag === 'header') return element.closest('article, aside, main, nav, section') ? 'generic' : 'banner';
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/** The form's default button: the first submit button in tree order (the
|
|
2
|
+
* one implicit submission activates). Reads the form's own descendants;
|
|
3
|
+
* controls associated from outside via form="" are out of this rule's
|
|
4
|
+
* selector anyway. Written as a query, not form.elements, because a
|
|
5
|
+
* control named "elements" shadows that property on the form. */
|
|
6
|
+
function defaultButton(form) {
|
|
7
|
+
return form.querySelector('button, input[type="submit"], input[type="image"]');
|
|
8
|
+
}
|
|
9
|
+
|
|
1
10
|
export default {
|
|
2
11
|
id: 'button-type',
|
|
3
12
|
name: 'Button types in forms',
|
|
@@ -6,10 +15,22 @@ export default {
|
|
|
6
15
|
help: 'Buttons inside forms should declare their type',
|
|
7
16
|
helpUrl: 'https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-type',
|
|
8
17
|
selector: 'form button:not([type]), form button[type=""]',
|
|
9
|
-
evaluate() {
|
|
18
|
+
evaluate(element) {
|
|
19
|
+
// HTML, button type: "The missing value default and invalid value
|
|
20
|
+
// default are both the Submit Button state." That fixes the default; it
|
|
21
|
+
// does not make relying on it an error. When this is the form's only
|
|
22
|
+
// button AND its default button, submitting is its whole purpose and
|
|
23
|
+
// nothing happens by accident, so there is nothing to report. Otherwise
|
|
24
|
+
// the finding states the implicit-default fact and asks for the type
|
|
25
|
+
// to be declared (2026-08-25 overnight audit).
|
|
26
|
+
const form = element.form ?? element.closest('form');
|
|
27
|
+
const buttons = form.querySelectorAll('button');
|
|
28
|
+
if (buttons.length === 1 && buttons[0] === element && defaultButton(form) === element) {
|
|
29
|
+
return { status: 'pass' };
|
|
30
|
+
}
|
|
10
31
|
return {
|
|
11
32
|
status: 'fail',
|
|
12
|
-
message: 'A <button> without type inside a form
|
|
33
|
+
message: 'A <button> without a type inside a form is a submit button by default (the HTML missing value default is the Submit Button state), so activating it submits the form. Declare the type so the intent is explicit.',
|
|
13
34
|
fix: 'Add type="button" (or type="submit" if submission is intended).',
|
|
14
35
|
};
|
|
15
36
|
},
|