tailwind-a11y 0.9.0 → 0.10.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 +2 -1
- package/dist/cli.js +10 -2
- package/dist/cliArgs.js +4 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/parser/extractFocusIndicators.d.ts +2 -0
- package/dist/parser/extractFocusIndicators.js +20 -0
- package/dist/rules/checkContrast.js +4 -1
- package/dist/rules/checkFocusIndicator.d.ts +15 -0
- package/dist/rules/checkFocusIndicator.js +109 -0
- package/package.json +1 -1
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 # touch targets: WCAG 2.5.5 (AAA, 44x44px); focus indicators: also WCAG 2.4.13 (AAA, min thickness)
|
|
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
|
|
@@ -56,6 +56,7 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
56
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 |
|
|
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
|
+
| 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) |
|
|
59
60
|
|
|
60
61
|
## Scope
|
|
61
62
|
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { checkContrast, checkContrastValueSkips } from "./rules/checkContrast.js
|
|
|
8
8
|
import { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
|
|
9
9
|
import { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
10
10
|
import { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
11
|
-
import { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
11
|
+
import { checkFocusContrast, checkFocusIndicators, } from "./rules/checkFocusIndicator.js";
|
|
12
12
|
import { parseArgs, getHelpText } from "./cliArgs.js";
|
|
13
13
|
import { resolveTheme } from "./theme/loadCustomTheme.js";
|
|
14
14
|
// ../package.json resolves correctly from both src/ (dev) and dist/ (published).
|
|
@@ -26,6 +26,13 @@ function formatViolation(v) {
|
|
|
26
26
|
}
|
|
27
27
|
case "focus-indicator":
|
|
28
28
|
return `${v.line}: <${v.tagName}> removes the focus outline (${v.removalClass}) with no visible replacement (focus:ring-*/border-*/shadow-*/bg-*/outline-*)`;
|
|
29
|
+
case "focus-contrast": {
|
|
30
|
+
const sc = v.level === "AAA" ? "2.4.13" : "1.4.11";
|
|
31
|
+
const base = `${v.line}: <${v.tagName}> focus indicator ${v.indicatorClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (WCAG ${sc})`;
|
|
32
|
+
return v.thicknessPx !== undefined
|
|
33
|
+
? `${base}; also only ${v.thicknessPx}px thick, needs >= ${v.requiredThicknessPx}px`
|
|
34
|
+
: base;
|
|
35
|
+
}
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
function groupByFile(items) {
|
|
@@ -79,7 +86,8 @@ async function main() {
|
|
|
79
86
|
try {
|
|
80
87
|
const code = readFileSync(absPath, "utf8");
|
|
81
88
|
const contrastChecks = extractChecks(code, file);
|
|
82
|
-
|
|
89
|
+
const focusChecks = extractFocusIndicatorChecks(code, file);
|
|
90
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette));
|
|
83
91
|
if (verbose) {
|
|
84
92
|
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
85
93
|
}
|
package/dist/cliArgs.js
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
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, and focus indicator removal.
|
|
6
|
+
touch target size, and focus indicator removal/contrast.
|
|
7
7
|
|
|
8
8
|
Options:
|
|
9
9
|
-v, --verbose Also report what couldn't be checked, and why
|
|
10
10
|
-V, --version Print the version number
|
|
11
11
|
-h, --help Print this help message
|
|
12
12
|
--strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
|
|
13
|
-
instead of the default 2.5.8 (AA, 24x24px)
|
|
13
|
+
instead of the default 2.5.8 (AA, 24x24px); focus
|
|
14
|
+
indicators are also held to 2.4.13 (AAA, minimum
|
|
15
|
+
thickness) alongside the default 1.4.11 (AA, contrast)
|
|
14
16
|
--config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
|
|
15
17
|
@theme file like app/globals.css (v4) to read
|
|
16
18
|
custom theme colors/spacing from (default:
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { checkContrast, checkContrastValueSkips, suggestContrastFix, type Contra
|
|
|
3
3
|
export { extractTouchTargetChecks, extractTouchTargetSkips, type TouchTargetCheck, type TouchTargetSkip } from "./parser/extractTouchTargets.js";
|
|
4
4
|
export { checkTouchTargets, type TouchTargetViolation } from "./rules/checkTouchTarget.js";
|
|
5
5
|
export { extractFocusIndicatorChecks, type FocusIndicatorCheck } from "./parser/extractFocusIndicators.js";
|
|
6
|
-
export { checkFocusIndicators, type FocusIndicatorViolation } from "./rules/checkFocusIndicator.js";
|
|
6
|
+
export { checkFocusIndicators, checkFocusContrast, type FocusIndicatorViolation, type FocusContrastViolation, } from "./rules/checkFocusIndicator.js";
|
|
7
7
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio, type RGB } from "./contrast/luminance.js";
|
|
8
8
|
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, type ResolvedTheme, type RawCustomTheme, } from "./theme/loadCustomTheme.js";
|
|
9
9
|
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { checkContrast, checkContrastValueSkips, suggestContrastFix } from "./ru
|
|
|
3
3
|
export { extractTouchTargetChecks, extractTouchTargetSkips } from "./parser/extractTouchTargets.js";
|
|
4
4
|
export { checkTouchTargets } from "./rules/checkTouchTarget.js";
|
|
5
5
|
export { extractFocusIndicatorChecks } from "./parser/extractFocusIndicators.js";
|
|
6
|
-
export { checkFocusIndicators } from "./rules/checkFocusIndicator.js";
|
|
6
|
+
export { checkFocusIndicators, checkFocusContrast, } from "./rules/checkFocusIndicator.js";
|
|
7
7
|
export { hexToRgb, contrastRatio, meetsWCAG, requiredRatio } from "./contrast/luminance.js";
|
|
8
8
|
export { resolveTheme, findTailwindConfig, loadCustomTheme, findTailwindThemeCss, loadThemeFromCssFile, mergePalette, mergeSpacing, } from "./theme/loadCustomTheme.js";
|
|
9
9
|
export { parseThemeCss } from "./theme/parseThemeCss.js";
|
|
@@ -3,5 +3,7 @@ export interface FocusIndicatorCheck {
|
|
|
3
3
|
line: number;
|
|
4
4
|
tagName: string;
|
|
5
5
|
focusClasses: string[];
|
|
6
|
+
bgClass?: string | null;
|
|
7
|
+
bgSource?: "self" | "parent" | null;
|
|
6
8
|
}
|
|
7
9
|
export declare function extractFocusIndicatorChecks(code: string, filePath: string): FocusIndicatorCheck[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as t from "@babel/types";
|
|
2
2
|
import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
3
3
|
import { isInteractiveElement } from "./isInteractiveElement.js";
|
|
4
|
+
import { lastColorToken } from "./extractClasses.js";
|
|
4
5
|
function focusScopedClasses(className) {
|
|
5
6
|
return className
|
|
6
7
|
.split(/\s+/)
|
|
@@ -27,11 +28,30 @@ export function extractFocusIndicatorChecks(code, filePath) {
|
|
|
27
28
|
const focusClasses = focusScopedClasses(className);
|
|
28
29
|
if (focusClasses.length === 0)
|
|
29
30
|
return; // nothing under focus:/focus-visible: — not a candidate
|
|
31
|
+
const ownBg = lastColorToken(className, "bg");
|
|
32
|
+
let bgClass = ownBg;
|
|
33
|
+
let bgSource = ownBg ? "self" : null;
|
|
34
|
+
if (!bgClass) {
|
|
35
|
+
// Only the immediate JSX parent, same limit as extractClasses.ts's
|
|
36
|
+
// own contrast resolution — no deeper ancestor walk, no
|
|
37
|
+
// cross-component resolution.
|
|
38
|
+
const parentNode = path.parentPath?.node;
|
|
39
|
+
if (parentNode && t.isJSXElement(parentNode)) {
|
|
40
|
+
const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
|
|
41
|
+
const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
|
|
42
|
+
if (parentBg) {
|
|
43
|
+
bgClass = parentBg;
|
|
44
|
+
bgSource = "parent";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
30
48
|
checks.push({
|
|
31
49
|
file: filePath,
|
|
32
50
|
line: opening.loc?.start.line ?? 0,
|
|
33
51
|
tagName: t.isJSXIdentifier(opening.name) ? opening.name.name : "onClick-element",
|
|
34
52
|
focusClasses,
|
|
53
|
+
bgClass,
|
|
54
|
+
bgSource,
|
|
35
55
|
});
|
|
36
56
|
},
|
|
37
57
|
});
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { applyAlpha, contrastRatio, hexToRgb, meetsWCAG, requiredRatio } from "../contrast/luminance.js";
|
|
2
2
|
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
|
+
// 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);
|
|
5
8
|
if (!match)
|
|
6
9
|
return null;
|
|
7
10
|
const token = match[1];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FocusIndicatorCheck } from "../parser/extractFocusIndicators.js";
|
|
2
|
+
import type { Palette } from "../theme/defaultPalette.js";
|
|
2
3
|
export interface FocusIndicatorViolation {
|
|
3
4
|
type: "focus-indicator";
|
|
4
5
|
file: string;
|
|
@@ -7,3 +8,17 @@ export interface FocusIndicatorViolation {
|
|
|
7
8
|
removalClass: string;
|
|
8
9
|
}
|
|
9
10
|
export declare function checkFocusIndicators(checks: FocusIndicatorCheck[]): FocusIndicatorViolation[];
|
|
11
|
+
export interface FocusContrastViolation {
|
|
12
|
+
type: "focus-contrast";
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
tagName: string;
|
|
16
|
+
indicatorClass: string;
|
|
17
|
+
bgClass: string;
|
|
18
|
+
ratio: number;
|
|
19
|
+
required: number;
|
|
20
|
+
level: "AA" | "AAA";
|
|
21
|
+
thicknessPx?: number;
|
|
22
|
+
requiredThicknessPx?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function checkFocusContrast(checks: FocusIndicatorCheck[], strict?: boolean, palette?: Palette): FocusContrastViolation[];
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { COLOR_TOKEN } from "../parser/extractClasses.js";
|
|
2
|
+
import { resolveColorValue } from "./checkContrast.js";
|
|
3
|
+
import { contrastRatio, hexToRgb } from "../contrast/luminance.js";
|
|
4
|
+
import { defaultPalette } from "../theme/defaultPalette.js";
|
|
2
5
|
const REMOVAL_BASE = "outline-none";
|
|
3
6
|
// Utilities that match the "replacement" shape but are semantically no-ops —
|
|
4
7
|
// the same failure mode as bg-opacity-50 masking a real color match: a
|
|
@@ -97,3 +100,109 @@ export function checkFocusIndicators(checks) {
|
|
|
97
100
|
}
|
|
98
101
|
return violations;
|
|
99
102
|
}
|
|
103
|
+
// Flat non-text threshold -- not luminance.ts's requiredRatio()/meetsWCAG(),
|
|
104
|
+
// which model the text-specific large-text/small-text AA/AAA table that
|
|
105
|
+
// doesn't apply to UI-component contrast.
|
|
106
|
+
const NON_TEXT_MIN_RATIO = 3;
|
|
107
|
+
// WCAG 2.4.13's "2 CSS pixel thick perimeter" -- verified against a real
|
|
108
|
+
// tailwindcss@4.3.3 compile (see CLAUDE.md) rather than assumed.
|
|
109
|
+
const FOCUS_INDICATOR_MIN_THICKNESS_PX = 2;
|
|
110
|
+
// outline-{0,1,2,4,8} / ring-{0,1,2,4,8} -- Tailwind's fixed width scale for
|
|
111
|
+
// both utilities, verified against a real build. Bare `ring`/`outline` (no
|
|
112
|
+
// digit) is deliberately NOT in this map: verified via that same build that
|
|
113
|
+
// it resolves to 1px in Tailwind v4, but v3's bare `ring` default is
|
|
114
|
+
// documented elsewhere as 3px -- a real cross-version difference this tool
|
|
115
|
+
// can't currently distinguish, so bare ring/outline contributes color (if
|
|
116
|
+
// paired with an explicit color utility) but never a thickness value.
|
|
117
|
+
const WIDTH_SCALE = { "0": 0, "1": 1, "2": 2, "4": 4, "8": 8 };
|
|
118
|
+
// Near-duplicate of extractClasses.ts's lastColorToken, not a call to it:
|
|
119
|
+
// that function takes a single prefix ("text" | "bg") and this needs
|
|
120
|
+
// last-token-wins across *two* prefixes (outline-*, ring-*) in one pass, so
|
|
121
|
+
// whichever was actually written last in the class list wins regardless of
|
|
122
|
+
// which utility it is. Reuses COLOR_TOKEN (the one shared "is this
|
|
123
|
+
// color-shaped" test) and only excludes the "opacity" scale name locally --
|
|
124
|
+
// lastColorToken's full NON_COLOR_SCALE_NAMES set also excludes
|
|
125
|
+
// "linear"/"conic", but those are bg-gradient-angle utilities with no
|
|
126
|
+
// outline-*/ring-* equivalent, so they can never appear here.
|
|
127
|
+
function lastIndicatorColorToken(focusClasses) {
|
|
128
|
+
let found = null;
|
|
129
|
+
for (const raw of focusClasses) {
|
|
130
|
+
const base = raw.slice(raw.lastIndexOf(":") + 1);
|
|
131
|
+
const match = /^(?:outline|ring)-(.+)$/.exec(base);
|
|
132
|
+
if (!match)
|
|
133
|
+
continue;
|
|
134
|
+
const rest = match[1];
|
|
135
|
+
if (!COLOR_TOKEN.test(rest))
|
|
136
|
+
continue;
|
|
137
|
+
const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
|
|
138
|
+
if (scaleName === "opacity")
|
|
139
|
+
continue;
|
|
140
|
+
found = base;
|
|
141
|
+
}
|
|
142
|
+
return found;
|
|
143
|
+
}
|
|
144
|
+
// Same last-token-wins-across-both-prefixes shape as above, but for width:
|
|
145
|
+
// only an enumerated outline-{N}/ring-{N} or an arbitrary [Npx] sets a
|
|
146
|
+
// thickness. A color token (ring-blue-400), ring-offset-*, ring-inset, etc.
|
|
147
|
+
// don't match either shape and are silently ignored here -- they're a
|
|
148
|
+
// different utility's job (color, offset, inset), not this one's.
|
|
149
|
+
function lastIndicatorThicknessPx(focusClasses) {
|
|
150
|
+
let found = null;
|
|
151
|
+
for (const raw of focusClasses) {
|
|
152
|
+
const base = raw.slice(raw.lastIndexOf(":") + 1);
|
|
153
|
+
const match = /^(?:outline|ring)-(.+)$/.exec(base);
|
|
154
|
+
if (!match)
|
|
155
|
+
continue;
|
|
156
|
+
const token = match[1];
|
|
157
|
+
if (token in WIDTH_SCALE) {
|
|
158
|
+
found = WIDTH_SCALE[token];
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const arbitrary = /^\[(\d+(?:\.\d+)?)px\]$/.exec(token);
|
|
162
|
+
if (arbitrary)
|
|
163
|
+
found = Number(arbitrary[1]);
|
|
164
|
+
}
|
|
165
|
+
return found;
|
|
166
|
+
}
|
|
167
|
+
export function checkFocusContrast(checks, strict = false, palette = defaultPalette) {
|
|
168
|
+
const violations = [];
|
|
169
|
+
for (const check of checks) {
|
|
170
|
+
if (!check.bgClass)
|
|
171
|
+
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
|
+
const bgHex = resolveColorValue(check.bgClass, palette);
|
|
177
|
+
if (!indicatorHex || !bgHex)
|
|
178
|
+
continue; // custom theme color / unsupported arbitrary value — skip
|
|
179
|
+
const indicatorRgb = hexToRgb(indicatorHex);
|
|
180
|
+
const bgRgb = hexToRgb(bgHex);
|
|
181
|
+
if (!indicatorRgb || !bgRgb)
|
|
182
|
+
continue;
|
|
183
|
+
const ratio = contrastRatio(indicatorRgb, bgRgb);
|
|
184
|
+
const contrastFails = ratio < NON_TEXT_MIN_RATIO;
|
|
185
|
+
let thicknessPx = null;
|
|
186
|
+
if (strict)
|
|
187
|
+
thicknessPx = lastIndicatorThicknessPx(check.focusClasses);
|
|
188
|
+
const thicknessFails = strict && thicknessPx !== null && thicknessPx < FOCUS_INDICATOR_MIN_THICKNESS_PX;
|
|
189
|
+
if (!contrastFails && !thicknessFails)
|
|
190
|
+
continue;
|
|
191
|
+
const rawIndicatorClass = check.focusClasses.find((raw) => raw.slice(raw.lastIndexOf(":") + 1) === indicatorBase);
|
|
192
|
+
violations.push({
|
|
193
|
+
type: "focus-contrast",
|
|
194
|
+
file: check.file,
|
|
195
|
+
line: check.line,
|
|
196
|
+
tagName: check.tagName,
|
|
197
|
+
indicatorClass: rawIndicatorClass,
|
|
198
|
+
bgClass: check.bgClass,
|
|
199
|
+
ratio,
|
|
200
|
+
required: NON_TEXT_MIN_RATIO,
|
|
201
|
+
level: thicknessFails ? "AAA" : "AA",
|
|
202
|
+
...(thicknessFails && thicknessPx !== null
|
|
203
|
+
? { thicknessPx, requiredThicknessPx: FOCUS_INDICATOR_MIN_THICKNESS_PX }
|
|
204
|
+
: {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return violations;
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|