tailwind-a11y 0.13.5 → 0.14.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/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` and no `motion-reduce:`/`motion-safe:` handling |
60
+ | Reduced motion | 2.3.3 (AAA, `--strict` only) | A `hover:`/`focus:`/`focus-visible:`/`active:`-scoped (or their `group-*`/`peer-*` equivalents) `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 `${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`;
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) {
@@ -1,7 +1,34 @@
1
1
  import * as t from "@babel/types";
2
2
  import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
3
3
  const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-transform"]);
4
- const INTERACTION_VARIANTS = new Set(["hover", "focus", "focus-visible", "focus-within", "active"]);
4
+ // group-hover:/peer-hover:/etc. compile to the identical momentary-pseudo-
5
+ // class shape as bare hover:, just evaluated against an ancestor/sibling
6
+ // (.group/.peer marker) instead of the element itself -- verified against a
7
+ // real Tailwind v4 build (`.group-hover\:scale-110:is(:where(.group):hover *)`).
8
+ // Deliberately NOT extended to has-*:/arbitrary variants ([&:hover]:, already
9
+ // an established out-of-scope precedent for this file -- unbounded selector
10
+ // text, not a closed enumerable set) or in-*: (a real v4.1+ ancestor-state
11
+ // variant with the identical shape, but a legitimate separate follow-up, not
12
+ // folded into this set).
13
+ const INTERACTION_VARIANTS = new Set([
14
+ "hover", "focus", "focus-visible", "focus-within", "active",
15
+ "group-hover", "group-focus", "group-focus-visible", "group-focus-within", "group-active",
16
+ "peer-hover", "peer-focus", "peer-focus-visible", "peer-focus-within", "peer-active",
17
+ ]);
18
+ // Named groups/peers (`group-hover/sidebar:scale-110`) compile the group
19
+ // name into the variant token itself via a slash
20
+ // (`.group-hover\/sidebar\:scale-110:is(:where(.group\/sidebar):hover *)`),
21
+ // so variantSegments() returns "group-hover/sidebar" verbatim -- a plain
22
+ // Set.has() would miss it. Safe to slice on the first "/" and re-check:
23
+ // Tailwind's opacity-modifier slash (`text-black/50`) lives inside the base
24
+ // utility segment (after the last ":"), never inside a variant segment, so
25
+ // there's no collision to worry about here.
26
+ function isInteractionVariant(v) {
27
+ if (INTERACTION_VARIANTS.has(v))
28
+ return true;
29
+ const slash = v.indexOf("/");
30
+ return slash !== -1 && INTERACTION_VARIANTS.has(v.slice(0, slash));
31
+ }
5
32
  function baseUtility(raw) {
6
33
  return raw.slice(raw.lastIndexOf(":") + 1);
7
34
  }
@@ -17,6 +44,9 @@ function baseUtility(raw) {
17
44
  function variantSegments(raw) {
18
45
  return raw.split(":").slice(0, -1);
19
46
  }
47
+ function isAnimateBase(base) {
48
+ return base.startsWith("animate-");
49
+ }
20
50
  export function extractReducedMotionChecks(code, filePath) {
21
51
  const ast = parseJSX(code, filePath);
22
52
  if (!ast)
@@ -30,13 +60,33 @@ export function extractReducedMotionChecks(code, filePath) {
30
60
  return;
31
61
  const classes = className.split(/\s+/).filter(Boolean);
32
62
  const hasTransitionBase = classes.some((raw) => TRANSITION_BASES.has(baseUtility(raw)));
33
- const hasInteractionClass = classes.some((raw) => variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v)));
63
+ const hasInteractionClass = classes.some((raw) => variantSegments(raw).some(isInteractionVariant));
64
+ // A second, independent candidacy path for animate-* utilities, which
65
+ // carry their own `animation` property and need no transition-* base
66
+ // at all -- `hover:animate-bounce` alone must be a candidate even
67
+ // though `hasTransitionBase` is false. Deliberately a SINGLE-class
68
+ // condition (one class must be both an animate-* base AND
69
+ // interaction-scoped in its own variant stack), not two independent
70
+ // whole-element flags like hasTransitionBase/hasInteractionClass
71
+ // above: unlike transition-transform (never itself interaction-scoped)
72
+ // paired with a separate hover:scale-110, an animate-* class is
73
+ // simultaneously its own trigger and its own animator. Two independent
74
+ // flags would wrongly treat `animate-spin hover:text-red-500` (an
75
+ // unscoped, continuously-running animation next to an unrelated hover
76
+ // class) as a candidate -- that's 2.2.2 (Pause/Stop/Hide) territory,
77
+ // not 2.3.3, per the same reasoning checkReducedMotion.ts already
78
+ // documents for why unscoped animate-* is out of scope here.
79
+ const hasInteractionScopedAnimate = classes.some((raw) => {
80
+ const base = baseUtility(raw);
81
+ return isAnimateBase(base) && variantSegments(raw).some(isInteractionVariant);
82
+ });
34
83
  // Not a candidate at all unless there's some transition utility
35
- // (scoped or not) *and* some interaction-scoped class -- narrows the
36
- // set of elements checkReducedMotion.ts has to reason about, without
37
- // pre-deciding any of the nuance (unscoped vs motion-safe:, identity
38
- // values, motion-reduce: guards) that belongs in the rule.
39
- if (!hasTransitionBase || !hasInteractionClass)
84
+ // (scoped or not) *and* some interaction-scoped class, OR an
85
+ // interaction-scoped animate-* class -- narrows the set of elements
86
+ // checkReducedMotion.ts has to reason about, without pre-deciding any
87
+ // of the nuance (unscoped vs motion-safe:, identity/non-motion
88
+ // animate-* names, motion-reduce: guards) that belongs in the rule.
89
+ if ((!hasTransitionBase || !hasInteractionClass) && !hasInteractionScopedAnimate)
40
90
  return;
41
91
  checks.push({
42
92
  file: filePath,
@@ -1,11 +1,20 @@
1
1
  import type { ReducedMotionCheck } from "../parser/extractReducedMotion.js";
2
- export interface ReducedMotionViolation {
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[];
@@ -5,7 +5,25 @@
5
5
  // animates (the browser has nothing telling it to transition that
6
6
  // property), and correctly isn't flagged.
7
7
  const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-transform"]);
8
- const INTERACTION_VARIANTS = new Set(["hover", "focus", "focus-visible", "focus-within", "active"]);
8
+ // group-hover:/peer-hover:/etc. compile to the identical momentary-pseudo-
9
+ // class shape as bare hover:, just evaluated against an ancestor/sibling
10
+ // (.group/.peer marker) instead of the element itself -- see the identical
11
+ // set (and full explanation) in extractReducedMotion.ts. Deliberately NOT
12
+ // extended to has-*:/arbitrary variants or in-*: -- see that file's comment.
13
+ const INTERACTION_VARIANTS = new Set([
14
+ "hover", "focus", "focus-visible", "focus-within", "active",
15
+ "group-hover", "group-focus", "group-focus-visible", "group-focus-within", "group-active",
16
+ "peer-hover", "peer-focus", "peer-focus-visible", "peer-focus-within", "peer-active",
17
+ ]);
18
+ // Named groups/peers (`group-hover/sidebar:...`) put the group name in the
19
+ // variant token itself via a slash -- see extractReducedMotion.ts for the
20
+ // full explanation of why slicing on the first "/" is safe here.
21
+ function isInteractionVariant(v) {
22
+ if (INTERACTION_VARIANTS.has(v))
23
+ return true;
24
+ const slash = v.indexOf("/");
25
+ return slash !== -1 && INTERACTION_VARIANTS.has(v.slice(0, slash));
26
+ }
9
27
  function baseUtility(raw) {
10
28
  return raw.slice(raw.lastIndexOf(":") + 1);
11
29
  }
@@ -44,6 +62,17 @@ function isNonIdentityMotionUtility(base) {
44
62
  return Number(skew[2]) !== 0;
45
63
  return false;
46
64
  }
65
+ // Verified against a real Tailwind v4 build's compiled keyframes:
66
+ // animate-spin -> rotate(360deg) (orientation/shape change), animate-ping ->
67
+ // scale(2)+opacity:0 (includes a size change), animate-bounce ->
68
+ // translateY(-25%) (position change) -- all three qualify as "motion
69
+ // animation" per this project's own WCAG 2.3.3 boundary (size/shape/
70
+ // position, not color/opacity/blur). animate-pulse is opacity-only (excluded,
71
+ // same reason color/opacity transitions don't count above) and animate-none
72
+ // is the off/identity value (excluded, same treatment as scale-100/rotate-0).
73
+ // A fully enumerated set, not a regex -- unlike scale/rotate/translate/skew,
74
+ // none of these utilities take an arbitrary numeric value to range-check.
75
+ const ANIMATE_MOTION_BASES = new Set(["animate-spin", "animate-ping", "animate-bounce"]);
47
76
  // `strict` gates the whole check for the scan-everything-by-default
48
77
  // adapters (CLI/VS Code/GitHub Action) -- WCAG 2.3.3 is AAA-only, and
49
78
  // unconditionally enabling a brand-new AAA check would silently start
@@ -88,7 +117,7 @@ export function checkReducedMotion(checks, strict = false) {
88
117
  // `dark:transition hover:scale-110` was silently treated as
89
118
  // compliant even though it animates on hover in dark mode
90
119
  // regardless of the user's motion preference.
91
- const isInteractionGated = segments.some((v) => INTERACTION_VARIANTS.has(v));
120
+ const isInteractionGated = segments.some(isInteractionVariant);
92
121
  const isMotionSafeGated = segments.includes("motion-safe");
93
122
  if (TRANSITION_BASES.has(base) && !isInteractionGated && !isMotionSafeGated)
94
123
  realTransition = raw;
@@ -109,6 +138,39 @@ export function checkReducedMotion(checks, strict = false) {
109
138
  hasMotionReduceGuard = true;
110
139
  }
111
140
  }
141
+ // Independent detection path for the animate-* mechanism -- deliberately
142
+ // NOT nested after the transition path's `continue`s below, since
143
+ // `hover:animate-bounce` alone has no transition base at all
144
+ // (realTransition stays null), which would skip past this block entirely
145
+ // if it were placed after the `if (!realTransition) continue;` line. A
146
+ // single element can have a real violation on both mechanisms at once
147
+ // (independently pushed), or on just one -- they don't race for one slot.
148
+ const hasMotionReduceAnimateGuard = check.classes.some((raw) => {
149
+ const segments = variantSegments(raw);
150
+ return segments.length === 1 && segments[0] === "motion-reduce" && baseUtility(raw) === "animate-none";
151
+ });
152
+ const animateMotionClass = check.classes.find((raw) => {
153
+ const segments = variantSegments(raw);
154
+ // Same self-guard and interaction-scoping reasoning as the transition
155
+ // side's motionClass find below -- see its comment for the full
156
+ // explanation of why both checks are per-candidate, not per-element.
157
+ if (segments.includes("motion-safe"))
158
+ return false;
159
+ if (!segments.some(isInteractionVariant))
160
+ return false;
161
+ return ANIMATE_MOTION_BASES.has(baseUtility(raw));
162
+ });
163
+ if (animateMotionClass && !hasMotionReduceAnimateGuard) {
164
+ violations.push({
165
+ type: "reduced-motion",
166
+ mechanism: "animate",
167
+ file: check.file,
168
+ line: check.line,
169
+ tagName: check.tagName,
170
+ motionClass: animateMotionClass,
171
+ level: "AAA",
172
+ });
173
+ }
112
174
  // No real (non-motion-safe-guarded) transition at all means it simply
113
175
  // doesn't exist unless motion is already safe -- a complete
114
176
  // alternative way of satisfying 2.3.3, not a partial one -- so this
@@ -127,7 +189,7 @@ export function checkReducedMotion(checks, strict = false) {
127
189
  // self-guarded at the same time, e.g. `hover:motion-safe:scale-110`).
128
190
  if (segments.includes("motion-safe"))
129
191
  return false;
130
- if (!segments.some((v) => INTERACTION_VARIANTS.has(v)))
192
+ if (!segments.some(isInteractionVariant))
131
193
  return false;
132
194
  return isNonIdentityMotionUtility(baseUtility(raw));
133
195
  });
@@ -135,6 +197,7 @@ export function checkReducedMotion(checks, strict = false) {
135
197
  continue; // no real, un-self-guarded motion actually triggered by interaction
136
198
  violations.push({
137
199
  type: "reduced-motion",
200
+ mechanism: "transition",
138
201
  file: check.file,
139
202
  line: check.line,
140
203
  tagName: check.tagName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.13.5",
3
+ "version": "0.14.1",
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": {