tailwind-a11y 0.10.0 → 0.13.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 +3 -2
- package/dist/cli.js +5 -1
- package/dist/cliArgs.js +6 -2
- package/dist/contrast/luminance.d.ts +1 -0
- package/dist/contrast/luminance.js +9 -0
- package/dist/contrast/oklch.d.ts +2 -0
- package/dist/contrast/oklch.js +67 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/parser/extractReducedMotion.d.ts +7 -0
- package/dist/parser/extractReducedMotion.js +50 -0
- package/dist/rules/checkReducedMotion.d.ts +11 -0
- package/dist/rules/checkReducedMotion.js +109 -0
- package/dist/theme/loadCustomTheme.js +52 -5
- package/dist/theme/themeValueParsers.js +19 -4
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ npm install --save-dev tailwind-a11y
|
|
|
25
25
|
npx tailwind-a11y # scans **/*.{jsx,tsx}
|
|
26
26
|
npx tailwind-a11y "src/**/*.tsx" # custom glob
|
|
27
27
|
npx tailwind-a11y --verbose # also reports what couldn't be checked, and why
|
|
28
|
-
npx tailwind-a11y --strict # touch targets
|
|
28
|
+
npx tailwind-a11y --strict # AAA tier: touch targets 2.5.5, focus indicators also 2.4.13, and enables the reduced-motion check (2.3.3)
|
|
29
29
|
npx tailwind-a11y --config ./tw.config.cjs # use a specific config instead of auto-detecting
|
|
30
30
|
npx tailwind-a11y --version # print the installed version
|
|
31
31
|
npx tailwind-a11y --help # usage and all options
|
|
@@ -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
|
|
|
@@ -57,6 +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
61
|
|
|
61
62
|
## Scope
|
|
62
63
|
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extr
|
|
|
9
9
|
import { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
10
10
|
import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
11
11
|
import { checkFocusContrast, checkFocusIndicators, } from "./rules/checkFocusIndicator.js";
|
|
12
|
+
import { extractReducedMotionChecks } from "./parser/extractReducedMotion.js";
|
|
13
|
+
import { checkReducedMotion } from "./rules/checkReducedMotion.js";
|
|
12
14
|
import { parseArgs, getHelpText } from "./cliArgs.js";
|
|
13
15
|
import { resolveTheme } from "./theme/loadCustomTheme.js";
|
|
14
16
|
// ../package.json resolves correctly from both src/ (dev) and dist/ (published).
|
|
@@ -33,6 +35,8 @@ function formatViolation(v) {
|
|
|
33
35
|
? `${base}; also only ${v.thicknessPx}px thick, needs >= ${v.requiredThicknessPx}px`
|
|
34
36
|
: base;
|
|
35
37
|
}
|
|
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`;
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
function groupByFile(items) {
|
|
@@ -87,7 +91,7 @@ async function main() {
|
|
|
87
91
|
const code = readFileSync(absPath, "utf8");
|
|
88
92
|
const contrastChecks = extractChecks(code, file);
|
|
89
93
|
const focusChecks = extractFocusIndicatorChecks(code, file);
|
|
90
|
-
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette));
|
|
94
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette), ...checkReducedMotion(extractReducedMotionChecks(code, file), strict));
|
|
91
95
|
if (verbose) {
|
|
92
96
|
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
93
97
|
}
|
package/dist/cliArgs.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const HELP_TEXT = `Usage: tailwind-a11y [options] [<glob>...]
|
|
4
4
|
|
|
5
5
|
Static analysis for Tailwind CSS accessibility violations -- color contrast,
|
|
6
|
-
touch target size,
|
|
6
|
+
touch target size, focus indicator removal/contrast, and reduced-motion
|
|
7
|
+
support for interaction-triggered animation.
|
|
7
8
|
|
|
8
9
|
Options:
|
|
9
10
|
-v, --verbose Also report what couldn't be checked, and why
|
|
@@ -12,7 +13,10 @@ Options:
|
|
|
12
13
|
--strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
|
|
13
14
|
instead of the default 2.5.8 (AA, 24x24px); focus
|
|
14
15
|
indicators are also held to 2.4.13 (AAA, minimum
|
|
15
|
-
thickness) alongside the default 1.4.11 (AA, contrast)
|
|
16
|
+
thickness) alongside the default 1.4.11 (AA, contrast);
|
|
17
|
+
also enables the reduced-motion check (WCAG 2.3.3,
|
|
18
|
+
AAA-only, off by default -- has no AA tier to fall
|
|
19
|
+
back to)
|
|
16
20
|
--config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
|
|
17
21
|
@theme file like app/globals.css (v4) to read
|
|
18
22
|
custom theme colors/spacing from (default:
|
|
@@ -4,6 +4,7 @@ export interface RGB {
|
|
|
4
4
|
b: number;
|
|
5
5
|
}
|
|
6
6
|
export declare function hexToRgb(hex: string): RGB | null;
|
|
7
|
+
export declare function rgbToHex(rgb: RGB): string;
|
|
7
8
|
export declare function applyAlpha(fg: RGB, alpha: number, bg: RGB): RGB;
|
|
8
9
|
export declare function relativeLuminance(rgb: RGB): number;
|
|
9
10
|
export declare function contrastRatio(rgb1: RGB, rgb2: RGB): number;
|
|
@@ -16,6 +16,15 @@ export function hexToRgb(hex) {
|
|
|
16
16
|
b: parseInt(digits.slice(4, 6), 16),
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
// Inverse of hexToRgb -- used once, by themeValueParsers.ts's
|
|
20
|
+
// parseColorScale(), to turn an accepted oklch() value back into the plain
|
|
21
|
+
// hex string every downstream consumer already expects a palette shade to
|
|
22
|
+
// be. Channels are clamped and rounded rather than assumed already valid,
|
|
23
|
+
// since a caller could in principle pass an out-of-range value.
|
|
24
|
+
export function rgbToHex(rgb) {
|
|
25
|
+
const channel = (c) => Math.round(Math.min(255, Math.max(0, c))).toString(16).padStart(2, "0");
|
|
26
|
+
return `#${channel(rgb.r)}${channel(rgb.g)}${channel(rgb.b)}`;
|
|
27
|
+
}
|
|
19
28
|
// Standard "src-over" compositing of a foreground at `alpha` opacity over an
|
|
20
29
|
// opaque background, in gamma-encoded sRGB space (0-255 channels) -- matches
|
|
21
30
|
// how browsers actually composite CSS opacity, no linear-light conversion
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// L and C each accept a plain number or a percentage; H accepts a plain
|
|
2
|
+
// degree number or an explicit `deg` suffix (`180` and `180deg` render
|
|
3
|
+
// identically -- confirmed against a real browser, see below). An alpha
|
|
4
|
+
// component (`/ A`) or the `none` keyword for any channel is deliberately
|
|
5
|
+
// unsupported and falls through to the "not oklch" case below: the palette
|
|
6
|
+
// only ever stores fully opaque colors today (HEX_RE in luminance.ts
|
|
7
|
+
// doesn't accept 4/8-digit hex with alpha either), so this mirrors an
|
|
8
|
+
// existing limit rather than introducing a new one.
|
|
9
|
+
// A real number shape only -- `\d+(?:\.\d+)?|\.\d+` matches "5", "5.5", and
|
|
10
|
+
// ".5" but not "5." or "0..5". The looser `[\d.]+` this replaced accepted
|
|
11
|
+
// "0..5" too, which Number() coerces to NaN -- silently producing an
|
|
12
|
+
// {r:NaN,g:NaN,b:NaN} object where oklchToRgb's contract says this should
|
|
13
|
+
// have been rejected outright (caught in review; the isFinite guard below
|
|
14
|
+
// is a second, independent layer against the same failure mode).
|
|
15
|
+
const NUM = String.raw `\d+(?:\.\d+)?|\.\d+`;
|
|
16
|
+
const OKLCH_RE = new RegExp(`^oklch\\(\\s*(${NUM})(%)?\\s+(${NUM})(%)?\\s+(${NUM})(deg)?\\s*\\)$`);
|
|
17
|
+
// Standard OKLab <-> linear sRGB conversion matrices (Björn Ottosson's
|
|
18
|
+
// published reference, the same ones browsers implement per CSS Color 4).
|
|
19
|
+
// Verified this session against a real headless Chrome: rendered each of
|
|
20
|
+
// white/black/a midtone gray/saturated red/teal/purple/an intentionally
|
|
21
|
+
// out-of-gamut saturated red-orange/percentage-L/percentage-C/a deg-suffixed
|
|
22
|
+
// hue onto a <canvas> and read back the actual pixel RGB via getImageData()
|
|
23
|
+
// -- getComputedStyle().color isn't usable for this, since modern Chrome
|
|
24
|
+
// serializes a computed oklch() value back out as oklch(), not rgb(). Every
|
|
25
|
+
// case matched the real browser-rendered pixel exactly (0 channel
|
|
26
|
+
// difference), including the out-of-gamut case, confirming both the
|
|
27
|
+
// matrices and the clamp-before-gamma-encode approach below are correct as
|
|
28
|
+
// implemented, not just approximately close.
|
|
29
|
+
function gammaEncode(linear) {
|
|
30
|
+
const clamped = Math.min(1, Math.max(0, linear));
|
|
31
|
+
return clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * clamped ** (1 / 2.4) - 0.055;
|
|
32
|
+
}
|
|
33
|
+
export function oklchToRgb(value) {
|
|
34
|
+
const match = OKLCH_RE.exec(value.trim());
|
|
35
|
+
if (!match)
|
|
36
|
+
return null;
|
|
37
|
+
const [, lRaw, lPct, cRaw, cPct, hRaw] = match;
|
|
38
|
+
const L = lPct ? Number(lRaw) / 100 : Number(lRaw);
|
|
39
|
+
const C = cPct ? (Number(cRaw) / 100) * 0.4 : Number(cRaw); // 100% chroma == 0.4, the CSS Color 4 reference range
|
|
40
|
+
const H = Number(hRaw);
|
|
41
|
+
// Belt-and-suspenders: independent of whether the regex above is airtight,
|
|
42
|
+
// this makes the RGB | null contract impossible to violate -- a NaN here
|
|
43
|
+
// would otherwise propagate silently into a "successfully resolved" but
|
|
44
|
+
// garbage color (rgbToHex would render it as the literal string
|
|
45
|
+
// "#NaNNaNNaN", which downstream code happens to reject today, but only
|
|
46
|
+
// by accident of a stricter hex regex elsewhere, not because this
|
|
47
|
+
// function actually enforced its own contract).
|
|
48
|
+
if (!Number.isFinite(L) || !Number.isFinite(C) || !Number.isFinite(H))
|
|
49
|
+
return null;
|
|
50
|
+
const hRad = (H * Math.PI) / 180;
|
|
51
|
+
const a = C * Math.cos(hRad);
|
|
52
|
+
const b = C * Math.sin(hRad);
|
|
53
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
54
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
55
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
56
|
+
const l = l_ ** 3;
|
|
57
|
+
const m = m_ ** 3;
|
|
58
|
+
const s = s_ ** 3;
|
|
59
|
+
const rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
60
|
+
const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
61
|
+
const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
62
|
+
return {
|
|
63
|
+
r: Math.round(gammaEncode(rLin) * 255),
|
|
64
|
+
g: Math.round(gammaEncode(gLin) * 255),
|
|
65
|
+
b: Math.round(gammaEncode(bLin) * 255),
|
|
66
|
+
};
|
|
67
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export { extractTouchTargetChecks, extractTouchTargetSkips, type TouchTargetChec
|
|
|
4
4
|
export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouchTarget.js";
|
|
5
5
|
export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
|
|
6
6
|
export { checkFocusIndicators, checkFocusContrast, type FocusIndicatorViolation, type FocusContrastViolation, } from "./rules/checkFocusIndicator.js";
|
|
7
|
+
export { extractReducedMotionChecks, type ReducedMotionCheck } from "./parser/extractReducedMotion.js";
|
|
8
|
+
export { checkReducedMotion, type ReducedMotionViolation } from "./rules/checkReducedMotion.js";
|
|
7
9
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
|
|
8
10
|
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
|
|
9
11
|
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ export { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extr
|
|
|
4
4
|
export { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
5
5
|
export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
6
6
|
export { checkFocusIndicators, checkFocusContrast, } from "./rules/checkFocusIndicator.js";
|
|
7
|
+
export { extractReducedMotionChecks } from "./parser/extractReducedMotion.js";
|
|
8
|
+
export { checkReducedMotion } from "./rules/checkReducedMotion.js";
|
|
7
9
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
|
|
8
10
|
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
|
|
9
11
|
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as t from "@babel/types";
|
|
2
|
+
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
|
+
const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-transform"]);
|
|
4
|
+
const INTERACTION_VARIANTS = new Set(["hover", "focus", "focus-visible", "focus-within", "active"]);
|
|
5
|
+
function baseUtility(raw) {
|
|
6
|
+
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
7
|
+
}
|
|
8
|
+
// Every variant applied to a class, in source order -- NOT just the one
|
|
9
|
+
// immediately before the base utility. Caught in independent review:
|
|
10
|
+
// Tailwind variants stack (`motion-safe:hover:scale-110` and
|
|
11
|
+
// `hover:motion-safe:scale-110` compile to the identical nested media query,
|
|
12
|
+
// confirmed against a real v4 build), so checking only the innermost
|
|
13
|
+
// segment made this extractor's own candidacy gate order-dependent -- it
|
|
14
|
+
// would only recognize the interaction variant when it happened to be
|
|
15
|
+
// written last, silently missing the equally valid `hover:motion-safe:...`
|
|
16
|
+
// ordering. checkReducedMotion.ts uses the same helper for the same reason.
|
|
17
|
+
function variantSegments(raw) {
|
|
18
|
+
return raw.split(":").slice(0, -1);
|
|
19
|
+
}
|
|
20
|
+
export function extractReducedMotionChecks(code, filePath) {
|
|
21
|
+
const ast = parseJSX(code, filePath);
|
|
22
|
+
if (!ast)
|
|
23
|
+
return [];
|
|
24
|
+
const checks = [];
|
|
25
|
+
traverse(ast, {
|
|
26
|
+
JSXElement(path) {
|
|
27
|
+
const opening = path.node.openingElement;
|
|
28
|
+
const className = getStaticClassName(opening.attributes);
|
|
29
|
+
if (!className)
|
|
30
|
+
return;
|
|
31
|
+
const classes = className.split(/\s+/).filter(Boolean);
|
|
32
|
+
const hasTransitionBase = classes.some((raw) => TRANSITION_BASES.has(baseUtility(raw)));
|
|
33
|
+
const hasInteractionClass = classes.some((raw) => variantSegments(raw).some((v) => INTERACTION_VARIANTS.has(v)));
|
|
34
|
+
// 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)
|
|
40
|
+
return;
|
|
41
|
+
checks.push({
|
|
42
|
+
file: filePath,
|
|
43
|
+
line: opening.loc?.start.line ?? 0,
|
|
44
|
+
tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "unknown-element",
|
|
45
|
+
classes,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
return checks;
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReducedMotionCheck } from "../parser/extractReducedMotion.js";
|
|
2
|
+
export interface ReducedMotionViolation {
|
|
3
|
+
type: "reduced-motion";
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
tagName: string;
|
|
7
|
+
transitionClass: string;
|
|
8
|
+
motionClass: string;
|
|
9
|
+
level: "AAA";
|
|
10
|
+
}
|
|
11
|
+
export declare function checkReducedMotion(checks: ReducedMotionCheck[], strict?: boolean): ReducedMotionViolation[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Verified against a real Tailwind v4 build: only these three include
|
|
2
|
+
// transform/translate/scale/rotate in their transition-property list.
|
|
3
|
+
// transition-colors/-opacity/-shadow don't -- so a scale/rotate/translate
|
|
4
|
+
// change under hover on an element with only one of those never actually
|
|
5
|
+
// animates (the browser has nothing telling it to transition that
|
|
6
|
+
// property), and correctly isn't flagged.
|
|
7
|
+
const TRANSITION_BASES = new Set(["transition", "transition-all", "transition-transform"]);
|
|
8
|
+
const INTERACTION_VARIANTS = new Set(["hover", "focus", "focus-visible", "focus-within", "active"]);
|
|
9
|
+
function baseUtility(raw) {
|
|
10
|
+
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
11
|
+
}
|
|
12
|
+
// Every variant applied to a class, in source order -- see the identical
|
|
13
|
+
// helper (and full explanation) in extractReducedMotion.ts. Duplicated
|
|
14
|
+
// rather than imported, matching this codebase's own established
|
|
15
|
+
// precedent of small per-file primitives (e.g. checkFocusIndicator.ts's own
|
|
16
|
+
// baseUtility is separate from extractFocusIndicators.ts's).
|
|
17
|
+
function variantSegments(raw) {
|
|
18
|
+
return raw.split(":").slice(0, -1);
|
|
19
|
+
}
|
|
20
|
+
// Positive shape filter, not a denylist -- same reasoning as COLOR_TOKEN in
|
|
21
|
+
// extractClasses.ts. Excludes each utility's identity value (scale-100,
|
|
22
|
+
// rotate-0, translate-x-0/-y-0, skew-x-0/-y-0), since those utilities are
|
|
23
|
+
// real but move nothing -- flagging them would be a false positive, the
|
|
24
|
+
// same "shape looks real but isn't" failure class this project has hit
|
|
25
|
+
// before. Arbitrary bracket values (scale-[1.5]) and 3D transform utilities
|
|
26
|
+
// (rotate-x-*, translate-z-*, ...) are out of scope for v1 -- unmatched, so
|
|
27
|
+
// silently not considered "motion" here rather than guessed at.
|
|
28
|
+
const SCALE_RE = /^(scale|scale-x|scale-y)-(\d+)$/;
|
|
29
|
+
const ROTATE_RE = /^-?rotate-(\d+(?:\.\d+)?)$/;
|
|
30
|
+
const TRANSLATE_RE = /^-?(translate-x|translate-y)-(\d+(?:\.\d+)?)$/;
|
|
31
|
+
const SKEW_RE = /^-?(skew-x|skew-y)-(\d+(?:\.\d+)?)$/;
|
|
32
|
+
function isNonIdentityMotionUtility(base) {
|
|
33
|
+
const scale = SCALE_RE.exec(base);
|
|
34
|
+
if (scale)
|
|
35
|
+
return Number(scale[2]) !== 100;
|
|
36
|
+
const rotate = ROTATE_RE.exec(base);
|
|
37
|
+
if (rotate)
|
|
38
|
+
return Number(rotate[1]) !== 0;
|
|
39
|
+
const translate = TRANSLATE_RE.exec(base);
|
|
40
|
+
if (translate)
|
|
41
|
+
return Number(translate[2]) !== 0;
|
|
42
|
+
const skew = SKEW_RE.exec(base);
|
|
43
|
+
if (skew)
|
|
44
|
+
return Number(skew[2]) !== 0;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// `strict` gates the whole check for the scan-everything-by-default
|
|
48
|
+
// adapters (CLI/VS Code/GitHub Action) -- WCAG 2.3.3 is AAA-only, and
|
|
49
|
+
// unconditionally enabling a brand-new AAA check would silently start
|
|
50
|
+
// failing existing users' CI on a routine upgrade, unlike a genuine AA
|
|
51
|
+
// baseline (2.4.7's focus-indicator check, the one other check in this file
|
|
52
|
+
// with no strict tier). Consistent with this project's own `strict` =
|
|
53
|
+
// "hold every check to its AAA tier where one exists" framing. The ESLint
|
|
54
|
+
// rule is the one caller that always passes `true` regardless -- enabling
|
|
55
|
+
// `tailwind-a11y/reduced-motion` in an ESLint config is itself the opt-in
|
|
56
|
+
// gesture there, so a second gate on top would just make the rule silently
|
|
57
|
+
// report nothing when a user enabled it expecting it to check something.
|
|
58
|
+
export function checkReducedMotion(checks, strict = false) {
|
|
59
|
+
if (!strict)
|
|
60
|
+
return [];
|
|
61
|
+
const violations = [];
|
|
62
|
+
for (const check of checks) {
|
|
63
|
+
let unscopedTransition = null;
|
|
64
|
+
let hasMotionReduceGuard = false;
|
|
65
|
+
for (const raw of check.classes) {
|
|
66
|
+
const base = baseUtility(raw);
|
|
67
|
+
const segments = variantSegments(raw);
|
|
68
|
+
if (segments.length === 0 && TRANSITION_BASES.has(base))
|
|
69
|
+
unscopedTransition = raw;
|
|
70
|
+
if (segments.includes("motion-reduce") && (base === "transition-none" || base === "transform-none")) {
|
|
71
|
+
hasMotionReduceGuard = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// A transition scoped only under motion-safe: (never unscoped) means it
|
|
75
|
+
// simply doesn't exist unless motion is already safe -- a complete
|
|
76
|
+
// alternative way of satisfying 2.3.3, not a partial one -- so this
|
|
77
|
+
// correctly falls through as a pass, not a skip-because-unresolvable.
|
|
78
|
+
if (!unscopedTransition)
|
|
79
|
+
continue;
|
|
80
|
+
if (hasMotionReduceGuard)
|
|
81
|
+
continue;
|
|
82
|
+
const motionClass = check.classes.find((raw) => {
|
|
83
|
+
const segments = variantSegments(raw);
|
|
84
|
+
// motion-safe: anywhere in this specific class's own variant stack
|
|
85
|
+
// means the motion utility itself doesn't apply unless motion is
|
|
86
|
+
// already safe -- the same complete-alternative reasoning as the
|
|
87
|
+
// transition side above, just checked per-candidate instead of once
|
|
88
|
+
// for the whole element (a class can be interaction-scoped *and*
|
|
89
|
+
// self-guarded at the same time, e.g. `hover:motion-safe:scale-110`).
|
|
90
|
+
if (segments.includes("motion-safe"))
|
|
91
|
+
return false;
|
|
92
|
+
if (!segments.some((v) => INTERACTION_VARIANTS.has(v)))
|
|
93
|
+
return false;
|
|
94
|
+
return isNonIdentityMotionUtility(baseUtility(raw));
|
|
95
|
+
});
|
|
96
|
+
if (!motionClass)
|
|
97
|
+
continue; // no real, un-self-guarded motion actually triggered by interaction
|
|
98
|
+
violations.push({
|
|
99
|
+
type: "reduced-motion",
|
|
100
|
+
file: check.file,
|
|
101
|
+
line: check.line,
|
|
102
|
+
tagName: check.tagName,
|
|
103
|
+
transitionClass: unscopedTransition,
|
|
104
|
+
motionClass,
|
|
105
|
+
level: "AAA",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return violations;
|
|
109
|
+
}
|
|
@@ -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") {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { hexToRgb } from "../contrast/luminance.js";
|
|
1
|
+
import { hexToRgb, rgbToHex } from "../contrast/luminance.js";
|
|
2
|
+
import { oklchToRgb } from "../contrast/oklch.js";
|
|
2
3
|
const SPACING_RE = /^-?[\d.]+(rem|px)$/;
|
|
3
4
|
export function parseSpacingValue(value) {
|
|
4
5
|
if (typeof value !== "string")
|
|
@@ -13,6 +14,19 @@ export function parseSpacingValue(value) {
|
|
|
13
14
|
// A flat string color (`brand: '#3490dc'`) or a `DEFAULT` key is skipped
|
|
14
15
|
// entirely -- there's no class syntax ("bg-brand-DEFAULT" isn't real Tailwind)
|
|
15
16
|
// that would ever resolve to it, so partially supporting it would be dead code.
|
|
17
|
+
// Resolves a shade value to the plain hex string every downstream consumer
|
|
18
|
+
// (resolveColorValue -> hexToRgb in checkContrast.ts/checkFocusIndicator.ts)
|
|
19
|
+
// already assumes every palette entry is. A hex value is stored as-is; an
|
|
20
|
+
// oklch() value is converted to hex once, here, so the conversion never has
|
|
21
|
+
// to be repeated (or reimplemented) at every place a palette color gets
|
|
22
|
+
// used. Anything else (rgb()/hsl()/var()/lab()/lch()/color()/...) is still
|
|
23
|
+
// skipped -- out of scope for now, same "don't guess" precedent as before.
|
|
24
|
+
function resolveShadeHex(shadeValue) {
|
|
25
|
+
if (hexToRgb(shadeValue) !== null)
|
|
26
|
+
return shadeValue;
|
|
27
|
+
const oklchRgb = oklchToRgb(shadeValue);
|
|
28
|
+
return oklchRgb ? rgbToHex(oklchRgb) : null;
|
|
29
|
+
}
|
|
16
30
|
export function parseColorScale(value) {
|
|
17
31
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
18
32
|
return null;
|
|
@@ -22,9 +36,10 @@ export function parseColorScale(value) {
|
|
|
22
36
|
continue; // no "bg-brand-DEFAULT" class syntax exists to resolve it
|
|
23
37
|
if (typeof shadeValue !== "string")
|
|
24
38
|
continue;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
const hex = resolveShadeHex(shadeValue);
|
|
40
|
+
if (hex === null)
|
|
41
|
+
continue; // neither hex nor oklch -- skip, don't guess
|
|
42
|
+
shades[shade] = hex;
|
|
28
43
|
}
|
|
29
44
|
return Object.keys(shades).length > 0 ? shades : null;
|
|
30
45
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Static analysis CLI that catches WCAG accessibility violations — color contrast, touch target size, and focus indicator removal — in Tailwind CSS class combinations before they ship.",
|
|
3
|
+
"version": "0.13.0",
|
|
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": {
|
|
7
7
|
"tailwind-a11y": "./dist/cli.js"
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
"esbuild": "^0.28.0",
|
|
62
62
|
"tsx": "^4.19.0",
|
|
63
63
|
"typescript": "^5.5.0",
|
|
64
|
-
"vitest": "^
|
|
64
|
+
"vitest": "^4.1.11"
|
|
65
65
|
}
|
|
66
66
|
}
|