tailwind-a11y 0.13.5 → 0.14.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/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
57
57
|
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px — or 44×44px with `--strict` (2.5.5, AAA) |
|
|
58
58
|
| Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
|
|
59
59
|
| Focus indicator contrast | 1.4.11 (AA) | A present `outline-*`/`ring-*` focus indicator below 3:1 contrast — or also below the 2px minimum thickness with `--strict` (2.4.13, AAA) |
|
|
60
|
-
| Reduced motion | 2.3.3 (AAA, `--strict` only) | A `hover:`/`focus:`/`focus-visible:`/`active:`-scoped `scale-*`/`rotate-*`/`translate-*`/`skew-*` change with an unscoped `transition`/`transition-all`/`transition-transform`
|
|
60
|
+
| Reduced motion | 2.3.3 (AAA, `--strict` only) | A `hover:`/`focus:`/`focus-visible:`/`active:`-scoped `scale-*`/`rotate-*`/`translate-*`/`skew-*` change with an unscoped `transition`/`transition-all`/`transition-transform`, or an `animate-spin`/`-ping`/`-bounce` under the same variants, with no `motion-reduce:`/`motion-safe:` handling |
|
|
61
61
|
|
|
62
62
|
## Scope
|
|
63
63
|
|
package/dist/cli.js
CHANGED
|
@@ -36,7 +36,9 @@ function formatViolation(v) {
|
|
|
36
36
|
: base;
|
|
37
37
|
}
|
|
38
38
|
case "reduced-motion":
|
|
39
|
-
return
|
|
39
|
+
return v.mechanism === "animate"
|
|
40
|
+
? `${v.line}: <${v.tagName}> animates ${v.motionClass} via a CSS animation with no motion-reduce:animate-none guard — WCAG 2.3.3 requires motion animation triggered by interaction to be disableable`
|
|
41
|
+
: `${v.line}: <${v.tagName}> animates ${v.motionClass} via ${v.transitionClass} with no motion-reduce:transition-none/transform-none guard — WCAG 2.3.3 requires motion animation triggered by interaction to be disableable`;
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
function groupByFile(items) {
|
|
@@ -17,6 +17,9 @@ function baseUtility(raw) {
|
|
|
17
17
|
function variantSegments(raw) {
|
|
18
18
|
return raw.split(":").slice(0, -1);
|
|
19
19
|
}
|
|
20
|
+
function isAnimateBase(base) {
|
|
21
|
+
return base.startsWith("animate-");
|
|
22
|
+
}
|
|
20
23
|
export function extractReducedMotionChecks(code, filePath) {
|
|
21
24
|
const ast = parseJSX(code, filePath);
|
|
22
25
|
if (!ast)
|
|
@@ -31,12 +34,32 @@ export function extractReducedMotionChecks(code, filePath) {
|
|
|
31
34
|
const classes = className.split(/\s+/).filter(Boolean);
|
|
32
35
|
const hasTransitionBase = classes.some((raw) => TRANSITION_BASES.has(baseUtility(raw)));
|
|
33
36
|
const hasInteractionClass = classes.some((raw) => variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v)));
|
|
37
|
+
// A second, independent candidacy path for animate-* utilities, which
|
|
38
|
+
// carry their own `animation` property and need no transition-* base
|
|
39
|
+
// at all -- `hover:animate-bounce` alone must be a candidate even
|
|
40
|
+
// though `hasTransitionBase` is false. Deliberately a SINGLE-class
|
|
41
|
+
// condition (one class must be both an animate-* base AND
|
|
42
|
+
// interaction-scoped in its own variant stack), not two independent
|
|
43
|
+
// whole-element flags like hasTransitionBase/hasInteractionClass
|
|
44
|
+
// above: unlike transition-transform (never itself interaction-scoped)
|
|
45
|
+
// paired with a separate hover:scale-110, an animate-* class is
|
|
46
|
+
// simultaneously its own trigger and its own animator. Two independent
|
|
47
|
+
// flags would wrongly treat `animate-spin hover:text-red-500` (an
|
|
48
|
+
// unscoped, continuously-running animation next to an unrelated hover
|
|
49
|
+
// class) as a candidate -- that's 2.2.2 (Pause/Stop/Hide) territory,
|
|
50
|
+
// not 2.3.3, per the same reasoning checkReducedMotion.ts already
|
|
51
|
+
// documents for why unscoped animate-* is out of scope here.
|
|
52
|
+
const hasInteractionScopedAnimate = classes.some((raw) => {
|
|
53
|
+
const base = baseUtility(raw);
|
|
54
|
+
return isAnimateBase(base) && variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v));
|
|
55
|
+
});
|
|
34
56
|
// Not a candidate at all unless there's some transition utility
|
|
35
|
-
// (scoped or not) *and* some interaction-scoped class
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
57
|
+
// (scoped or not) *and* some interaction-scoped class, OR an
|
|
58
|
+
// interaction-scoped animate-* class -- narrows the set of elements
|
|
59
|
+
// checkReducedMotion.ts has to reason about, without pre-deciding any
|
|
60
|
+
// of the nuance (unscoped vs motion-safe:, identity/non-motion
|
|
61
|
+
// animate-* names, motion-reduce: guards) that belongs in the rule.
|
|
62
|
+
if ((!hasTransitionBase || !hasInteractionClass) && !hasInteractionScopedAnimate)
|
|
40
63
|
return;
|
|
41
64
|
checks.push({
|
|
42
65
|
file: filePath,
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { ReducedMotionCheck } from "../parser/extractReducedMotion.js";
|
|
2
|
-
export
|
|
2
|
+
export type ReducedMotionViolation = {
|
|
3
3
|
type: "reduced-motion";
|
|
4
|
+
mechanism: "transition";
|
|
4
5
|
file: string;
|
|
5
6
|
line: number;
|
|
6
7
|
tagName: string;
|
|
7
8
|
transitionClass: string;
|
|
8
9
|
motionClass: string;
|
|
9
10
|
level: "AAA";
|
|
10
|
-
}
|
|
11
|
+
} | {
|
|
12
|
+
type: "reduced-motion";
|
|
13
|
+
mechanism: "animate";
|
|
14
|
+
file: string;
|
|
15
|
+
line: number;
|
|
16
|
+
tagName: string;
|
|
17
|
+
motionClass: string;
|
|
18
|
+
level: "AAA";
|
|
19
|
+
};
|
|
11
20
|
export declare function checkReducedMotion(checks: ReducedMotionCheck[], strict?: boolean): ReducedMotionViolation[];
|
|
@@ -44,6 +44,17 @@ function isNonIdentityMotionUtility(base) {
|
|
|
44
44
|
return Number(skew[2]) !== 0;
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
|
+
// Verified against a real Tailwind v4 build's compiled keyframes:
|
|
48
|
+
// animate-spin -> rotate(360deg) (orientation/shape change), animate-ping ->
|
|
49
|
+
// scale(2)+opacity:0 (includes a size change), animate-bounce ->
|
|
50
|
+
// translateY(-25%) (position change) -- all three qualify as "motion
|
|
51
|
+
// animation" per this project's own WCAG 2.3.3 boundary (size/shape/
|
|
52
|
+
// position, not color/opacity/blur). animate-pulse is opacity-only (excluded,
|
|
53
|
+
// same reason color/opacity transitions don't count above) and animate-none
|
|
54
|
+
// is the off/identity value (excluded, same treatment as scale-100/rotate-0).
|
|
55
|
+
// A fully enumerated set, not a regex -- unlike scale/rotate/translate/skew,
|
|
56
|
+
// none of these utilities take an arbitrary numeric value to range-check.
|
|
57
|
+
const ANIMATE_MOTION_BASES = new Set(["animate-spin", "animate-ping", "animate-bounce"]);
|
|
47
58
|
// `strict` gates the whole check for the scan-everything-by-default
|
|
48
59
|
// adapters (CLI/VS Code/GitHub Action) -- WCAG 2.3.3 is AAA-only, and
|
|
49
60
|
// unconditionally enabling a brand-new AAA check would silently start
|
|
@@ -109,6 +120,39 @@ export function checkReducedMotion(checks, strict = false) {
|
|
|
109
120
|
hasMotionReduceGuard = true;
|
|
110
121
|
}
|
|
111
122
|
}
|
|
123
|
+
// Independent detection path for the animate-* mechanism -- deliberately
|
|
124
|
+
// NOT nested after the transition path's `continue`s below, since
|
|
125
|
+
// `hover:animate-bounce` alone has no transition base at all
|
|
126
|
+
// (realTransition stays null), which would skip past this block entirely
|
|
127
|
+
// if it were placed after the `if (!realTransition) continue;` line. A
|
|
128
|
+
// single element can have a real violation on both mechanisms at once
|
|
129
|
+
// (independently pushed), or on just one -- they don't race for one slot.
|
|
130
|
+
const hasMotionReduceAnimateGuard = check.classes.some((raw) => {
|
|
131
|
+
const segments = variantSegments(raw);
|
|
132
|
+
return segments.length === 1 && segments[0] === "motion-reduce" && baseUtility(raw) === "animate-none";
|
|
133
|
+
});
|
|
134
|
+
const animateMotionClass = check.classes.find((raw) => {
|
|
135
|
+
const segments = variantSegments(raw);
|
|
136
|
+
// Same self-guard and interaction-scoping reasoning as the transition
|
|
137
|
+
// side's motionClass find below -- see its comment for the full
|
|
138
|
+
// explanation of why both checks are per-candidate, not per-element.
|
|
139
|
+
if (segments.includes("motion-safe"))
|
|
140
|
+
return false;
|
|
141
|
+
if (!segments.some((v) => INTERACTION_VARIANTS.has(v)))
|
|
142
|
+
return false;
|
|
143
|
+
return ANIMATE_MOTION_BASES.has(baseUtility(raw));
|
|
144
|
+
});
|
|
145
|
+
if (animateMotionClass && !hasMotionReduceAnimateGuard) {
|
|
146
|
+
violations.push({
|
|
147
|
+
type: "reduced-motion",
|
|
148
|
+
mechanism: "animate",
|
|
149
|
+
file: check.file,
|
|
150
|
+
line: check.line,
|
|
151
|
+
tagName: check.tagName,
|
|
152
|
+
motionClass: animateMotionClass,
|
|
153
|
+
level: "AAA",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
112
156
|
// No real (non-motion-safe-guarded) transition at all means it simply
|
|
113
157
|
// doesn't exist unless motion is already safe -- a complete
|
|
114
158
|
// alternative way of satisfying 2.3.3, not a partial one -- so this
|
|
@@ -135,6 +179,7 @@ export function checkReducedMotion(checks, strict = false) {
|
|
|
135
179
|
continue; // no real, un-self-guarded motion actually triggered by interaction
|
|
136
180
|
violations.push({
|
|
137
181
|
type: "reduced-motion",
|
|
182
|
+
mechanism: "transition",
|
|
138
183
|
file: check.file,
|
|
139
184
|
line: check.line,
|
|
140
185
|
tagName: check.tagName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal/contrast — in Tailwind CSS class combinations before they ship.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|