tailwind-a11y 0.14.1 → 0.15.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
@@ -53,7 +53,7 @@ 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) |
@@ -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 (!COLOR_TOKEN.test(rest))
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 scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
48
- if (scaleName && NON_COLOR_SCALE_NAMES.has(scaleName))
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
- if (!textClass)
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 parentNode = path.parentPath?.node;
76
- if (parentNode && t.isJSXElement(parentNode)) {
77
- const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
78
- const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
79
- if (parentBg) {
80
- checks.push({ file: filePath, line, textColorClass: textClass, bgColorClass: parentBg, bgSource: "parent" });
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
- if (!textClass)
170
+ const placeholderClass = isPlaceholderCapable(path.node.openingElement)
171
+ ? lastPlaceholderColorToken(className)
172
+ : null;
173
+ if (!textClass && !placeholderClass)
105
174
  return;
106
- const ownBg = lastColorToken(className, "bg");
107
- if (ownBg)
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
- if (parentNode && t.isJSXElement(parentNode)) {
112
- const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
113
- const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
114
- if (parentBg)
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: `${textClass} — background may be set inside <${parentTag}>, which this tool doesn't inspect across component boundaries`,
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
- skips.push({
129
- file: filePath,
130
- line,
131
- reason: `${textClass} — no background utility found on this element or its immediate parent`,
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;
@@ -5,15 +5,20 @@ const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-tr
5
5
  // class shape as bare hover:, just evaluated against an ancestor/sibling
6
6
  // (.group/.peer marker) instead of the element itself -- verified against a
7
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).
8
+ // in-*: (Tailwind v4.1+) is the same idea with no marker class required --
9
+ // verified against a real build that `.in-hover\:scale-110` compiles to
10
+ // `:where(:hover) .in-hover\:scale-110`, matching *any* ancestor in that
11
+ // state. This tool already doesn't verify a `.group`/`.peer` marker actually
12
+ // exists on an ancestor (an accepted limitation), so in-*: is no harder to
13
+ // recognize correctly -- if anything simpler, since it has no named-variant
14
+ // slash syntax to handle. Deliberately NOT extended to has-*:/arbitrary
15
+ // variants ([&:hover]:, already an established out-of-scope precedent for
16
+ // this file -- unbounded selector text, not a closed enumerable set).
13
17
  const INTERACTION_VARIANTS = new Set([
14
18
  "hover", "focus", "focus-visible", "focus-within", "active",
15
19
  "group-hover", "group-focus", "group-focus-visible", "group-focus-within", "group-active",
16
20
  "peer-hover", "peer-focus", "peer-focus-visible", "peer-focus-within", "peer-active",
21
+ "in-hover", "in-focus", "in-focus-visible", "in-focus-within", "in-active",
17
22
  ]);
18
23
  // Named groups/peers (`group-hover/sidebar:scale-110`) compile the group
19
24
  // name into the variant token itself via a slash
@@ -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
- const match = /^(?:text|bg|outline|ring)-(.+)$/.exec(utilityClass);
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
- const TEXT_SCALE_SHADE_RE = /^text-([a-z]+)-(\d+)$/;
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 suggestedClass = alpha < 1 ? `text-${scale}-${candidate}/${Math.round(alpha * 100)}` : `text-${scale}-${candidate}`;
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,15 +5,16 @@
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
- // 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.
8
+ // group-hover:/peer-hover:/in-hover:/etc. compile to the identical
9
+ // momentary-pseudo-class shape as bare hover: -- see the identical set (and
10
+ // full explanation, including why in-*: needs no marker-class verification
11
+ // and no named-variant handling) in extractReducedMotion.ts. Deliberately
12
+ // NOT extended to has-*:/arbitrary variants -- see that file's comment.
13
13
  const INTERACTION_VARIANTS = new Set([
14
14
  "hover", "focus", "focus-visible", "focus-within", "active",
15
15
  "group-hover", "group-focus", "group-focus-visible", "group-focus-within", "group-active",
16
16
  "peer-hover", "peer-focus", "peer-focus-visible", "peer-focus-within", "peer-active",
17
+ "in-hover", "in-focus", "in-focus-visible", "in-focus-within", "in-active",
17
18
  ]);
18
19
  // Named groups/peers (`group-hover/sidebar:...`) put the group name in the
19
20
  // variant token itself via a slash -- see extractReducedMotion.ts for the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tailwind-a11y",
3
- "version": "0.14.1",
3
+ "version": "0.15.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": {