tailwind-a11y 0.8.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 +3 -1
- package/dist/cli.js +15 -5
- package/dist/cliArgs.d.ts +1 -0
- package/dist/cliArgs.js +8 -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/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: WCAG 2.5.5 (AAA, 44x44px); focus indicators: also WCAG 2.4.13 (AAA, min thickness)
|
|
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,8 +54,9 @@ 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 |
|
|
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) |
|
|
58
60
|
|
|
59
61
|
## Scope
|
|
60
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).
|
|
@@ -20,10 +20,19 @@ 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-*)`;
|
|
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
|
+
}
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
38
|
function groupByFile(items) {
|
|
@@ -40,7 +49,7 @@ function groupByFile(items) {
|
|
|
40
49
|
return byFile;
|
|
41
50
|
}
|
|
42
51
|
async function main() {
|
|
43
|
-
const { help, version, verbose, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
|
|
52
|
+
const { help, version, verbose, strict, config, configError: usageError, patterns } = parseArgs(process.argv.slice(2));
|
|
44
53
|
if (help) {
|
|
45
54
|
console.log(getHelpText());
|
|
46
55
|
return;
|
|
@@ -77,7 +86,8 @@ async function main() {
|
|
|
77
86
|
try {
|
|
78
87
|
const code = readFileSync(absPath, "utf8");
|
|
79
88
|
const contrastChecks = extractChecks(code, file);
|
|
80
|
-
|
|
89
|
+
const focusChecks = extractFocusIndicatorChecks(code, file);
|
|
90
|
+
violations.push(...checkContrast(contrastChecks, palette), ...checkTouchTargets(extractTouchTargetChecks(code, file, spacing), strict), ...checkFocusIndicators(focusChecks), ...checkFocusContrast(focusChecks, strict, palette));
|
|
81
91
|
if (verbose) {
|
|
82
92
|
skips.push(...extractContrastSkips(code, file), ...checkContrastValueSkips(contrastChecks, palette), ...extractTouchTargetSkips(code, file, spacing));
|
|
83
93
|
}
|
package/dist/cliArgs.d.ts
CHANGED
package/dist/cliArgs.js
CHANGED
|
@@ -3,12 +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
|
+
--strict Touch targets must meet WCAG 2.5.5 (AAA, 44x44px)
|
|
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)
|
|
12
16
|
--config <path> Path to a tailwind.config.js/.cjs (v3) or a CSS
|
|
13
17
|
@theme file like app/globals.css (v4) to read
|
|
14
18
|
custom theme colors/spacing from (default:
|
|
@@ -18,6 +22,7 @@ Examples:
|
|
|
18
22
|
tailwind-a11y Scan **/*.{jsx,tsx} from the current directory
|
|
19
23
|
tailwind-a11y "src/**/*.tsx" Scan a custom glob pattern
|
|
20
24
|
tailwind-a11y --verbose Also report skipped/unresolvable cases
|
|
25
|
+
tailwind-a11y --strict Enforce the stricter 44x44px touch target minimum
|
|
21
26
|
tailwind-a11y --config ./tailwind.config.cjs
|
|
22
27
|
`;
|
|
23
28
|
export function getHelpText() {
|
|
@@ -25,7 +30,7 @@ export function getHelpText() {
|
|
|
25
30
|
}
|
|
26
31
|
// -v/--verbose already existed before --version was added; -V (uppercase)
|
|
27
32
|
// avoids colliding with it, matching a common CLI convention.
|
|
28
|
-
const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h"]);
|
|
33
|
+
const FLAGS = new Set(["--verbose", "-v", "--version", "-V", "--help", "-h", "--strict"]);
|
|
29
34
|
export function parseArgs(argv) {
|
|
30
35
|
let config = null;
|
|
31
36
|
let configError = null;
|
|
@@ -53,6 +58,7 @@ export function parseArgs(argv) {
|
|
|
53
58
|
help: argv.includes("--help") || argv.includes("-h"),
|
|
54
59
|
version: argv.includes("--version") || argv.includes("-V"),
|
|
55
60
|
verbose: argv.includes("--verbose") || argv.includes("-v"),
|
|
61
|
+
strict: argv.includes("--strict"),
|
|
56
62
|
config,
|
|
57
63
|
configError,
|
|
58
64
|
patterns,
|
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
|
+
}
|
|
@@ -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.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": {
|