tailwind-a11y 0.13.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.
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tailwind-a11y",
|
|
3
|
-
"version": "0.13.
|
|
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": {
|