tailwind-a11y 0.14.0 → 0.15.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
|
@@ -53,11 +53,11 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
53
53
|
|
|
54
54
|
| Check | WCAG | Detects |
|
|
55
55
|
|---|---|---|
|
|
56
|
-
| Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent, including a text-side opacity modifier (`text-gray-400/50`) composited against the background; suggests the nearest passing shade |
|
|
56
|
+
| Contrast | 1.4.3 (AA) | `text-*`/`bg-*` pairs below 4.5:1, same-element or direct-parent, including a text-side opacity modifier (`text-gray-400/50`) composited against the background; also checks `placeholder:text-*` on `<input>`/`<textarea>`; suggests the nearest passing shade |
|
|
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`, or an `animate-spin`/`-ping`/`-bounce` under the same variants, with 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
|
|
|
@@ -23,6 +23,16 @@ export const COLOR_TOKEN = /^\[(#[0-9a-fA-F]{3,8})\](\/\d{1,3})?$|^[a-z]+-\d{2,3
|
|
|
23
23
|
// the same failure mode: bg-linear-45 shares the word-number shape with
|
|
24
24
|
// bg-red-500 and would otherwise mask it via last-token-wins.
|
|
25
25
|
const NON_COLOR_SCALE_NAMES = new Set(["opacity", "linear", "conic"]);
|
|
26
|
+
// One definition, not two hand-mirrored copies (lastColorToken and
|
|
27
|
+
// lastPlaceholderColorToken both need this exact test) -- the same "one
|
|
28
|
+
// definition, not a second copy that could drift" reasoning already applied
|
|
29
|
+
// to COLOR_TOKEN itself.
|
|
30
|
+
function isColorScaleToken(rest) {
|
|
31
|
+
if (!COLOR_TOKEN.test(rest))
|
|
32
|
+
return false;
|
|
33
|
+
const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
|
|
34
|
+
return !(scaleName && NON_COLOR_SCALE_NAMES.has(scaleName));
|
|
35
|
+
}
|
|
26
36
|
export function lastColorToken(className, prefix) {
|
|
27
37
|
let found = null;
|
|
28
38
|
for (const raw of className.split(/\s+/).filter(Boolean)) {
|
|
@@ -42,15 +52,72 @@ export function lastColorToken(className, prefix) {
|
|
|
42
52
|
if (!raw.startsWith(`${prefix}-`))
|
|
43
53
|
continue;
|
|
44
54
|
const rest = raw.slice(prefix.length + 1);
|
|
45
|
-
if (!
|
|
55
|
+
if (!isColorScaleToken(rest))
|
|
56
|
+
continue;
|
|
57
|
+
found = raw;
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
// placeholder:text-* targets the ::placeholder pseudo-element -- a
|
|
62
|
+
// genuinely different rendered text than the element's own resting text
|
|
63
|
+
// color, so it's checked as an independent candidate, not folded into
|
|
64
|
+
// lastColorToken. Deliberately the exact two-segment shape only
|
|
65
|
+
// (raw.split(":") === ["placeholder", "text-..."]) -- a nested shape like
|
|
66
|
+
// dark:placeholder:text-gray-500 is NOT recognized. This isn't a narrower-
|
|
67
|
+
// for-now cut, it's the only choice consistent with how lastColorToken
|
|
68
|
+
// already treats every other variant-scoped color candidate in this same
|
|
69
|
+
// file: skip outright rather than guess which persistent condition is
|
|
70
|
+
// active. Returns the full raw string including the "placeholder:" prefix
|
|
71
|
+
// -- checkContrast.ts's resolveColorValue/suggestContrastFix tolerate the
|
|
72
|
+
// prefix directly, so every downstream message/skip-reason/suggestion
|
|
73
|
+
// already reads correctly with zero further changes.
|
|
74
|
+
function lastPlaceholderColorToken(className) {
|
|
75
|
+
let found = null;
|
|
76
|
+
for (const raw of className.split(/\s+/).filter(Boolean)) {
|
|
77
|
+
const segments = raw.split(":");
|
|
78
|
+
if (segments.length !== 2 || segments[0] !== "placeholder")
|
|
46
79
|
continue;
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
80
|
+
const base = segments[1];
|
|
81
|
+
if (!base.startsWith("text-"))
|
|
82
|
+
continue;
|
|
83
|
+
const rest = base.slice("text-".length);
|
|
84
|
+
if (!isColorScaleToken(rest))
|
|
49
85
|
continue;
|
|
50
86
|
found = raw;
|
|
51
87
|
}
|
|
52
88
|
return found;
|
|
53
89
|
}
|
|
90
|
+
// ::placeholder only exists on <input>/<textarea> in any browser -- a
|
|
91
|
+
// placeholder:text-* class on any other tag is not "maybe irrelevant," it
|
|
92
|
+
// is dead CSS, guaranteed never to render. Unlike reduced-motion's
|
|
93
|
+
// deliberate "not scoped to isInteractiveElement()" choice (a hover-
|
|
94
|
+
// animated <div> genuinely animates), tag-scoping here prevents a
|
|
95
|
+
// guaranteed false positive rather than narrowing a genuine one. A small
|
|
96
|
+
// local set, not a reuse of isInteractiveElement() -- that helper also
|
|
97
|
+
// matches button/a/select and any onClick-bearing element, none of which
|
|
98
|
+
// can render a placeholder.
|
|
99
|
+
const PLACEHOLDER_CAPABLE_TAGS = new Set(["input", "textarea"]);
|
|
100
|
+
function isPlaceholderCapable(openingElement) {
|
|
101
|
+
return (t.isJSXIdentifier(openingElement.name) && PLACEHOLDER_CAPABLE_TAGS.has(openingElement.name.name));
|
|
102
|
+
}
|
|
103
|
+
// Resolves an element's background exactly once (self, else immediate JSX
|
|
104
|
+
// parent — see extractChecks' own scope note) so both the resting-text and
|
|
105
|
+
// placeholder candidates share one bg/bgSource pair rather than each
|
|
106
|
+
// re-walking the parent chain independently.
|
|
107
|
+
function resolveBg(path) {
|
|
108
|
+
const className = getStaticClassName(path.node.openingElement.attributes);
|
|
109
|
+
const ownBg = className ? lastColorToken(className, "bg") : null;
|
|
110
|
+
if (ownBg)
|
|
111
|
+
return { bg: ownBg, source: "self" };
|
|
112
|
+
const parentNode = path.parentPath?.node;
|
|
113
|
+
if (parentNode && t.isJSXElement(parentNode)) {
|
|
114
|
+
const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
|
|
115
|
+
const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
|
|
116
|
+
if (parentBg)
|
|
117
|
+
return { bg: parentBg, source: "parent" };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
54
121
|
export function extractChecks(code, filePath) {
|
|
55
122
|
const ast = parseJSX(code, filePath);
|
|
56
123
|
if (!ast)
|
|
@@ -62,23 +129,22 @@ export function extractChecks(code, filePath) {
|
|
|
62
129
|
if (!className)
|
|
63
130
|
return;
|
|
64
131
|
const textClass = lastColorToken(className, "text");
|
|
65
|
-
|
|
132
|
+
const placeholderClass = isPlaceholderCapable(path.node.openingElement)
|
|
133
|
+
? lastPlaceholderColorToken(className)
|
|
134
|
+
: null;
|
|
135
|
+
if (!textClass && !placeholderClass)
|
|
66
136
|
return;
|
|
67
137
|
const line = path.node.openingElement.loc?.start.line ?? 0;
|
|
68
|
-
const ownBg = lastColorToken(className, "bg");
|
|
69
|
-
if (ownBg) {
|
|
70
|
-
checks.push({ file: filePath, line, textColorClass: textClass, bgColorClass: ownBg, bgSource: "self" });
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
138
|
// Only the immediate JSX parent is considered — no deeper ancestor
|
|
74
139
|
// walk and no cross-component resolution (see CLAUDE.md scope).
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
140
|
+
const bg = resolveBg(path);
|
|
141
|
+
if (!bg)
|
|
142
|
+
return;
|
|
143
|
+
if (textClass) {
|
|
144
|
+
checks.push({ file: filePath, line, textColorClass: textClass, bgColorClass: bg.bg, bgSource: bg.source });
|
|
145
|
+
}
|
|
146
|
+
if (placeholderClass) {
|
|
147
|
+
checks.push({ file: filePath, line, textColorClass: placeholderClass, bgColorClass: bg.bg, bgSource: bg.source });
|
|
82
148
|
}
|
|
83
149
|
},
|
|
84
150
|
});
|
|
@@ -101,35 +167,37 @@ export function extractContrastSkips(code, filePath) {
|
|
|
101
167
|
if (!className)
|
|
102
168
|
return;
|
|
103
169
|
const textClass = lastColorToken(className, "text");
|
|
104
|
-
|
|
170
|
+
const placeholderClass = isPlaceholderCapable(path.node.openingElement)
|
|
171
|
+
? lastPlaceholderColorToken(className)
|
|
172
|
+
: null;
|
|
173
|
+
if (!textClass && !placeholderClass)
|
|
105
174
|
return;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return; // extractChecks already covers this case
|
|
175
|
+
if (resolveBg(path))
|
|
176
|
+
return; // extractChecks already covers this case, for either candidate
|
|
109
177
|
const line = path.node.openingElement.loc?.start.line ?? 0;
|
|
110
178
|
const parentNode = path.parentPath?.node;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return; // extractChecks already covers this case
|
|
116
|
-
const parentTag = t.isJSXIdentifier(parentNode.openingElement.name)
|
|
117
|
-
? parentNode.openingElement.name.name
|
|
118
|
-
: null;
|
|
179
|
+
const parentTag = parentNode && t.isJSXElement(parentNode) && t.isJSXIdentifier(parentNode.openingElement.name)
|
|
180
|
+
? parentNode.openingElement.name.name
|
|
181
|
+
: null;
|
|
182
|
+
const reportSkip = (candidateClass) => {
|
|
119
183
|
if (parentTag && /^[A-Z]/.test(parentTag)) {
|
|
120
184
|
skips.push({
|
|
121
185
|
file: filePath,
|
|
122
186
|
line,
|
|
123
|
-
reason: `${
|
|
187
|
+
reason: `${candidateClass} — background may be set inside <${parentTag}>, which this tool doesn't inspect across component boundaries`,
|
|
124
188
|
});
|
|
125
189
|
return;
|
|
126
190
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
191
|
+
skips.push({
|
|
192
|
+
file: filePath,
|
|
193
|
+
line,
|
|
194
|
+
reason: `${candidateClass} — no background utility found on this element or its immediate parent`,
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
if (textClass)
|
|
198
|
+
reportSkip(textClass);
|
|
199
|
+
if (placeholderClass)
|
|
200
|
+
reportSkip(placeholderClass);
|
|
133
201
|
},
|
|
134
202
|
});
|
|
135
203
|
return skips;
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -33,7 +60,7 @@ export function extractReducedMotionChecks(code, filePath) {
|
|
|
33
60
|
return;
|
|
34
61
|
const classes = className.split(/\s+/).filter(Boolean);
|
|
35
62
|
const hasTransitionBase = classes.some((raw) => TRANSITION_BASES.has(baseUtility(raw)));
|
|
36
|
-
const hasInteractionClass = classes.some((raw) => variantSegments(raw).some(
|
|
63
|
+
const hasInteractionClass = classes.some((raw) => variantSegments(raw).some(isInteractionVariant));
|
|
37
64
|
// A second, independent candidacy path for animate-* utilities, which
|
|
38
65
|
// carry their own `animation` property and need no transition-* base
|
|
39
66
|
// at all -- `hover:animate-bounce` alone must be a candidate even
|
|
@@ -51,7 +78,7 @@ export function extractReducedMotionChecks(code, filePath) {
|
|
|
51
78
|
// documents for why unscoped animate-* is out of scope here.
|
|
52
79
|
const hasInteractionScopedAnimate = classes.some((raw) => {
|
|
53
80
|
const base = baseUtility(raw);
|
|
54
|
-
return isAnimateBase(base) && variantSegments(raw).some(
|
|
81
|
+
return isAnimateBase(base) && variantSegments(raw).some(isInteractionVariant);
|
|
55
82
|
});
|
|
56
83
|
// Not a candidate at all unless there's some transition utility
|
|
57
84
|
// (scoped or not) *and* some interaction-scoped class, OR an
|
|
@@ -3,8 +3,14 @@ import { defaultPalette, semanticColors } from "../theme/defaultPalette.js";
|
|
|
3
3
|
export function resolveColorValue(utilityClass, palette = defaultPalette) {
|
|
4
4
|
// outline/ring are here for checkFocusIndicator.ts's non-text-contrast
|
|
5
5
|
// check (WCAG 1.4.11/2.4.13) -- same palette/arbitrary-hex/semantic-color
|
|
6
|
-
// resolution as text/bg, just a different utility prefix.
|
|
7
|
-
|
|
6
|
+
// resolution as text/bg, just a different utility prefix. The optional
|
|
7
|
+
// leading "placeholder:" tolerates extractClasses.ts's
|
|
8
|
+
// lastPlaceholderColorToken, which deliberately keeps that prefix on the
|
|
9
|
+
// class string it returns -- harmless to accept on all four prefixes
|
|
10
|
+
// here since extraction only ever produces "placeholder:text-*", never
|
|
11
|
+
// "placeholder:bg-*"/etc., and checkFocusIndicator.ts's own call sites
|
|
12
|
+
// can never pass a placeholder:-prefixed string into this function.
|
|
13
|
+
const match = /^(?:placeholder:)?(?:text|bg|outline|ring)-(.+)$/.exec(utilityClass);
|
|
8
14
|
if (!match)
|
|
9
15
|
return null;
|
|
10
16
|
const token = match[1];
|
|
@@ -86,7 +92,10 @@ export function checkContrast(checks, palette = defaultPalette) {
|
|
|
86
92
|
}
|
|
87
93
|
return violations;
|
|
88
94
|
}
|
|
89
|
-
|
|
95
|
+
// Optional leading "placeholder:" tolerated for the same reason as
|
|
96
|
+
// resolveColorValue's regex above -- suggestContrastFix re-prepends it to
|
|
97
|
+
// the suggested class below so the suggestion stays copy-pasteable.
|
|
98
|
+
const TEXT_SCALE_SHADE_RE = /^(?:placeholder:)?text-([a-z]+)-(\d+)$/;
|
|
90
99
|
// Only the text shade moves — bg and any opacity modifier on the text class
|
|
91
100
|
// stay fixed, since text color is the more commonly adjustable side in
|
|
92
101
|
// practice. Candidates come from the palette's actual keys (not an assumed
|
|
@@ -104,6 +113,7 @@ export function suggestContrastFix(textClass, bgClass, required, palette = defau
|
|
|
104
113
|
if (!match)
|
|
105
114
|
return null; // text-white, text-[#eee] — no suggestion
|
|
106
115
|
const [, scale, shade] = match;
|
|
116
|
+
const isPlaceholder = base.startsWith("placeholder:");
|
|
107
117
|
const shades = palette[scale];
|
|
108
118
|
if (!shades?.[shade])
|
|
109
119
|
return null; // custom scale, or a decoy like text-opacity-50
|
|
@@ -125,7 +135,8 @@ export function suggestContrastFix(textClass, bgClass, required, palette = defau
|
|
|
125
135
|
const effectiveRgb = alpha < 1 ? applyAlpha(rgb, alpha, bgRgb) : rgb;
|
|
126
136
|
const ratio = contrastRatio(effectiveRgb, bgRgb);
|
|
127
137
|
if (ratio >= required) {
|
|
128
|
-
const
|
|
138
|
+
const suggestedBase = alpha < 1 ? `text-${scale}-${candidate}/${Math.round(alpha * 100)}` : `text-${scale}-${candidate}`;
|
|
139
|
+
const suggestedClass = isPlaceholder ? `placeholder:${suggestedBase}` : suggestedBase;
|
|
129
140
|
return { textClass: suggestedClass, ratio };
|
|
130
141
|
}
|
|
131
142
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -99,7 +117,7 @@ export function checkReducedMotion(checks, strict = false) {
|
|
|
99
117
|
// `dark:transition hover:scale-110` was silently treated as
|
|
100
118
|
// compliant even though it animates on hover in dark mode
|
|
101
119
|
// regardless of the user's motion preference.
|
|
102
|
-
const isInteractionGated = segments.some(
|
|
120
|
+
const isInteractionGated = segments.some(isInteractionVariant);
|
|
103
121
|
const isMotionSafeGated = segments.includes("motion-safe");
|
|
104
122
|
if (TRANSITION_BASES.has(base) && !isInteractionGated && !isMotionSafeGated)
|
|
105
123
|
realTransition = raw;
|
|
@@ -138,7 +156,7 @@ export function checkReducedMotion(checks, strict = false) {
|
|
|
138
156
|
// explanation of why both checks are per-candidate, not per-element.
|
|
139
157
|
if (segments.includes("motion-safe"))
|
|
140
158
|
return false;
|
|
141
|
-
if (!segments.some(
|
|
159
|
+
if (!segments.some(isInteractionVariant))
|
|
142
160
|
return false;
|
|
143
161
|
return ANIMATE_MOTION_BASES.has(baseUtility(raw));
|
|
144
162
|
});
|
|
@@ -171,7 +189,7 @@ export function checkReducedMotion(checks, strict = false) {
|
|
|
171
189
|
// self-guarded at the same time, e.g. `hover:motion-safe:scale-110`).
|
|
172
190
|
if (segments.includes("motion-safe"))
|
|
173
191
|
return false;
|
|
174
|
-
if (!segments.some(
|
|
192
|
+
if (!segments.some(isInteractionVariant))
|
|
175
193
|
return false;
|
|
176
194
|
return isNonIdentityMotionUtility(baseUtility(raw));
|
|
177
195
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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": {
|