pour-engine 1.34.1 → 1.35.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/COVERAGE.md +3 -3
- package/engine/lib/dom.js +16 -0
- package/engine/lib/roles.js +1 -1
- package/engine/rules/best-practice/landmark-unique.js +1 -1
- package/engine/rules/best-practice/region.js +1 -1
- package/engine/rules/index.js +8 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +4 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +1 -1
- package/engine/rules/wcag/2.3.3-reduced-motion.js +170 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +4 -0
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +4 -0
- package/engine/rules/wcag/2.5.8-target-size.js +17 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +71 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +45 -4
- package/engine/wcag22.js +1 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -58,7 +58,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
58
58
|
| 2.2.6 | [Timeouts](https://www.w3.org/WAI/WCAG22/Understanding/timeouts.html) | AAA | Manual review | — |
|
|
59
59
|
| 2.3.1 | [Three Flashes or Below Threshold](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html) | A | Manual review | — |
|
|
60
60
|
| 2.3.2 | [Three Flashes](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes.html) | AAA | Manual review | — |
|
|
61
|
-
| 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA |
|
|
61
|
+
| 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Partially automated | reduced-motion |
|
|
62
62
|
| 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
|
|
63
63
|
| 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
|
|
64
64
|
| 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Partially automated | visual-order-divergence |
|
|
@@ -111,7 +111,7 @@ meaning that only a person can settle, even where a rule decides most real pages
|
|
|
111
111
|
|
|
112
112
|
| SC | Name | Level | Coverage | Rules |
|
|
113
113
|
|---|---|---|---|---|
|
|
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 |
|
|
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, invoker-target |
|
|
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:** 39 partially automated · 47 manual — with 88 rules.
|
package/engine/lib/dom.js
CHANGED
|
@@ -124,6 +124,22 @@ export function isEmbeddedDocument(doc) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** The element's own (not descendant-element) text, trimmed. */
|
|
127
|
+
/**
|
|
128
|
+
* Is this element inside an `inert` subtree? The attribute makes a whole
|
|
129
|
+
* subtree unreachable: nothing in it takes focus, nothing in it accepts a
|
|
130
|
+
* click, and assistive technology skips it (HTML §6.6.7). A rule that
|
|
131
|
+
* reasons about focus order, reachability or pointer targets must treat
|
|
132
|
+
* such elements as absent. Walks up through shadow hosts, because inert on
|
|
133
|
+
* a host inerts its shadow tree too. `inert` is a boolean attribute:
|
|
134
|
+
* presence is what counts, whatever the value.
|
|
135
|
+
*/
|
|
136
|
+
export function isInert(element) {
|
|
137
|
+
for (let node = element; node; node = node.parentElement ?? node.getRootNode?.()?.host ?? null) {
|
|
138
|
+
if (node.nodeType === 1 && node.hasAttribute('inert')) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
export function ownText(element) {
|
|
128
144
|
return [...element.childNodes]
|
|
129
145
|
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
package/engine/lib/roles.js
CHANGED
|
@@ -133,7 +133,7 @@ const DATALIST_TYPES = new Set(['text', 'search', 'tel', 'url', 'email']);
|
|
|
133
133
|
|
|
134
134
|
const TAG_ROLES = {
|
|
135
135
|
button: 'button', textarea: 'textbox', img: 'img', article: 'article',
|
|
136
|
-
aside: 'complementary', nav: 'navigation', main: 'main',
|
|
136
|
+
aside: 'complementary', nav: 'navigation', main: 'main', search: 'search',
|
|
137
137
|
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
|
|
138
138
|
ul: 'list', ol: 'list', menu: 'list', li: 'listitem',
|
|
139
139
|
table: 'table', thead: 'rowgroup', tbody: 'rowgroup', tfoot: 'rowgroup',
|
|
@@ -38,7 +38,7 @@ export default {
|
|
|
38
38
|
// Implicit landmarks (header, footer, labelled section/form) join the
|
|
39
39
|
// grouping — a page's <header> and a div[role="banner"] ARE duplicates.
|
|
40
40
|
selector:
|
|
41
|
-
'nav, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
|
|
41
|
+
'nav, search, aside, header, footer, section[aria-label], section[aria-labelledby], ' +
|
|
42
42
|
'form[aria-label], form[aria-labelledby], [role="navigation"], [role="complementary"], ' +
|
|
43
43
|
'[role="banner"], [role="contentinfo"], [role="region"], [role="search"], [role="form"], [role="main"], main',
|
|
44
44
|
// Judged as a set: two unlabelled navs are the problem, not either alone.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// so a closed dialog cannot make a landmark-less page look landmarked
|
|
11
11
|
// (2026-08-25 overnight audit).
|
|
12
12
|
const LANDMARK =
|
|
13
|
-
'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
|
|
13
|
+
'main, nav, aside, search, form[aria-label], form[aria-labelledby], ' +
|
|
14
14
|
'header:not(:is(article, aside, main, nav, section) header), ' +
|
|
15
15
|
'footer:not(:is(article, aside, main, nav, section) footer), ' +
|
|
16
16
|
'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
|
package/engine/rules/index.js
CHANGED
|
@@ -99,6 +99,12 @@ import audioTranscript from './wcag/1.2.1-audio-transcript.js';
|
|
|
99
99
|
import readingOrderDivergence from './wcag/1.3.2-reading-order-divergence.js';
|
|
100
100
|
import dragAlternative from './wcag/2.5.7-drag-alternative.js';
|
|
101
101
|
import redundantEntry from './wcag/3.3.7-redundant-entry.js';
|
|
102
|
+
// 2026-08-27 platform batch: the HTML invoker attributes (popovertarget,
|
|
103
|
+
// commandfor) get the dangling-reference assert aria-controls already has,
|
|
104
|
+
// and 2.3.3 gains its review question about interaction-driven motion that
|
|
105
|
+
// no stylesheet lets the reader turn off.
|
|
106
|
+
import invokerTarget from './wcag/4.1.2-invoker-target.js';
|
|
107
|
+
import reducedMotion from './wcag/2.3.3-reduced-motion.js';
|
|
102
108
|
import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
|
|
103
109
|
import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
|
|
104
110
|
import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
|
|
@@ -198,6 +204,8 @@ export default [
|
|
|
198
204
|
readingOrderDivergence,
|
|
199
205
|
dragAlternative,
|
|
200
206
|
redundantEntry,
|
|
207
|
+
invokerTarget,
|
|
208
|
+
reducedMotion,
|
|
201
209
|
onInputNavigation,
|
|
202
210
|
errorMessageLinkage,
|
|
203
211
|
compositeWidgetName,
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// (found on carbondesignsystem.com: a scrollable <nav> the rule never saw).
|
|
7
7
|
// The cost stays flat because the overwhelming majority of elements exit on
|
|
8
8
|
// two integer reads before any style or visibility work happens.
|
|
9
|
+
import { isInert } from '../../lib/dom.js';
|
|
10
|
+
|
|
9
11
|
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]';
|
|
10
12
|
|
|
11
13
|
/** Reachable with the Tab key — what actually lets a keyboard user move
|
|
@@ -15,6 +17,8 @@ const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea
|
|
|
15
17
|
* nothing reachable (it auto-focuses the scroller, same as when empty). */
|
|
16
18
|
function isTabbable(element) {
|
|
17
19
|
if (element.tabIndex < 0 || element.disabled) return false;
|
|
20
|
+
// Inert content never takes focus, so it cannot make a region reachable.
|
|
21
|
+
if (isInert(element)) return false;
|
|
18
22
|
if (typeof element.checkVisibility === 'function') {
|
|
19
23
|
return element.checkVisibility({ visibilityProperty: true });
|
|
20
24
|
}
|
|
@@ -28,7 +28,7 @@ const MOTION_PROPERTIES = new Set([
|
|
|
28
28
|
|
|
29
29
|
/** Does this keyframe set move the element around, rather than merely
|
|
30
30
|
* restyle it? A hue cycle is not movement; a translate is. */
|
|
31
|
-
function keyframesMove(keyframes) {
|
|
31
|
+
export function keyframesMove(keyframes) {
|
|
32
32
|
return keyframes.some((frame) => Object.keys(frame).some((property) => MOTION_PROPERTIES.has(property)));
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// WCAG SC 2.3.3 Animation from Interactions (Level AAA)
|
|
2
|
+
// "Motion animation triggered by interaction can be disabled, unless the
|
|
3
|
+
// animation is essential to the functionality or the information being
|
|
4
|
+
// conveyed."
|
|
5
|
+
//
|
|
6
|
+
// The criterion is about movement a person's own action sets off: the page
|
|
7
|
+
// scrolls and things glide, a pointer arrives and a card lifts, a link is
|
|
8
|
+
// followed and the viewport slides. The web's one standard way to let a
|
|
9
|
+
// reader turn such motion off is the prefers-reduced-motion media feature
|
|
10
|
+
// (technique C39): a page that honours it anywhere has a mechanism; one
|
|
11
|
+
// that animates on interaction and never mentions it has none.
|
|
12
|
+
//
|
|
13
|
+
// Three kinds of evidence are readable from the loaded page:
|
|
14
|
+
// - scroll- and view-driven animations (animation-timeline: scroll() /
|
|
15
|
+
// view()), read from the Web Animations API as animations on a timeline
|
|
16
|
+
// other than the document's, whose keyframes move the element;
|
|
17
|
+
// - smooth scrolling (scroll-behavior: smooth) on the root or the body,
|
|
18
|
+
// which turns every in-page link and anchor jump into a glide;
|
|
19
|
+
// - style rules keyed to :hover, :focus, :focus-visible, :focus-within or
|
|
20
|
+
// :active that set a motion property (transform, translate, offset
|
|
21
|
+
// path, position) on a subject that transitions or animates it: an
|
|
22
|
+
// instant snap on hover is a change of place, not motion.
|
|
23
|
+
// Whether any of it is essential is the criterion's own test and a human
|
|
24
|
+
// call, so the rule reviews, never asserts. One question per page, on the
|
|
25
|
+
// root: the finding is about the page's motion policy, not about each
|
|
26
|
+
// element that moves. A stylesheet this audit cannot read (cross-origin
|
|
27
|
+
// without CORS) might be the one carrying the media rule, so the question
|
|
28
|
+
// says so when that is the case, and a page whose only motion sits in
|
|
29
|
+
// unreadable sheets is not asked at all.
|
|
30
|
+
import { keyframesMove } from './2.2.2-pause-stop-hide.js';
|
|
31
|
+
|
|
32
|
+
// Properties whose change on hover or focus is movement. Deliberately
|
|
33
|
+
// narrower than 2.2.2's list: `background` and `margin` shorthands expand
|
|
34
|
+
// to background-position and margin-* in the CSSOM, so a plain hover
|
|
35
|
+
// colour change would read as motion if those were counted.
|
|
36
|
+
const MOTION_CSS = [
|
|
37
|
+
'transform', 'translate', 'rotate', 'scale', 'offset-path', 'offset-distance', 'offset-rotate',
|
|
38
|
+
'left', 'right', 'top', 'bottom',
|
|
39
|
+
];
|
|
40
|
+
const INTERACTION = /:(?:hover|focus-visible|focus-within|focus|active)\b/;
|
|
41
|
+
const INTERACTION_ALL = /:(?:hover|focus-visible|focus-within|focus|active)\b/g;
|
|
42
|
+
const ANIMATES = /\b(?:all|transform|translate|rotate|scale|offset-path|offset-distance|offset-rotate|left|right|top|bottom|inset)\b/;
|
|
43
|
+
|
|
44
|
+
/** Does a hover/focus rule that sets a motion property actually ANIMATE
|
|
45
|
+
* its subject? 2.3.3 is about animation: a transform that snaps into
|
|
46
|
+
* place on hover is an instant change, not motion. The rule itself may
|
|
47
|
+
* declare the transition or animation; more often the resting rule does,
|
|
48
|
+
* so the elements the selector addresses at rest are sampled for a
|
|
49
|
+
* transition that covers a motion property (or `all`) with a duration.
|
|
50
|
+
* An interaction selector that matches nothing on this page moves
|
|
51
|
+
* nothing. Measured on the benchmark corpus: counting every hover
|
|
52
|
+
* transform asked the question of two pages in five, mostly 1px button
|
|
53
|
+
* nudges with no transition; this keeps it to pages that glide. */
|
|
54
|
+
/** Is the movement this rule declares more than a nudge? A transform that
|
|
55
|
+
* shifts its subject under 8px, or scales it within 15%, gives no sense of
|
|
56
|
+
* travel across the page (the criterion's own examples are parallax and
|
|
57
|
+
* elements flying in); counting those asked the question of a page in
|
|
58
|
+
* four for a hover lift on a button. Percentage translates, rotations,
|
|
59
|
+
* offset paths and inset changes always count: their size is unbounded
|
|
60
|
+
* or unknowable from the rule alone. */
|
|
61
|
+
function movesFar(rule) {
|
|
62
|
+
const style = rule.style;
|
|
63
|
+
if (['offset-path', 'offset-distance', 'offset-rotate', 'rotate', 'left', 'right', 'top', 'bottom']
|
|
64
|
+
.some((property) => style.getPropertyValue(property))) return true;
|
|
65
|
+
const values = ['transform', 'translate', 'scale'].map((property) => style.getPropertyValue(property)).join(' ');
|
|
66
|
+
if (/rotate|skew|matrix|%|em|rem|vw|vh/.test(values)) return true;
|
|
67
|
+
const px = [...values.matchAll(/(-?\d*\.?\d+)px/g)].map((m) => Math.abs(parseFloat(m[1])));
|
|
68
|
+
if (px.some((n) => n >= 8)) return true;
|
|
69
|
+
const scales = [...values.matchAll(/scale[XYZ3d]*\(([^)]*)\)/gi)].flatMap((m) => m[1].split(',').map((v) => parseFloat(v)));
|
|
70
|
+
const bareScale = style.getPropertyValue('scale').split(/\s+/).map((v) => parseFloat(v)).filter((v) => !Number.isNaN(v));
|
|
71
|
+
return [...scales, ...bareScale].some((v) => !Number.isNaN(v) && (v < 0.85 || v > 1.15));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rulesAnimate(doc, rule) {
|
|
75
|
+
const own = rule.style.getPropertyValue('transition-duration') || rule.style.getPropertyValue('animation-name');
|
|
76
|
+
if (own && own !== 'none' && own !== '0s') return true;
|
|
77
|
+
const resting = rule.selectorText.replace(INTERACTION_ALL, '').trim();
|
|
78
|
+
if (!resting || /^[,\s]*$/.test(resting)) return false;
|
|
79
|
+
let subjects;
|
|
80
|
+
try { subjects = doc.querySelectorAll(resting); } catch { return false; }
|
|
81
|
+
let seen = 0;
|
|
82
|
+
for (const subject of subjects) {
|
|
83
|
+
if (seen++ >= 5) break;
|
|
84
|
+
const style = doc.defaultView.getComputedStyle(subject);
|
|
85
|
+
const durations = style.transitionDuration.split(',').map((d) => parseFloat(d) || 0);
|
|
86
|
+
if (!durations.some((d) => d > 0)) continue;
|
|
87
|
+
if (ANIMATES.test(style.transitionProperty)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Walk every rule of every readable stylesheet (nested rules and imports
|
|
93
|
+
* included), collecting the two facts this rule needs. */
|
|
94
|
+
function readStyles(doc) {
|
|
95
|
+
const facts = { honours: false, hoverMotion: 0, hoverSelectors: [], unreadable: 0 };
|
|
96
|
+
const scan = (rules) => {
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
const media = rule.media?.mediaText;
|
|
99
|
+
if (media && /prefers-reduced-motion/i.test(media)) facts.honours = true;
|
|
100
|
+
if (rule.selectorText && INTERACTION.test(rule.selectorText) && rule.style
|
|
101
|
+
&& MOTION_CSS.some((property) => rule.style.getPropertyValue(property))
|
|
102
|
+
&& movesFar(rule) && rulesAnimate(doc, rule)) {
|
|
103
|
+
facts.hoverMotion += 1;
|
|
104
|
+
if (facts.hoverSelectors.length < 3) facts.hoverSelectors.push(rule.selectorText.slice(0, 80));
|
|
105
|
+
}
|
|
106
|
+
const inner = rule.cssRules ?? rule.styleSheet?.cssRules;
|
|
107
|
+
if (inner) {
|
|
108
|
+
try { scan(inner); } catch { facts.unreadable += 1; }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const sheets = [...(doc.styleSheets ?? []), ...(doc.adoptedStyleSheets ?? [])];
|
|
113
|
+
for (const sheet of sheets) {
|
|
114
|
+
try { scan(sheet.cssRules); } catch { facts.unreadable += 1; }
|
|
115
|
+
}
|
|
116
|
+
return facts;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Animations that advance with scrolling rather than with time, and move. */
|
|
120
|
+
function scrollDrivenMotion(doc) {
|
|
121
|
+
if (typeof doc.getAnimations !== 'function') return 0;
|
|
122
|
+
let animations;
|
|
123
|
+
try { animations = doc.getAnimations(); } catch { return 0; }
|
|
124
|
+
let count = 0;
|
|
125
|
+
for (const animation of animations) {
|
|
126
|
+
if (!animation.timeline || animation.timeline === doc.timeline) continue;
|
|
127
|
+
let keyframes;
|
|
128
|
+
try { keyframes = animation.effect?.getKeyframes?.() ?? []; } catch { continue; }
|
|
129
|
+
if (keyframesMove(keyframes)) count += 1;
|
|
130
|
+
}
|
|
131
|
+
return count;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default {
|
|
135
|
+
id: 'reduced-motion',
|
|
136
|
+
name: 'Motion honours reduced-motion',
|
|
137
|
+
impact: 'moderate',
|
|
138
|
+
tags: ['wcag21aaa', 'wcag233'],
|
|
139
|
+
help: 'Motion set off by scrolling, hovering or focusing should be switchable off',
|
|
140
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html',
|
|
141
|
+
selector: 'html',
|
|
142
|
+
visibleOnly: false,
|
|
143
|
+
evaluate(element) {
|
|
144
|
+
const doc = element.ownerDocument;
|
|
145
|
+
const win = doc.defaultView;
|
|
146
|
+
const styles = readStyles(doc);
|
|
147
|
+
if (styles.honours) return { status: 'pass' };
|
|
148
|
+
|
|
149
|
+
const evidence = [];
|
|
150
|
+
const scrolling = scrollDrivenMotion(doc);
|
|
151
|
+
if (scrolling) evidence.push(`${scrolling} scroll-driven animation${scrolling === 1 ? '' : 's'} that move${scrolling === 1 ? 's' : ''} content`);
|
|
152
|
+
const smooth = [doc.documentElement, doc.body].some((node) =>
|
|
153
|
+
node && win.getComputedStyle(node).scrollBehavior === 'smooth');
|
|
154
|
+
if (smooth) evidence.push('smooth scrolling on in-page navigation (scroll-behavior: smooth)');
|
|
155
|
+
if (styles.hoverMotion) {
|
|
156
|
+
const more = styles.hoverMotion > styles.hoverSelectors.length ? ', …' : '';
|
|
157
|
+
evidence.push(`${styles.hoverMotion} style rule${styles.hoverMotion === 1 ? '' : 's'} animating movement on hover or focus (${styles.hoverSelectors.join(', ')}${more})`);
|
|
158
|
+
}
|
|
159
|
+
if (!evidence.length) return { status: 'pass' };
|
|
160
|
+
|
|
161
|
+
const unread = styles.unreadable
|
|
162
|
+
? ` (${styles.unreadable} stylesheet${styles.unreadable === 1 ? '' : 's'} could not be read and might carry one)`
|
|
163
|
+
: '';
|
|
164
|
+
return {
|
|
165
|
+
status: 'incomplete',
|
|
166
|
+
message: `This page moves content when people interact with it (${evidence.join('; ')}), and none of the stylesheets this audit could read honours prefers-reduced-motion${unread}. 2.3.3 asks that such motion can be turned off unless it is essential.`,
|
|
167
|
+
fix: 'Put the motion inside @media (prefers-reduced-motion: no-preference), or remove it under @media (prefers-reduced-motion: reduce), keeping only motion that is essential to the function or the information.',
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Sites that apply the spec's own sufficient technique — scroll-padding for
|
|
18
18
|
// the overlay's edge — are not flagged at all.
|
|
19
|
+
import { isInert } from '../../lib/dom.js';
|
|
20
|
+
|
|
19
21
|
const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])';
|
|
20
22
|
|
|
21
23
|
/** Does the scroll container reserve room for an overlay on this edge?
|
|
@@ -80,6 +82,8 @@ export default {
|
|
|
80
82
|
// `:disabled` catches controls disabled by an ancestor fieldset, which
|
|
81
83
|
// the `disabled` property does not reflect — those never take focus.
|
|
82
84
|
if (element.matches(':disabled')) return { status: 'pass' };
|
|
85
|
+
// Inert content cannot be focused, so nothing can obscure its focus.
|
|
86
|
+
if (isInert(element)) return { status: 'pass' };
|
|
83
87
|
const rect = element.getBoundingClientRect();
|
|
84
88
|
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
85
89
|
// Only in-viewport geometry is trustworthy (or cheap).
|
|
@@ -25,8 +25,12 @@
|
|
|
25
25
|
export const FLEXGRID = /^(inline-)?(flex|grid)$/;
|
|
26
26
|
|
|
27
27
|
/** DOM-order focusable children grouped per flex/grid parent. */
|
|
28
|
+
import { isInert } from '../../lib/dom.js';
|
|
29
|
+
|
|
28
30
|
export function tabbable(element) {
|
|
29
31
|
if (element.disabled || element.tabIndex < 0) return false;
|
|
32
|
+
// An inert subtree is skipped by Tab entirely: no sequence to compare.
|
|
33
|
+
if (isInert(element)) return false;
|
|
30
34
|
return true;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -29,6 +29,8 @@ const TARGETS = 'a[href], button, input, select, [role="button"], [role="link"]'
|
|
|
29
29
|
* These are keyboard affordances, not pointer targets — and their phantom
|
|
30
30
|
* rects must not count as "crowding" for the real targets around them.
|
|
31
31
|
*/
|
|
32
|
+
import { isInert } from '../../lib/dom.js';
|
|
33
|
+
|
|
32
34
|
function isHiddenFromPointer(element, rect) {
|
|
33
35
|
if (rect.width <= 1 || rect.height <= 1) return true;
|
|
34
36
|
if (rect.right <= 0 || rect.bottom <= 0) return true; // parked above/left of the canvas
|
|
@@ -321,8 +323,13 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
321
323
|
&& !element.checkVisibility({ contentVisibilityAuto: true, visibilityProperty: true });
|
|
322
324
|
} catch { return false; }
|
|
323
325
|
};
|
|
326
|
+
// Inert targets accept no pointer action at all, so they are neither
|
|
327
|
+
// judged nor counted as crowders (bbc.com/news keeps 28 controls of a
|
|
328
|
+
// closed menu in an inert subtree; the browser ignores every click on
|
|
329
|
+
// them, and so must the size judgment).
|
|
324
330
|
const laidOut = rects.map((r, i) =>
|
|
325
|
-
r.width > 0 && r.height > 0 && !
|
|
331
|
+
r.width > 0 && r.height > 0 && !isInert(elements[i])
|
|
332
|
+
&& !isHiddenFromPointer(elements[i], r) && !layoutSkipped(elements[i]));
|
|
326
333
|
const undersized = rects.map((r, i) => laidOut[i] && (r.width < min || r.height < min));
|
|
327
334
|
// One rect fully inside another is one control drawn twice (stretched-
|
|
328
335
|
// link cards, overlay + inner button) — not two targets crowding.
|
|
@@ -570,6 +577,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
570
577
|
// fragment IS its box, so genuine overlays are dismissed exactly
|
|
571
578
|
// as before.
|
|
572
579
|
if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) return false;
|
|
580
|
+
// Crowding across fixed/sticky contexts is scroll state, not
|
|
581
|
+
// layout, exactly as the obscured-target test reads overlap: a
|
|
582
|
+
// consent banner pinned to the bottom of the viewport lays its
|
|
583
|
+
// "Privacy Policy" link on whichever headline happens to sit
|
|
584
|
+
// under it right now, and that headline has clear space the
|
|
585
|
+
// moment the page scrolls. Asserting it as crowded invents a
|
|
586
|
+
// defect the layout does not have (measured live on two news
|
|
587
|
+
// homes carrying the same consent banner, 2026-08-27).
|
|
588
|
+
if (fixedContextOf(other) !== fixedContextOf(element)) return false;
|
|
573
589
|
const within = (box) => {
|
|
574
590
|
if (undersized[j]) {
|
|
575
591
|
const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// WCAG SC 4.1.2 Name, Role, Value (Level A) — the HTML invoker slice
|
|
2
|
+
//
|
|
3
|
+
// HTML's invoker attributes wire a button to the element it acts on:
|
|
4
|
+
// `popovertarget` opens, closes or toggles a popover, and `commandfor` with
|
|
5
|
+
// a `command` drives a popover or a <dialog> (or a custom `--` command the
|
|
6
|
+
// page handles itself). The browser builds the accessibility relationship
|
|
7
|
+
// from that wiring on its own: the button exposes its expanded state and a
|
|
8
|
+
// relation to what it controls, with no ARIA written by the author.
|
|
9
|
+
//
|
|
10
|
+
// That relationship exists only when the reference resolves. An id that
|
|
11
|
+
// names nothing, a popovertarget aimed at an element with no popover
|
|
12
|
+
// attribute, or show-modal aimed at a <div>, leaves a button that does
|
|
13
|
+
// nothing when activated and exposes no state: the same defect as a
|
|
14
|
+
// dangling aria-controls, which aria-valid-refs asserts, except that here
|
|
15
|
+
// it is the browser's own semantics that go missing. Decidable from the
|
|
16
|
+
// DOM, so asserted.
|
|
17
|
+
//
|
|
18
|
+
// Unknown command keywords are NOT judged: the command vocabulary is still
|
|
19
|
+
// growing (new built-ins ship every few releases), and a keyword this
|
|
20
|
+
// engine has not heard of is not evidence of a fault.
|
|
21
|
+
const POPOVER_COMMANDS = new Set(['toggle-popover', 'show-popover', 'hide-popover']);
|
|
22
|
+
const DIALOG_COMMANDS = new Set(['show-modal', 'close', 'request-close']);
|
|
23
|
+
|
|
24
|
+
export default {
|
|
25
|
+
id: 'invoker-target',
|
|
26
|
+
name: 'Popover and command targets',
|
|
27
|
+
impact: 'serious',
|
|
28
|
+
tags: ['wcag2a', 'wcag412'],
|
|
29
|
+
help: 'popovertarget and commandfor must point at an element they can act on',
|
|
30
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
31
|
+
selector: '[popovertarget], [commandfor]',
|
|
32
|
+
evaluate(element) {
|
|
33
|
+
// Ids resolve within the button's own tree scope (a shadow root's ids
|
|
34
|
+
// are its own), exactly as the browser resolves them.
|
|
35
|
+
const root = element.getRootNode();
|
|
36
|
+
const lookup = (id) => (id && root.getElementById?.(id)) || null;
|
|
37
|
+
const problems = [];
|
|
38
|
+
|
|
39
|
+
if (element.hasAttribute('popovertarget')) {
|
|
40
|
+
const id = element.getAttribute('popovertarget').trim();
|
|
41
|
+
const target = lookup(id);
|
|
42
|
+
if (!target) {
|
|
43
|
+
problems.push(`popovertarget="${id}" names no element in this document`);
|
|
44
|
+
} else if (!target.hasAttribute('popover')) {
|
|
45
|
+
problems.push(`popovertarget="${id}" points at a <${target.tagName.toLowerCase()}> with no popover attribute, which the button cannot open`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (element.hasAttribute('commandfor')) {
|
|
50
|
+
const id = element.getAttribute('commandfor').trim();
|
|
51
|
+
const command = (element.getAttribute('command') ?? '').trim().toLowerCase();
|
|
52
|
+
const target = lookup(id);
|
|
53
|
+
if (!target) {
|
|
54
|
+
problems.push(`commandfor="${id}" names no element in this document`);
|
|
55
|
+
} else if (!command) {
|
|
56
|
+
problems.push('commandfor has no command attribute, so activating the button does nothing');
|
|
57
|
+
} else if (POPOVER_COMMANDS.has(command) && !target.hasAttribute('popover')) {
|
|
58
|
+
problems.push(`command="${command}" needs a popover, and "${id}" has no popover attribute`);
|
|
59
|
+
} else if (DIALOG_COMMANDS.has(command) && target.tagName !== 'DIALOG') {
|
|
60
|
+
problems.push(`command="${command}" needs a <dialog>, and "${id}" is a <${target.tagName.toLowerCase()}>`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!problems.length) return { status: 'pass' };
|
|
65
|
+
return {
|
|
66
|
+
status: 'fail',
|
|
67
|
+
message: `${problems.join('; ')}. Activating this button does nothing, and the expanded state and control relationship the browser would expose for it are missing.`,
|
|
68
|
+
fix: 'Point the attribute at the id of the popover or dialog it drives (give that element the popover attribute, or make it a <dialog>), and keep the id unique in this tree.',
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -22,6 +22,38 @@ const VALID_ROLES = new Set([
|
|
|
22
22
|
'image', 'comment', 'mark', 'suggestion',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
|
+
/** Edit distance between two short tokens (Levenshtein). */
|
|
26
|
+
function editDistance(a, b) {
|
|
27
|
+
const rows = Array.from({ length: a.length + 1 }, (_, i) => [i]);
|
|
28
|
+
for (let j = 1; j <= b.length; j++) rows[0][j] = j;
|
|
29
|
+
for (let i = 1; i <= a.length; i++) {
|
|
30
|
+
for (let j = 1; j <= b.length; j++) {
|
|
31
|
+
rows[i][j] = Math.min(
|
|
32
|
+
rows[i - 1][j] + 1,
|
|
33
|
+
rows[i][j - 1] + 1,
|
|
34
|
+
rows[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rows[a.length][b.length];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The valid role closest to any of the written tokens, when one sits
|
|
42
|
+
* within two edits of it; null when nothing is that close. */
|
|
43
|
+
function nearestRole(tokens) {
|
|
44
|
+
let best = null;
|
|
45
|
+
let bestDistance = 3;
|
|
46
|
+
for (const token of tokens) {
|
|
47
|
+
const t = token.toLowerCase();
|
|
48
|
+
for (const role of VALID_ROLES) {
|
|
49
|
+
if (Math.abs(role.length - t.length) > 2) continue;
|
|
50
|
+
const distance = editDistance(t, role);
|
|
51
|
+
if (distance > 0 && distance < bestDistance) { best = role; bestDistance = distance; }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return best;
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
export default {
|
|
26
58
|
id: 'valid-role',
|
|
27
59
|
name: 'Valid roles',
|
|
@@ -46,6 +78,15 @@ export default {
|
|
|
46
78
|
// canonical harmless case. The failure worth reporting is an invalid
|
|
47
79
|
// role MASKING real semantics or decorating an interactive element.
|
|
48
80
|
const tag = element.tagName.toLowerCase();
|
|
81
|
+
// A near-miss is almost always a typo: name the role that is one or two
|
|
82
|
+
// edits away, so "buton" reads as the misspelling it is rather than as
|
|
83
|
+
// a mystery ("role="buton" isn't really… what?" was the first reaction
|
|
84
|
+
// to the bare message on a seeded fault, 2026-08-27).
|
|
85
|
+
const nearest = nearestRole(tokens);
|
|
86
|
+
const hint = nearest ? ` (did you mean role="${nearest}"?)` : '';
|
|
87
|
+
const fix = nearest
|
|
88
|
+
? `Correct the spelling to role="${nearest}", or remove the attribute to keep the element’s native role.`
|
|
89
|
+
: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.';
|
|
49
90
|
// What the element falls back to when the bogus role is ignored. div/span
|
|
50
91
|
// expose generic; <svg> exposes image (or graphics-document) in every
|
|
51
92
|
// current engine, which is what an author writing role="img"/"image" on it
|
|
@@ -69,14 +110,14 @@ export default {
|
|
|
69
110
|
if (!focusable && !hasAriaProps) {
|
|
70
111
|
return {
|
|
71
112
|
status: 'incomplete',
|
|
72
|
-
message: `role="${element.getAttribute('role')}" is not a valid ARIA role, 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.`,
|
|
73
|
-
fix
|
|
113
|
+
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.`,
|
|
114
|
+
fix,
|
|
74
115
|
};
|
|
75
116
|
}
|
|
76
117
|
return {
|
|
77
118
|
status: 'fail',
|
|
78
|
-
message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
|
|
79
|
-
fix
|
|
119
|
+
message: `role="${element.getAttribute('role')}" is not a valid ARIA role${hint}, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
|
|
120
|
+
fix,
|
|
80
121
|
};
|
|
81
122
|
},
|
|
82
123
|
};
|
package/engine/wcag22.js
CHANGED
|
@@ -66,7 +66,7 @@ export default [
|
|
|
66
66
|
sc('2.2.6', 'Timeouts', 'AAA', 'manual', '2.1'),
|
|
67
67
|
sc('2.3.1', 'Three Flashes or Below Threshold', 'A', 'manual'),
|
|
68
68
|
sc('2.3.2', 'Three Flashes', 'AAA', 'manual'),
|
|
69
|
-
sc('2.3.3', 'Animation from Interactions', 'AAA', '
|
|
69
|
+
sc('2.3.3', 'Animation from Interactions', 'AAA', 'partial', '2.1'), // partial: reduced-motion reads scroll-driven, smooth-scroll and animated hover/focus motion against the page's prefers-reduced-motion rules; whether the motion is essential stays human
|
|
70
70
|
sc('2.4.1', 'Bypass Blocks', 'A', 'partial'), // partial: "repeated across pages" can't be established from one page
|
|
71
71
|
sc('2.4.2', 'Page Titled', 'A', 'partial'), // partial: document-title proves a title exists and catches known placeholders; whether a real one DESCRIBES the page needs a reader
|
|
72
72
|
sc('2.4.3', 'Focus Order', 'A', 'partial'), // visual-order-divergence flags CSS order/*-reverse against DOM order; whether an order preserves MEANING stays human
|
package/package.json
CHANGED