tailwind-a11y 0.7.0 → 0.9.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 -1
- package/dist/cli.js +6 -4
- package/dist/cliArgs.d.ts +1 -0
- package/dist/cliArgs.js +5 -1
- package/dist/parser/extractClasses.d.ts +1 -0
- package/dist/parser/extractClasses.js +5 -1
- package/dist/rules/checkFocusIndicator.js +38 -4
- package/dist/rules/checkTouchTarget.d.ts +3 -1
- package/dist/rules/checkTouchTarget.js +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +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 must meet WCAG 2.5.5 (AAA, 44x44px), not just 2.5.8 (AA, 24x24px)
|
|
28
29
|
npx tailwind-a11y --config ./tw.config.cjs # use a specific config instead of auto-detecting
|
|
29
30
|
npx tailwind-a11y --version # print the installed version
|
|
30
31
|
npx tailwind-a11y --help # usage and all options
|
|
@@ -53,7 +54,7 @@ Exits `1` on violations — safe to use as a CI gate.
|
|
|
53
54
|
| Check | WCAG | Detects |
|
|
54
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
|
-
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px |
|
|
57
|
+
| Touch target | 2.5.8 (AA) | Interactive elements under 24×24px — or 44×44px with `--strict` (2.5.5, AAA) |
|
|
57
58
|
| Focus indicator | 2.4.7 (AA) | `focus:outline-none` with no visible replacement |
|
|
58
59
|
|
|
59
60
|
## Scope
|
|
@@ -78,6 +79,7 @@ When a case can't be resolved with confidence, it's skipped rather than guessed:
|
|
|
78
79
|
|
|
79
80
|
- [`eslint-plugin-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/eslint-plugin-tailwind-a11y) — same checks as ESLint rules
|
|
80
81
|
- [`vscode-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/vscode-tailwind-a11y) — same checks as live editor diagnostics
|
|
82
|
+
- [`github-action-tailwind-a11y`](https://github.com/chamroro/tailwind-a11y/tree/main/packages/github-action-tailwind-a11y) — same checks as inline PR annotations
|
|
81
83
|
|
|
82
84
|
## License
|
|
83
85
|
|
package/dist/cli.js
CHANGED
|
@@ -20,8 +20,10 @@ function formatViolation(v) {
|
|
|
20
20
|
const base = `${v.line}: ${v.textClass} on ${v.bgClass} — ratio ${v.ratio.toFixed(2)}, needs ${v.required} (${v.level})`;
|
|
21
21
|
return v.suggestion ? `${base}; try ${v.suggestion} (${v.suggestedRatio.toFixed(2)})` : base;
|
|
22
22
|
}
|
|
23
|
-
case "touch-target":
|
|
24
|
-
|
|
23
|
+
case "touch-target": {
|
|
24
|
+
const sc = v.level === "AAA" ? "2.5.5" : "2.5.8";
|
|
25
|
+
return `${v.line}: <${v.tagName}> is ${v.widthPx}×${v.heightPx}px (${v.widthClass} ${v.heightClass}) — WCAG ${sc} requires >= ${v.required}×${v.required}px`;
|
|
26
|
+
}
|
|
25
27
|
case "focus-indicator":
|
|
26
28
|
return `${v.line}: <${v.tagName}> removes the focus outline (${v.removalClass}) with no visible replacement (focus:ring-*/border-*/shadow-*/bg-*/outline-*)`;
|
|
27
29
|
}
|
|
@@ -40,7 +42,7 @@ function groupByFile(items) {
|
|
|
40
42
|
return byFile;
|
|
41
43
|
}
|
|
42
44
|
async function main() {
|
|
43
|
-
const { help, version, verbose, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
|
|
45
|
+
const { help, version, verbose, strict, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
|
|
44
46
|
if (help) {
|
|
45
47
|
console.log(getHelpText());
|
|
46
48
|
return;
|
|
@@ -77,7 +79,7 @@ async function main() {
|
|
|
77
79
|
try {
|
|
78
80
|
const code = readFileSync(absPath, "utf8");
|
|
79
81
|
const contrastChecks = extractChecks(code, file);
|
|
80
|
-
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing)), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
82
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(extractFocusIndicatorChecks(code, file)));
|
|
81
83
|
if (verbose) {
|
|
82
84
|
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
83
85
|
}
|
package/dist/cliArgs.d.ts
CHANGED
package/dist/cliArgs.js
CHANGED
|
@@ -9,6 +9,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
|
+
--strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
|
|
13
|
+
instead of the default 2.5.8 (AA, 24x24px)
|
|
12
14
|
--config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
|
|
13
15
|
@theme file like app/globals.css (v4) to read
|
|
14
16
|
custom theme colors/spacing from (default:
|
|
@@ -18,6 +20,7 @@ Examples:
|
|
|
18
20
|
tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
|
|
19
21
|
tailwind-a11y "src/**/*.tsx" Scan a custom glob pattern
|
|
20
22
|
tailwind-a11y --verbose Also report skipped/unresolvable cases
|
|
23
|
+
tailwind-a11y --strict Enforce the stricter 44x44px touch target minimum
|
|
21
24
|
tailwind-a11y --config ./tailwind.config.cjs
|
|
22
25
|
`;
|
|
23
26
|
export function getHelpText() {
|
|
@@ -25,7 +28,7 @@ export function getHelpText() {
|
|
|
25
28
|
}
|
|
26
29
|
// -v/--verbose already existed before --version was added; -V (uppercase)
|
|
27
30
|
// avoids colliding with it, matching a common CLI convention.
|
|
28
|
-
const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h"]);
|
|
31
|
+
const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h", "--strict"]);
|
|
29
32
|
export function parseArgs(argv) {
|
|
30
33
|
let config = null;
|
|
31
34
|
let configError = null;
|
|
@@ -53,6 +56,7 @@ export function parseArgs(argv) {
|
|
|
53
56
|
help: argv.includes("--help") || argv.includes("-h"),
|
|
54
57
|
version: argv.includes("--version") || argv.includes("-V"),
|
|
55
58
|
verbose: argv.includes("--verbose") || argv.includes("-v"),
|
|
59
|
+
strict: argv.includes("--strict"),
|
|
56
60
|
config,
|
|
57
61
|
configError,
|
|
58
62
|
patterns,
|
|
@@ -5,6 +5,7 @@ export interface ContrastCheck {
|
|
|
5
5
|
bgColorClass: string;
|
|
6
6
|
bgSource: "self" | "parent";
|
|
7
7
|
}
|
|
8
|
+
export declare const COLOR_TOKEN: RegExp;
|
|
8
9
|
export declare function lastColorToken(className: string, prefix: "text" | "bg"): string | null;
|
|
9
10
|
export declare function extractChecks(code: string, filePath: string): ContrastCheck[];
|
|
10
11
|
export interface ContrastSkip {
|
|
@@ -9,7 +9,11 @@ import { getStaticClassName, parseJSX, traverse } from "./babelInterop.js";
|
|
|
9
9
|
// an extremely common real idiom, more so than the named-scale case, and
|
|
10
10
|
// dropping it here would make the contrast checker's opacity support (see
|
|
11
11
|
// rules/checkContrast.ts) silently inapplicable to the most common case.
|
|
12
|
-
|
|
12
|
+
// Exported for reuse in checkFocusIndicator.ts, which needs the exact same
|
|
13
|
+
// "does this suffix look like a color value" test to detect shadow-{color}/
|
|
14
|
+
// ring-{color} decoys (see that file for why) -- one definition, not a
|
|
15
|
+
// second copy that could silently drift out of sync.
|
|
16
|
+
export const COLOR_TOKEN = /^\[(#[0-9a-fA-F]{3,8})\](\/\d{1,3})?$|^[a-z]+-\d{2,3}(\/\d{1,3})?$|^(white|black|transparent|current|inherit)(\/\d{1,3})?$/;
|
|
13
17
|
// opacity-{N} (e.g. bg-opacity-50, text-opacity-50) matches the same
|
|
14
18
|
// "word-number" shape as a color token but isn't one — without this
|
|
15
19
|
// exclusion it can silently overwrite a real color match via the
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { COLOR_TOKEN } from "../parser/extractClasses.js";
|
|
1
2
|
const REMOVAL_BASE = "outline-none";
|
|
2
3
|
// Utilities that match the "replacement" shape but are semantically no-ops —
|
|
3
4
|
// the same failure mode as bg-opacity-50 masking a real color match: a
|
|
4
5
|
// same-prefix decoy that would silently hide a real violation if we only
|
|
5
|
-
// checked the prefix.
|
|
6
|
-
|
|
6
|
+
// checked the prefix. border-none/border-hidden only set border-style, not
|
|
7
|
+
// border-width -- verified against a real Tailwind v4 build that preflight
|
|
8
|
+
// resets every element to `border: 0 solid`, so border-width stays 0
|
|
9
|
+
// regardless of style and no border is ever drawn, same failure mode as
|
|
10
|
+
// border-0.
|
|
11
|
+
const DEGENERATE_BASES = new Set([
|
|
12
|
+
"outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent",
|
|
13
|
+
"border-none", "border-hidden",
|
|
14
|
+
]);
|
|
7
15
|
// Modifier-only utilities (opacity/offset/inset) don't set a concrete value
|
|
8
16
|
// on their own — e.g. bg-opacity-50 with no bg-* color, or ring-offset-4
|
|
9
17
|
// with no ring-* width, renders nothing visible by itself. Same failure
|
|
@@ -31,8 +39,32 @@ const NON_VISUAL_BASES = new Set([
|
|
|
31
39
|
]);
|
|
32
40
|
// bg-blend-{mode} (e.g. bg-blend-multiply) sets a blend mode, not a color --
|
|
33
41
|
// suffix-varying like MODIFIER_ONLY above, so pattern-matched instead of
|
|
34
|
-
// enumerated.
|
|
35
|
-
|
|
42
|
+
// enumerated. border-spacing-*/-x-*/-y-* sets the CSS border-spacing
|
|
43
|
+
// property, which only affects the gap between <table> cells -- verified
|
|
44
|
+
// against a real Tailwind v4 build that it has zero visual effect on any
|
|
45
|
+
// non-table element (the only elements this check ever fires on), even
|
|
46
|
+
// with a real numeric value applied.
|
|
47
|
+
const NON_VISUAL_PATTERN = /^bg-blend-|^border-spacing(-[xy])?-/;
|
|
48
|
+
// shadow-{color}/ring-{color} alone (e.g. shadow-red-500, ring-blue-400/50,
|
|
49
|
+
// an arbitrary hex) only sets the --tw-shadow-color/--tw-ring-color CSS
|
|
50
|
+
// variable -- the actual box-shadow property is set by a separate *size*
|
|
51
|
+
// utility (shadow-lg, ring-2, etc.) that references that variable. Verified
|
|
52
|
+
// against a real Tailwind v4 build: shadow-red-500 alone computes to
|
|
53
|
+
// `box-shadow: none`; shadow-lg shadow-red-500 together produce a real,
|
|
54
|
+
// colored shadow. Same underlying mechanism as MODIFIER_ONLY's opacity/
|
|
55
|
+
// offset cases above, but shaped like a color token rather than a fixed
|
|
56
|
+
// suffix, so it reuses COLOR_TOKEN (the exact same "is this a color value"
|
|
57
|
+
// test extractClasses.ts uses) instead of a third, drifting definition of
|
|
58
|
+
// what a color looks like.
|
|
59
|
+
function isColorOnlyShadowOrRing(base) {
|
|
60
|
+
const shadowMatch = /^shadow-(.+)$/.exec(base);
|
|
61
|
+
if (shadowMatch && COLOR_TOKEN.test(shadowMatch[1]))
|
|
62
|
+
return true;
|
|
63
|
+
const ringMatch = /^ring-(.+)$/.exec(base);
|
|
64
|
+
if (ringMatch && COLOR_TOKEN.test(ringMatch[1]))
|
|
65
|
+
return true;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
36
68
|
function baseUtility(raw) {
|
|
37
69
|
return raw.slice(raw.lastIndexOf(":") + 1);
|
|
38
70
|
}
|
|
@@ -42,6 +74,8 @@ function isReplacement(raw) {
|
|
|
42
74
|
return false;
|
|
43
75
|
if (NON_VISUAL_BASES.has(base) || NON_VISUAL_PATTERN.test(base))
|
|
44
76
|
return false;
|
|
77
|
+
if (isColorOnlyShadowOrRing(base))
|
|
78
|
+
return false;
|
|
45
79
|
return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
|
|
46
80
|
}
|
|
47
81
|
export function checkFocusIndicators(checks) {
|
|
@@ -8,5 +8,7 @@ export interface TouchTargetViolation {
|
|
|
8
8
|
heightClass: string;
|
|
9
9
|
widthPx: number;
|
|
10
10
|
heightPx: number;
|
|
11
|
+
required: number;
|
|
12
|
+
level: "AA" | "AAA";
|
|
11
13
|
}
|
|
12
|
-
export declare function checkTouchTargets(checks: TouchTargetCheck[]): TouchTargetViolation[];
|
|
14
|
+
export declare function checkTouchTargets(checks: TouchTargetCheck[], strict?: boolean): TouchTargetViolation[];
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
// WCAG 2.5.8 Target Size (Minimum), Level AA: interactive targets must be
|
|
2
2
|
// at least 24x24 CSS pixels. "Minimum" is inclusive, so exactly 24x24 passes.
|
|
3
3
|
const MIN_TARGET_PX = 24;
|
|
4
|
-
|
|
4
|
+
// WCAG 2.5.5 Target Size (Enhanced), Level AAA: 44x44 CSS pixels -- opt-in
|
|
5
|
+
// via `strict`, not the default. Same "target in a sentence/text block"
|
|
6
|
+
// exemption as 2.5.8 (verified against the W3C Understanding doc), so
|
|
7
|
+
// extractTouchTargets.ts's isInlineInText() exemption logic applies
|
|
8
|
+
// unchanged to both thresholds -- this file only changes which number is
|
|
9
|
+
// compared against, not how targets are found or exempted.
|
|
10
|
+
const MIN_TARGET_PX_STRICT = 44;
|
|
11
|
+
export function checkTouchTargets(checks, strict = false) {
|
|
12
|
+
const required = strict ? MIN_TARGET_PX_STRICT : MIN_TARGET_PX;
|
|
13
|
+
const level = strict ? "AAA" : "AA";
|
|
5
14
|
return checks
|
|
6
|
-
.filter((c) => c.widthPx <
|
|
7
|
-
.map((c) => ({ type: "touch-target", ...c }));
|
|
15
|
+
.filter((c) => c.widthPx < required || c.heightPx < required)
|
|
16
|
+
.map((c) => ({ type: "touch-target", ...c, required, level }));
|
|
8
17
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|