/**
* Tests for: prefer-tailwind
*
* Encourages using Tailwind className over inline style attributes.
*/
import { RuleTester } from "@typescript-eslint/rule-tester";
import { describe, it, afterAll } from "vitest";
import rule from "./index.js";
RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
});
ruleTester.run("prefer-tailwind", rule, {
valid: [
// ============================================
// TAILWIND ONLY (no style attribute)
// ============================================
{
name: "all elements use className only",
code: `
function Component() {
return (
<>
>
);
}
`,
},
{
name: "using class attribute (HTML)",
code: `
function Component() {
return (
<>
>
);
}
`,
},
// ============================================
// BOTH STYLE AND CLASSNAME (acceptable)
// ============================================
{
name: "elements with both style and className are fine",
code: `
function Component() {
const dynamicColor = 'red';
return (
<>
>
);
}
`,
},
{
name: "mix of className-only and className+style",
code: `
function Component() {
return (
<>
>
);
}
`,
},
// ============================================
// BELOW THRESHOLD (not enough elements)
// ============================================
{
name: "too few styled elements to analyze (default minElements=3)",
code: `
function Component() {
return (
<>
>
);
}
`,
},
{
name: "single style-only element in file",
code: ``,
},
// ============================================
// LOW RATIO (below threshold)
// ============================================
{
name: "style ratio below threshold (1 style-only out of 4 = 25%)",
code: `
function Component() {
return (
<>
>
);
}
`,
},
{
name: "exactly at threshold (30%) should not warn",
code: `
function Component() {
return (
<>
>
);
}
`,
// 3 style-only out of 10 = 30%, exactly at threshold (uses >)
},
// ============================================
// NO STYLED ELEMENTS
// ============================================
{
name: "no style or className attributes",
code: `
function Component() {
return (
<>
Plain text
>
);
}
`,
},
// ============================================
// CUSTOM OPTIONS - styleRatioThreshold
// ============================================
{
name: "custom threshold allows higher ratio",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ styleRatioThreshold: 0.7 }], // 66% < 70%
},
// ============================================
// CUSTOM OPTIONS - minElementsForAnalysis
// ============================================
{
name: "custom minElements prevents analysis",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ minElementsForAnalysis: 5 }], // Only 4 elements
},
// ============================================
// CUSTOM OPTIONS - allowedStyleProperties
// ============================================
{
name: "allowed style properties are ignored",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ allowedStyleProperties: ["transform", "animation"] }],
},
{
name: "mixed allowed and disallowed properties - all allowed",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ allowedStyleProperties: ["transform", "animation"] }],
},
// ============================================
// CUSTOM OPTIONS - ignoreComponents
// ============================================
{
name: "ignored components are skipped",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ ignoreComponents: ["motion.div"] }],
},
{
name: "animated library components ignored",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ ignoreComponents: ["animated.View", "animated.div", "animated.span"] }],
},
// ============================================
// EDGE CASES
// ============================================
{
name: "className from cn() function",
code: `
function Component() {
return (
<>
>
);
}
`,
},
{
name: "className from template literal",
code: `
function Component() {
return (
<>
>
);
}
`,
},
{
name: "layout-heavy class strings do not count as visual clusters",
code: `
function Component() {
return (
);
}
`,
},
{
name: "three visual utilities are below the default cluster threshold",
code: `
function Component() {
return ;
}
`,
},
{
name: "four visual utilities from one group are below the default group threshold",
code: `
function Component() {
return ;
}
`,
},
{
name: "standalone opacity utilities are allowed",
code: `
function Component() {
return ;
}
`,
},
{
name: "text size slash line-height utilities are allowed",
code: `
function Component() {
return ;
}
`,
},
{
name: "allowed visual utility classes are ignored in cluster detection",
code: `
function Component() {
return ;
}
`,
options: [
{
allowedVisualUtilityClasses: [
"bg-card",
"rounded-2xl",
"shadow-md",
"border",
"border-border/40",
],
allowedOpacityModifierClasses: ["border-border/40"],
},
],
},
{
name: "allowed opacity modifier classes are ignored",
code: `
function Component() {
return ;
}
`,
options: [
{
allowedOpacityModifierClasses: [
"text-foreground/80",
"border-border/40",
],
},
],
},
{
name: "lowercase html elements with variant-like props are ignored",
code: `
function Component() {
return ;
}
`,
},
{
name: "custom component variant with small className override is allowed",
code: `
function Component() {
return ;
}
`,
},
{
name: "component variant leakage can be restricted to explicit components",
code: `
function Component() {
return ;
}
`,
options: [{ componentVariantComponents: ["Button"] }],
},
{
name: "component variant leakage check can be disabled",
code: `
function Component() {
return ;
}
`,
options: [{ preferComponentVariants: false }],
},
],
invalid: [
// ============================================
// HIGH RATIO OF STYLE-ONLY ELEMENTS
// ============================================
{
name: "all elements use style only (100%)",
code: `
function Component() {
return (
<>
>
);
}
`,
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
{
name: "high ratio (75% style-only)",
code: `
function Component() {
return (
<>
>
);
}
`,
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
{
name: "just over threshold (40% when threshold is 30%)",
code: `
function Component() {
return (
<>
>
);
}
`,
// 2 style-only out of 5 = 40% > 30%
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
// ============================================
// STYLE ATTRIBUTE VARIANTS
// ============================================
{
name: "detects style object expressions",
code: `
function Component() {
const styles = { color: 'red' };
return (
<>
>
);
}
`,
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
// ============================================
// MIXED WITH ACCEPTABLE ELEMENTS
// ============================================
{
name: "reports only style-only elements, not style+className",
code: `
function Component() {
return (
<>
>
);
}
`,
// 3 style-only, 1 style+className
// Ratio: 3/4 = 75%
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
// ============================================
// CUSTOM OPTIONS - STRICTER THRESHOLD
// ============================================
{
name: "custom lower threshold catches more",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ styleRatioThreshold: 0.1 }], // 20% > 10%
errors: [{ messageId: "preferTailwind" }],
},
// ============================================
// ALLOWED PROPERTIES WITH DISALLOWED
// ============================================
{
name: "mixed allowed and disallowed properties still warns",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ allowedStyleProperties: ["transform", "animation"] }],
// Each element has at least one non-allowed property
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
// ============================================
// IGNORED COMPONENTS DON'T AFFECT RATIO
// ============================================
{
name: "ignored components don't affect ratio calculation",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ ignoreComponents: ["motion.div"] }],
// motion.div is ignored, so 3/3 = 100% style-only
errors: [
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
{ messageId: "preferTailwind" },
],
},
// ============================================
// PREFER SEMANTIC COLORS
// ============================================
{
name: "hard-coded color classes trigger warning (bg-red-500)",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
errors: [
{ messageId: "preferSemanticColors" },
{ messageId: "preferSemanticColors" },
{ messageId: "preferSemanticColors" },
],
},
{
name: "hard-coded color with opacity modifier",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: true }],
errors: [{ messageId: "preferSemanticColors" }],
},
{
name: "hard-coded colors in hover/focus states",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: true }],
errors: [{ messageId: "preferSemanticColors" }],
},
{
name: "hard-coded colors in dark mode variants",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: true }],
errors: [{ messageId: "preferSemanticColors" }],
},
{
name: "ring and outline colors",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: true }],
errors: [{ messageId: "preferSemanticColors" }],
},
{
name: "gradient colors",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: true }],
errors: [{ messageId: "preferSemanticColors" }],
},
{
name: "decoration and accent colors",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: true }],
errors: [{ messageId: "preferSemanticColors" }],
},
// ============================================
// SEMANTIC CLASS GROUPS
// ============================================
{
name: "sessions-style panel visual cluster",
code: `
function Component() {
return ;
}
`,
errors: [
{ messageId: "preferSemanticClassGroups" },
{ messageId: "semanticOpacityModifier" },
],
},
{
name: "gradient shadow border radius visual cluster",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "preferSemanticClassGroups" }],
},
{
name: "visual cluster in cn() call",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "preferSemanticClassGroups" }],
},
{
name: "visual cluster in template literal static chunks",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "preferSemanticClassGroups" }],
},
// ============================================
// SEMANTIC OPACITY MODIFIERS
// ============================================
{
name: "semantic text opacity modifier",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "semanticOpacityModifier" }],
},
{
name: "semantic muted text opacity modifier",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "semanticOpacityModifier" }],
},
{
name: "semantic border opacity modifier",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "semanticOpacityModifier" }],
},
{
name: "semantic opacity modifier with variant",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "semanticOpacityModifier" }],
},
{
name: "arbitrary semantic variable opacity modifier",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "semanticOpacityModifier" }],
},
// ============================================
// COMPONENT VARIANT LEAKAGE
// ============================================
{
name: "component with variant props and bespoke button styling",
code: `
function Component({ running, onNewRun, phase }) {
return (
);
}
`,
errors: [
{ messageId: "componentVariantLeakage" },
{ messageId: "semanticOpacityModifier" },
],
},
{
name: "generic custom component with variant prop is inferred without component allowlist",
code: `
function Component() {
return ;
}
`,
errors: [{ messageId: "componentVariantLeakage" }],
},
{
name: "component variant leakage supports template and object combiner chunks",
code: `
function Component({ active }) {
return (
);
}
`,
errors: [
{ messageId: "componentVariantLeakage" },
{ messageId: "semanticOpacityModifier" },
],
},
],
});
// Separate test suite for preferSemanticColors valid cases
ruleTester.run("prefer-tailwind (semantic colors - valid)", rule, {
valid: [
// ============================================
// SEMANTIC COLORS (preferred)
// ============================================
{
name: "semantic color classes are fine",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
},
{
name: "shadcn semantic colors",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
},
{
name: "custom semantic colors",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
},
// ============================================
// NEUTRAL/STRUCTURAL COLORS (allowed by default)
// ============================================
{
name: "white/black/transparent are allowed",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
},
{
name: "inherit and current are allowed",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
},
// ============================================
// NON-COLOR CLASSES
// ============================================
{
name: "non-color utility classes are fine",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true }],
},
// ============================================
// OPTION DISABLED (explicitly)
// ============================================
{
name: "hard-coded colors allowed when option disabled",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: false }],
},
{
name: "explicitly disabled option allows hard-coded colors",
code: `
function Component() {
return ;
}
`,
options: [{ preferSemanticColors: false }],
},
// ============================================
// ALLOWED HARD-CODED COLORS
// ============================================
{
name: "allowedHardCodedColors option whitelists specific colors",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true, allowedHardCodedColors: ["red", "blue"] }],
},
{
name: "allowed colors work with all shades",
code: `
function Component() {
return (
<>
>
);
}
`,
options: [{ preferSemanticColors: true, allowedHardCodedColors: ["gray"] }],
},
],
invalid: [],
});