tailwind-a11y 0.12.0 → 0.13.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 +1 -1
- package/dist/parser/extractClasses.js +16 -4
- package/dist/rules/checkFocusIndicator.js +67 -43
- package/dist/theme/loadCustomTheme.js +52 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ npx tailwind-a11y --help # usage and all options
|
|
|
34
34
|
Custom theme colors/spacing are read automatically, so colors and spacing outside
|
|
35
35
|
Tailwind's defaults resolve too — not just the built-in palette. Both config formats
|
|
36
36
|
are supported: `theme.extend.colors`/`theme.extend.spacing` in a Tailwind v3
|
|
37
|
-
`tailwind.config.js`/`.cjs`, and `--color-*`/`--spacing-*` custom properties in a
|
|
37
|
+
`tailwind.config.js`/`.cjs`/`.mjs`, and `--color-*`/`--spacing-*` custom properties in a
|
|
38
38
|
Tailwind v4 CSS `@theme { ... }` block (auto-detected from common paths like
|
|
39
39
|
`app/globals.css`, or passed via `--config`).
|
|
40
40
|
|
|
@@ -26,16 +26,28 @@ const NON_COLOR_SCALE_NAMES = new Set(["opacity", "linear", "conic"]);
|
|
|
26
26
|
export function lastColorToken(className, prefix) {
|
|
27
27
|
let found = null;
|
|
28
28
|
for (const raw of className.split(/\s+/).filter(Boolean)) {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Variant-scoped classes (hover:/dark:/md:/...) are skipped entirely,
|
|
30
|
+
// not stripped down to their base utility -- fixed after independent
|
|
31
|
+
// testing found a real false negative: `bg-white dark:bg-gray-900`
|
|
32
|
+
// with `text-gray-300` silently passed, because stripping the `dark:`
|
|
33
|
+
// prefix let it participate in last-token-wins as if it were the real,
|
|
34
|
+
// always-rendered resting-state background, when `dark:bg-gray-900`
|
|
35
|
+
// only ever applies under a completely different condition. Mirrors
|
|
36
|
+
// `extractTouchTargets.ts`'s `lastSizeToken`, which already excludes
|
|
37
|
+
// any variant-scoped size token from resting-state resolution the same
|
|
38
|
+
// way (`if (raw.includes(":")) continue;`) -- this had no equivalent
|
|
39
|
+
// guard for colors.
|
|
40
|
+
if (raw.includes(":"))
|
|
31
41
|
continue;
|
|
32
|
-
|
|
42
|
+
if (!raw.startsWith(`${prefix}-`))
|
|
43
|
+
continue;
|
|
44
|
+
const rest = raw.slice(prefix.length + 1);
|
|
33
45
|
if (!COLOR_TOKEN.test(rest))
|
|
34
46
|
continue;
|
|
35
47
|
const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
|
|
36
48
|
if (scaleName && NON_COLOR_SCALE_NAMES.has(scaleName))
|
|
37
49
|
continue;
|
|
38
|
-
found =
|
|
50
|
+
found = raw;
|
|
39
51
|
}
|
|
40
52
|
return found;
|
|
41
53
|
}
|
|
@@ -115,23 +115,30 @@ const FOCUS_INDICATOR_MIN_THICKNESS_PX = 2;
|
|
|
115
115
|
// can't currently distinguish, so bare ring/outline contributes color (if
|
|
116
116
|
// paired with an explicit color utility) but never a thickness value.
|
|
117
117
|
const WIDTH_SCALE = { "0": 0, "1": 1, "2": 2, "4": 4, "8": 8 };
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// last-token-wins
|
|
121
|
-
//
|
|
122
|
-
// which
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
118
|
+
// Last-token-wins WITHIN one prefix only. Fixed after independent testing
|
|
119
|
+
// found a real bug: the original version raced outline-* against ring-*
|
|
120
|
+
// in one shared last-token-wins slot, so an element with BOTH a passing
|
|
121
|
+
// outline-* and a failing ring-* (or vice versa) got a verdict that
|
|
122
|
+
// depended purely on which was written later in the class string -- even
|
|
123
|
+
// though outline-*/ring-* are two independent CSS mechanisms
|
|
124
|
+
// (outline-color/-width vs. box-shadow) that both render simultaneously
|
|
125
|
+
// regardless of order (verified against a real Tailwind v4 build).
|
|
126
|
+
// Last-token-wins is still correct *within* a single prefix (e.g.
|
|
127
|
+
// `outline-red-500 outline-blue-600` really does render as blue-600, the
|
|
128
|
+
// later declaration winning in CSS) -- only racing the two prefixes
|
|
129
|
+
// against each other was wrong. Reuses COLOR_TOKEN (the shared
|
|
130
|
+
// "is this color-shaped" test) and only excludes the "opacity" scale name
|
|
131
|
+
// locally -- extractClasses.ts's lastColorToken's full NON_COLOR_SCALE_NAMES
|
|
132
|
+
// set also excludes "linear"/"conic", but those are bg-gradient-angle
|
|
133
|
+
// utilities with no outline-*/ring-* equivalent, so they can never appear
|
|
134
|
+
// here.
|
|
135
|
+
function lastColorTokenForIndicator(focusClasses, prefix) {
|
|
128
136
|
let found = null;
|
|
129
137
|
for (const raw of focusClasses) {
|
|
130
138
|
const base = raw.slice(raw.lastIndexOf(":") + 1);
|
|
131
|
-
|
|
132
|
-
if (!match)
|
|
139
|
+
if (!base.startsWith(`${prefix}-`))
|
|
133
140
|
continue;
|
|
134
|
-
const rest =
|
|
141
|
+
const rest = base.slice(prefix.length + 1);
|
|
135
142
|
if (!COLOR_TOKEN.test(rest))
|
|
136
143
|
continue;
|
|
137
144
|
const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
|
|
@@ -141,19 +148,18 @@ function lastIndicatorColorToken(focusClasses) {
|
|
|
141
148
|
}
|
|
142
149
|
return found;
|
|
143
150
|
}
|
|
144
|
-
// Same last-token-wins
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
function
|
|
151
|
+
// Same per-prefix last-token-wins shape as above, but for width: only an
|
|
152
|
+
// enumerated outline-{N}/ring-{N} or an arbitrary [Npx] sets a thickness.
|
|
153
|
+
// A color token (ring-blue-400), ring-offset-*, ring-inset, etc. don't
|
|
154
|
+
// match either shape and are silently ignored here -- they're a different
|
|
155
|
+
// utility's job (color, offset, inset), not this one's.
|
|
156
|
+
function thicknessTokenForIndicator(focusClasses, prefix) {
|
|
150
157
|
let found = null;
|
|
151
158
|
for (const raw of focusClasses) {
|
|
152
159
|
const base = raw.slice(raw.lastIndexOf(":") + 1);
|
|
153
|
-
|
|
154
|
-
if (!match)
|
|
160
|
+
if (!base.startsWith(`${prefix}-`))
|
|
155
161
|
continue;
|
|
156
|
-
const token =
|
|
162
|
+
const token = base.slice(prefix.length + 1);
|
|
157
163
|
if (token in WIDTH_SCALE) {
|
|
158
164
|
found = WIDTH_SCALE[token];
|
|
159
165
|
continue;
|
|
@@ -169,26 +175,44 @@ export function checkFocusContrast(checks, strict = false, palette = defaultPale
|
|
|
169
175
|
for (const check of checks) {
|
|
170
176
|
if (!check.bgClass)
|
|
171
177
|
continue; // no resolvable background — skip, not a guess
|
|
172
|
-
const indicatorBase = lastIndicatorColorToken(check.focusClasses);
|
|
173
|
-
if (!indicatorBase)
|
|
174
|
-
continue; // no explicit outline-*/ring-* color — out of scope, see CLAUDE.md
|
|
175
|
-
const indicatorHex = resolveColorValue(indicatorBase, palette);
|
|
176
178
|
const bgHex = resolveColorValue(check.bgClass, palette);
|
|
177
|
-
|
|
179
|
+
const bgRgb = bgHex ? hexToRgb(bgHex) : null;
|
|
180
|
+
if (!bgRgb)
|
|
178
181
|
continue; // custom theme color / unsupported arbitrary value — skip
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
182
|
+
// outline-* and ring-* are evaluated as independent candidates, not
|
|
183
|
+
// raced against each other -- both are real, simultaneously-rendering
|
|
184
|
+
// mechanisms (see lastColorTokenForIndicator's comment for why), so an
|
|
185
|
+
// element can have zero, one, or both present at once.
|
|
186
|
+
const candidates = [];
|
|
187
|
+
for (const prefix of ["outline", "ring"]) {
|
|
188
|
+
const indicatorBase = lastColorTokenForIndicator(check.focusClasses, prefix);
|
|
189
|
+
if (!indicatorBase)
|
|
190
|
+
continue; // this mechanism isn't in use on this element
|
|
191
|
+
const indicatorHex = resolveColorValue(indicatorBase, palette);
|
|
192
|
+
const indicatorRgb = indicatorHex ? hexToRgb(indicatorHex) : null;
|
|
193
|
+
if (!indicatorRgb)
|
|
194
|
+
continue; // custom theme color / unsupported arbitrary value — skip this candidate
|
|
195
|
+
const ratio = contrastRatio(indicatorRgb, bgRgb);
|
|
196
|
+
const contrastFails = ratio < NON_TEXT_MIN_RATIO;
|
|
197
|
+
let thicknessPx = null;
|
|
198
|
+
if (strict)
|
|
199
|
+
thicknessPx = thicknessTokenForIndicator(check.focusClasses, prefix);
|
|
200
|
+
const thicknessFails = strict && thicknessPx !== null && thicknessPx < FOCUS_INDICATOR_MIN_THICKNESS_PX;
|
|
201
|
+
candidates.push({ indicatorBase, ratio, contrastFails, thicknessPx, thicknessFails });
|
|
202
|
+
}
|
|
203
|
+
if (candidates.length === 0)
|
|
204
|
+
continue; // no resolvable outline-*/ring-* color at all
|
|
205
|
+
// A user only needs ONE sufficiently visible (and, under strict,
|
|
206
|
+
// sufficiently thick) focus indicator to perceive the focus state --
|
|
207
|
+
// if any present candidate fully passes, this element is compliant
|
|
208
|
+
// even if another present indicator independently would have failed.
|
|
209
|
+
const allFail = candidates.every((c) => c.contrastFails || c.thicknessFails);
|
|
210
|
+
if (!allFail)
|
|
190
211
|
continue;
|
|
191
|
-
|
|
212
|
+
// More than one candidate present and both fail -- report the worst
|
|
213
|
+
// (lowest-ratio) offender.
|
|
214
|
+
const worst = candidates.reduce((a, b) => (b.ratio < a.ratio ? b : a));
|
|
215
|
+
const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === worst.indicatorBase);
|
|
192
216
|
violations.push({
|
|
193
217
|
type: "focus-contrast",
|
|
194
218
|
file: check.file,
|
|
@@ -196,11 +220,11 @@ export function checkFocusContrast(checks, strict = false, palette = defaultPale
|
|
|
196
220
|
tagName: check.tagName,
|
|
197
221
|
indicatorClass: rawIndicatorClass,
|
|
198
222
|
bgClass: check.bgClass,
|
|
199
|
-
ratio,
|
|
223
|
+
ratio: worst.ratio,
|
|
200
224
|
required: NON_TEXT_MIN_RATIO,
|
|
201
|
-
level: thicknessFails ? "AAA" : "AA",
|
|
202
|
-
...(thicknessFails && thicknessPx !== null
|
|
203
|
-
? { thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
|
|
225
|
+
level: worst.thicknessFails ? "AAA" : "AA",
|
|
226
|
+
...(worst.thicknessFails && worst.thicknessPx !== null
|
|
227
|
+
? { thicknessPx: worst.thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
|
|
204
228
|
: {}),
|
|
205
229
|
});
|
|
206
230
|
}
|
|
@@ -5,7 +5,10 @@ import { defaultPalette } from "./defaultPalette.js";
|
|
|
5
5
|
import { spacingScale } from "./spacingScale.js";
|
|
6
6
|
import { parseColorScale, parseSpacingValue } from "./themeValueParsers.js";
|
|
7
7
|
import { parseThemeCss } from "./parseThemeCss.js";
|
|
8
|
-
|
|
8
|
+
// .mjs appended last (lowest priority) -- the newly-supported format behind
|
|
9
|
+
// the two established ones, same "most established first" ordering
|
|
10
|
+
// CSS_THEME_CANDIDATES below already uses for its own list.
|
|
11
|
+
const CONFIG_FILENAMES = ["tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs"];
|
|
9
12
|
// v1 only looks in the given directory itself -- no ancestor-directory search.
|
|
10
13
|
// --config (CLI) / settings["tailwind-a11y"].configPath (ESLint) exist as
|
|
11
14
|
// explicit escape hatches for projects where this isn't enough. `rootDir` must
|
|
@@ -42,15 +45,46 @@ export function findTailwindThemeCss(rootDir) {
|
|
|
42
45
|
}
|
|
43
46
|
return null;
|
|
44
47
|
}
|
|
45
|
-
// Loads a Tailwind v3-style tailwind.config.js/.cjs and extracts only
|
|
48
|
+
// Loads a Tailwind v3-style tailwind.config.js/.cjs/.mjs and extracts only
|
|
46
49
|
// `theme.extend.colors`/`theme.extend.spacing` -- v1 does not read a full
|
|
47
|
-
// `theme.colors`/`theme.spacing` replacement, or .
|
|
48
|
-
// config-transpiling dependency exists in this package). Tailwind v4's
|
|
50
|
+
// `theme.colors`/`theme.spacing` replacement, or .ts configs. Tailwind v4's
|
|
49
51
|
// CSS-based `@theme` config is a separate format entirely, handled by
|
|
50
52
|
// loadThemeFromCssFile()/parseThemeCss() below, not by this function.
|
|
51
53
|
// `configPath` must be an absolute path (require() resolves relative paths
|
|
52
54
|
// against this module's own location, not the caller's cwd).
|
|
53
55
|
//
|
|
56
|
+
// .mjs works via plain require() -- verified this session that Node
|
|
57
|
+
// 20.19+/22.13+ can require() an ESM module synchronously, no import(), no
|
|
58
|
+
// async refactor. On an older Node this throws ERR_REQUIRE_ESM, already
|
|
59
|
+
// caught below and treated as "no config found," so this degrades exactly
|
|
60
|
+
// as gracefully as it did before .mjs was supported. Every adapter's actual
|
|
61
|
+
// runtime already clears the threshold: the GitHub Action runs on Node 24
|
|
62
|
+
// (action.yml), eslint-plugin-tailwind-a11y's own engines.node already
|
|
63
|
+
// excludes every version that lacks this, and the CLI's broad >=18 floor
|
|
64
|
+
// just falls back safely on anything older.
|
|
65
|
+
//
|
|
66
|
+
// .ts is a deliberate non-goal, not a "not yet": Node's native TypeScript
|
|
67
|
+
// type-stripping only activates when the *host* process is launched with
|
|
68
|
+
// --experimental-strip-types (verified this session -- a library can't
|
|
69
|
+
// turn this on for the user), so the only way to support .ts transparently
|
|
70
|
+
// would be promoting esbuild from a devDependency to a real runtime
|
|
71
|
+
// dependency of this package purely to transpile config files, a real
|
|
72
|
+
// native-binary weight increase. Also verified this session: a fresh
|
|
73
|
+
// `create-next-app --typescript --tailwind` no longer generates a JS/TS
|
|
74
|
+
// config file at all -- Tailwind v4 projects put theme customization in a
|
|
75
|
+
// CSS `@theme` block instead (see loadThemeFromCssFile() below), so .ts
|
|
76
|
+
// config support would only help a shrinking population of legacy
|
|
77
|
+
// v3-plus-TypeScript projects, not worth the dependency.
|
|
78
|
+
//
|
|
79
|
+
// Known limitation, not fixed: bustRequireCache() below does NOT work for
|
|
80
|
+
// a .mjs config. Node's synchronous require(esm) caches the module in its
|
|
81
|
+
// own internal ESM registry, not (only) in `require.cache` -- deleting the
|
|
82
|
+
// `require.cache` entry doesn't force a reload, confirmed with a real
|
|
83
|
+
// edit-and-reload test this session. CLI and GitHub Action are unaffected
|
|
84
|
+
// (fresh process per run either way); the VS Code extension's live-reload
|
|
85
|
+
// guarantee, which does work correctly for .js/.cjs/.css configs, does NOT
|
|
86
|
+
// extend to .mjs -- editing a .mjs config requires reloading the window.
|
|
87
|
+
//
|
|
54
88
|
// Node's require() cache is busted before loading -- recursively, for the
|
|
55
89
|
// config file *and* everything it required (e.g. a config that factors
|
|
56
90
|
// tokens into a separate `require('./colors.js')`) -- without this, a
|
|
@@ -83,7 +117,20 @@ export function loadCustomTheme(configPath) {
|
|
|
83
117
|
const cached = require.cache[resolved];
|
|
84
118
|
if (cached)
|
|
85
119
|
bustRequireCache(require, cached, new Set());
|
|
86
|
-
const
|
|
120
|
+
const loaded = require(resolved);
|
|
121
|
+
// Node's require() of an ESM module returns the module namespace object
|
|
122
|
+
// (`{ __esModule: true, default: <the actual export>, ...named exports
|
|
123
|
+
// }`), not the export itself. Gated strictly on the .mjs extension --
|
|
124
|
+
// caught in independent review: a structural check ("does it have a
|
|
125
|
+
// `default` key") instead of this would silently misfire on a genuine
|
|
126
|
+
// CJS config that happens to export its own top-level `default` key
|
|
127
|
+
// (e.g. `module.exports = { default: "unrelated", theme: {...} }`),
|
|
128
|
+
// discarding the real theme with no error. .mjs is the only path that
|
|
129
|
+
// can ever produce this wrapped shape here: a `.js`/`.cjs` require()
|
|
130
|
+
// either returns the CJS export as-is, or -- inside a "type": "module"
|
|
131
|
+
// package -- throws ERR_REQUIRE_ESM before this line is ever reached
|
|
132
|
+
// (already handled by the catch block below, and already tested).
|
|
133
|
+
const config = resolved.endsWith(".mjs") && loaded && typeof loaded === "object" ? loaded.default : loaded;
|
|
87
134
|
const extend = config?.theme?.extend ?? {};
|
|
88
135
|
const result = {};
|
|
89
136
|
if (extend.colors && typeof extend.colors === "object") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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": {
|