diff --git a/.changeset/fresh-cherries-brush.md b/.changeset/fresh-cherries-brush.md new file mode 100644 index 00000000000..f57423cce4e --- /dev/null +++ b/.changeset/fresh-cherries-brush.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add support for tailwind v4 diff --git a/packages/shadcn/src/preflights/preflight-init.ts b/packages/shadcn/src/preflights/preflight-init.ts index fc7d511ddf7..11a51f00545 100644 --- a/packages/shadcn/src/preflights/preflight-init.ts +++ b/packages/shadcn/src/preflights/preflight-init.ts @@ -78,7 +78,15 @@ export async function preFlightInit( )}.` ) - const tailwindSpinner = spinner(`Validating Tailwind CSS.`, { + let tailwindSpinnerMessage = "Validating Tailwind CSS." + + if (projectInfo.tailwindVersion === "v4") { + tailwindSpinnerMessage = `Validating Tailwind CSS config. Found ${highlighter.info( + "v4" + )}.` + } + + const tailwindSpinner = spinner(tailwindSpinnerMessage, { silent: options.silent, }).start() if ( @@ -93,6 +101,9 @@ export async function preFlightInit( ) { errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true tailwindSpinner?.fail() + } else if (!projectInfo.tailwindVersion) { + errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true + tailwindSpinner?.fail() } else { tailwindSpinner?.succeed() } diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index 8d5a8b6e07d..c7aba2217a2 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -85,6 +85,7 @@ async function addProjectComponents( cleanupDefaultNextStyles: options.isNewProject, silent: options.silent, tailwindVersion, + tailwindConfig: tree.tailwind?.config, }) await updateDependencies(tree.dependencies, config, { @@ -176,6 +177,7 @@ async function addWorkspaceComponents( await updateCssVars(component.cssVars, targetConfig, { silent: true, tailwindVersion, + tailwindConfig: component.tailwind?.config, }) filesUpdated.push( path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindCss) diff --git a/packages/shadcn/src/utils/updaters/update-css-vars.ts b/packages/shadcn/src/utils/updaters/update-css-vars.ts index 8d4cb88753c..dc664782deb 100644 --- a/packages/shadcn/src/utils/updaters/update-css-vars.ts +++ b/packages/shadcn/src/utils/updaters/update-css-vars.ts @@ -1,8 +1,11 @@ import { promises as fs } from "fs" import path from "path" -import { registryItemCssVarsSchema } from "@/src/registry/schema" +import { + registryItemCssVarsSchema, + registryItemTailwindSchema, +} from "@/src/registry/schema" import { Config } from "@/src/utils/get-config" -import { TailwindVersion, getProjectInfo } from "@/src/utils/get-project-info" +import { TailwindVersion } from "@/src/utils/get-project-info" import { highlighter } from "@/src/utils/highlighter" import { spinner } from "@/src/utils/spinner" import postcss from "postcss" @@ -18,13 +21,10 @@ export async function updateCssVars( cleanupDefaultNextStyles?: boolean silent?: boolean tailwindVersion?: TailwindVersion + tailwindConfig?: z.infer["config"] } ) { - if ( - !cssVars || - !Object.keys(cssVars).length || - !config.resolvedPaths.tailwindCss - ) { + if (!config.resolvedPaths.tailwindCss) { return } @@ -46,9 +46,10 @@ export async function updateCssVars( } ).start() const raw = await fs.readFile(cssFilepath, "utf8") - let output = await transformCssVars(raw, cssVars, config, { + let output = await transformCssVars(raw, cssVars ?? {}, config, { cleanupDefaultNextStyles: options.cleanupDefaultNextStyles, tailwindVersion: options.tailwindVersion, + tailwindConfig: options.tailwindConfig, }) await fs.writeFile(cssFilepath, output, "utf8") cssVarsSpinner.succeed() @@ -61,14 +62,17 @@ export async function transformCssVars( options: { cleanupDefaultNextStyles?: boolean tailwindVersion?: TailwindVersion + tailwindConfig?: z.infer["config"] } = { cleanupDefaultNextStyles: false, tailwindVersion: "v3", + tailwindConfig: undefined, } ) { options = { cleanupDefaultNextStyles: false, tailwindVersion: "v3", + tailwindConfig: undefined, ...options, } @@ -80,6 +84,12 @@ export async function transformCssVars( updateCssVarsPluginV4(cssVars), updateThemePlugin(cssVars), ] + + if (options.tailwindConfig) { + plugins.push(updateTailwindConfigPlugin(options.tailwindConfig)) + plugins.push(updateTailwindConfigAnimationPlugin(options.tailwindConfig)) + plugins.push(updateTailwindConfigKeyframesPlugin(options.tailwindConfig)) + } } if (options.cleanupDefaultNextStyles) { @@ -94,7 +104,13 @@ export async function transformCssVars( from: undefined, }) - return result.css + let output = result.css.replace(/\/\* ---break--- \*\//g, "") + + if (options.tailwindVersion === "v4") { + output = output.replace(/(\n\s*\n)+/g, "\n\n") + } + + return output } function updateBaseLayerPlugin() { @@ -133,6 +149,7 @@ function updateBaseLayerPlugin() { raws: { semicolon: true, between: " ", before: "\n" }, }) root.append(baseLayer) + root.insertBefore(baseLayer, postcss.comment({ text: "---break---" })) } requiredRules.forEach(({ selector, apply }) => { @@ -186,6 +203,7 @@ function updateCssVarsPlugin( }, }) root.append(baseLayer) + root.insertBefore(baseLayer, postcss.comment({ text: "---break---" })) } if (baseLayer !== undefined) { @@ -254,6 +272,16 @@ function cleanupDefaultNextStylesPlugin() { }) ?.remove() + // Remove font-family: Arial, Helvetica, sans-serif; + bodyRule.nodes + .find( + (node): node is postcss.Declaration => + node.type === "decl" && + node.prop === "font-family" && + node.value === "Arial, Helvetica, sans-serif" + ) + ?.remove() + // If the body rule is empty, remove it. if (bodyRule.nodes.length === 0) { bodyRule.remove() @@ -335,17 +363,13 @@ function updateCssVarsPluginV4( raws: { semicolon: true, between: " ", before: "\n" }, }) root.append(ruleNode) + root.insertBefore(ruleNode, postcss.comment({ text: "---break---" })) } Object.entries(vars).forEach(([key, value]) => { const prop = `--${key.replace(/^--/, "")}` - if ( - !value.startsWith("hsl") && - !value.startsWith("rgb") && - !value.startsWith("#") && - !value.startsWith("oklch") - ) { + if (isLocalHSLValue(value)) { value = `hsl(${value})` } @@ -371,25 +395,8 @@ function updateThemePlugin(cssVars: z.infer) { return { postcssPlugin: "update-theme", Once(root: Root) { - let themeNode = root.nodes.find( - (node): node is AtRule => - node.type === "atrule" && - node.name === "theme" && - node.params === "inline" - ) - - if (!themeNode) { - themeNode = postcss.atRule({ - name: "theme", - params: "inline", - nodes: [], - raws: { semicolon: true, between: " ", before: "\n" }, - }) - root.append(themeNode) - } - // Find unique color names from light and dark. - const colors = Array.from( + const variables = Array.from( new Set( Object.keys(cssVars).flatMap((key) => Object.keys(cssVars[key as keyof typeof cssVars] || {}) @@ -397,24 +404,101 @@ function updateThemePlugin(cssVars: z.infer) { ) ) - for (const color of colors) { - const colorVar = postcss.decl({ - prop: `--color-${color.replace(/^--/, "")}`, - value: `var(--${color})`, + if (!variables.length) { + return + } + + const themeNode = upsertThemeNode(root) + + const themeVarNodes = themeNode.nodes?.filter( + (node): node is postcss.Declaration => + node.type === "decl" && node.prop.startsWith("--") + ) + + for (const variable of variables) { + const value = Object.values(cssVars).find((vars) => vars[variable])?.[ + variable + ] + + if (!value) { + continue + } + + if (variable === "radius") { + const radiusVariables = { + sm: "calc(var(--radius) - 4px)", + md: "calc(var(--radius) - 2px)", + lg: "var(--radius)", + xl: "calc(var(--radius) + 4px)", + } + for (const [key, value] of Object.entries(radiusVariables)) { + const cssVarNode = postcss.decl({ + prop: `--radius-${key}`, + value, + raws: { semicolon: true }, + }) + if ( + themeNode?.nodes?.find( + (node): node is postcss.Declaration => + node.type === "decl" && node.prop === cssVarNode.prop + ) + ) { + continue + } + themeNode?.append(cssVarNode) + } + break + } + + const cssVarNode = postcss.decl({ + prop: + isLocalHSLValue(value) || isColorValue(value) + ? `--color-${variable.replace(/^--/, "")}` + : `--${variable.replace(/^--/, "")}`, + value: `var(--${variable})`, raws: { semicolon: true }, }) const existingDecl = themeNode?.nodes?.find( (node): node is postcss.Declaration => - node.type === "decl" && node.prop === colorVar.prop + node.type === "decl" && node.prop === cssVarNode.prop ) if (!existingDecl) { - themeNode?.append(colorVar) + if (themeVarNodes?.length) { + themeNode?.insertAfter( + themeVarNodes[themeVarNodes.length - 1], + cssVarNode + ) + } else { + themeNode?.append(cssVarNode) + } } } }, } } +function upsertThemeNode(root: Root): AtRule { + let themeNode = root.nodes.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === "theme" && + node.params === "inline" + ) + + if (!themeNode) { + themeNode = postcss.atRule({ + name: "theme", + params: "inline", + nodes: [], + raws: { semicolon: true, between: " ", before: "\n" }, + }) + root.append(themeNode) + root.insertBefore(themeNode, postcss.comment({ text: "---break---" })) + } + + return themeNode +} + function addCustomVariant({ params }: { params: string }) { return { postcssPlugin: "add-custom-variant", @@ -424,15 +508,217 @@ function addCustomVariant({ params }: { params: string }) { node.type === "atrule" && node.name === "custom-variant" ) if (!customVariant) { - root.insertAfter( - root.nodes[0], - postcss.atRule({ - name: "custom-variant", - params, - raws: { semicolon: true, before: "\n" }, + const variantNode = postcss.atRule({ + name: "custom-variant", + params, + raws: { semicolon: true, before: "\n" }, + }) + root.insertAfter(root.nodes[0], variantNode) + root.insertBefore(variantNode, postcss.comment({ text: "---break---" })) + } + }, + } +} + +function updateTailwindConfigPlugin( + tailwindConfig: z.infer["config"] +) { + return { + postcssPlugin: "update-tailwind-config", + Once(root: Root) { + if (!tailwindConfig?.plugins) { + return + } + + const quoteType = getQuoteType(root) + const quote = quoteType === "single" ? "'" : '"' + + const pluginNodes = root.nodes.filter( + (node): node is AtRule => + node.type === "atrule" && node.name === "plugin" + ) + + const lastPluginNode = + pluginNodes[pluginNodes.length - 1] || root.nodes[0] + + for (const plugin of tailwindConfig.plugins) { + const pluginName = plugin.replace(/^require\(["']|["']\)$/g, "") + + // Check if the plugin is already present. + if ( + pluginNodes.some((node) => { + return node.params.replace(/["']/g, "") === pluginName }) + ) { + continue + } + + const pluginNode = postcss.atRule({ + name: "plugin", + params: `${quote}${pluginName}${quote}`, + raws: { semicolon: true, before: "\n" }, + }) + root.insertAfter(lastPluginNode, pluginNode) + root.insertBefore(pluginNode, postcss.comment({ text: "---break---" })) + } + }, + } +} + +function updateTailwindConfigKeyframesPlugin( + tailwindConfig: z.infer["config"] +) { + return { + postcssPlugin: "update-tailwind-config-keyframes", + Once(root: Root) { + if (!tailwindConfig?.theme?.extend?.keyframes) { + return + } + + const themeNode = upsertThemeNode(root) + const existingKeyFrameNodes = themeNode.nodes?.filter( + (node): node is AtRule => + node.type === "atrule" && node.name === "keyframes" + ) + + const keyframeValueSchema = z.record( + z.string(), + z.record(z.string(), z.string()) + ) + + for (const [keyframeName, keyframeValue] of Object.entries( + tailwindConfig.theme.extend.keyframes + )) { + if (typeof keyframeName !== "string") { + continue + } + + const parsedKeyframeValue = keyframeValueSchema.safeParse(keyframeValue) + + if (!parsedKeyframeValue.success) { + continue + } + + if ( + existingKeyFrameNodes?.find( + (node): node is postcss.AtRule => + node.type === "atrule" && + node.name === "keyframes" && + node.params === keyframeName + ) + ) { + continue + } + + const keyframeNode = postcss.atRule({ + name: "keyframes", + params: keyframeName, + nodes: [], + raws: { semicolon: true, between: " ", before: "\n " }, + }) + + for (const [key, values] of Object.entries(parsedKeyframeValue.data)) { + const rule = postcss.rule({ + selector: key, + nodes: Object.entries(values).map(([key, value]) => + postcss.decl({ + prop: key, + value, + raws: { semicolon: true, before: "\n ", between: ": " }, + }) + ), + raws: { semicolon: true, between: " ", before: "\n " }, + }) + keyframeNode.append(rule) + } + + themeNode.append(keyframeNode) + themeNode.insertBefore( + keyframeNode, + postcss.comment({ text: "---break---" }) ) } }, } } + +function updateTailwindConfigAnimationPlugin( + tailwindConfig: z.infer["config"] +) { + return { + postcssPlugin: "update-tailwind-config-animation", + Once(root: Root) { + if (!tailwindConfig?.theme?.extend?.animation) { + return + } + + const themeNode = upsertThemeNode(root) + const existingAnimationNodes = themeNode.nodes?.filter( + (node): node is postcss.Declaration => + node.type === "decl" && node.prop.startsWith("--animate-") + ) + + const parsedAnimationValue = z + .record(z.string(), z.string()) + .safeParse(tailwindConfig.theme.extend.animation) + if (!parsedAnimationValue.success) { + return + } + + for (const [key, value] of Object.entries(parsedAnimationValue.data)) { + const prop = `--animate-${key}` + if ( + existingAnimationNodes?.find( + (node): node is postcss.Declaration => node.prop === prop + ) + ) { + continue + } + + const animationNode = postcss.decl({ + prop, + value, + raws: { semicolon: true, between: ": ", before: "\n " }, + }) + themeNode.append(animationNode) + } + }, + } +} + +function getQuoteType(root: Root): "single" | "double" { + const firstNode = root.nodes[0] + const raw = firstNode.toString() + + if (raw.includes("'")) { + return "single" + } + return "double" +} + +export function isLocalHSLValue(value: string) { + if ( + value.startsWith("hsl") || + value.startsWith("rgb") || + value.startsWith("#") || + value.startsWith("oklch") + ) { + return false + } + + const chunks = value.split(" ") + + return ( + chunks.length === 3 && + chunks.slice(1, 3).every((chunk) => chunk.includes("%")) + ) +} + +export function isColorValue(value: string) { + return ( + value.startsWith("hsl") || + value.startsWith("rgb") || + value.startsWith("#") || + value.startsWith("oklch") + ) +} diff --git a/packages/shadcn/test/utils/updaters/update-css-vars.test.ts b/packages/shadcn/test/utils/updaters/update-css-vars.test.ts index 641d8e67b1e..a15bce115b0 100644 --- a/packages/shadcn/test/utils/updaters/update-css-vars.test.ts +++ b/packages/shadcn/test/utils/updaters/update-css-vars.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest" -import { transformCssVars } from "../../../src/utils/updaters/update-css-vars" +import { + isLocalHSLValue, + transformCssVars, +} from "../../../src/utils/updaters/update-css-vars" describe("transformCssVars", () => { test("should add light and dark css vars if not present", async () => { @@ -30,6 +33,7 @@ describe("transformCssVars", () => { "@tailwind base; @tailwind components; @tailwind utilities; + @layer base { :root { --background: white; @@ -40,6 +44,7 @@ describe("transformCssVars", () => { --foreground: white } } + @layer base { * { @apply border-border; @@ -101,6 +106,8 @@ describe("transformCssVars", () => { } } + + @layer base { * { @apply border-border; @@ -195,19 +202,24 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { --background: hsl(0 0% 100%); --foreground: hsl(240 10% 3.9%); } + .dark { --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); } + @layer base { * { @apply border-border; @@ -248,6 +260,7 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); :root { --background: hsl(215 20.2% 65.1%); @@ -311,6 +324,7 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); :root { --background: hsl(215 20.2% 65.1%); @@ -326,8 +340,75 @@ describe("transformCssVarsV4", () => { @theme inline { --color-background: var(--background); + --color-primary: var(--primary); --color-foreground: var(--foreground); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should only add hsl and color vars if color", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + :root { + --background: hsl(210 40% 98%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + } + + @theme inline { + --color-background: var(--background); + } + `, + { + light: { + background: "215 20.2% 65.1%", + foreground: "222.2 84% 4.9%", + primary: "215 20.2% 65.1%", + foo: "0.5rem", + }, + dark: { + foreground: "60 9.1% 97.8%", + primary: "222.2 84% 4.9%", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(215 20.2% 65.1%); + --foreground: hsl(222.2 84% 4.9%); + --primary: hsl(215 20.2% 65.1%); + --foo: 0.5rem; + } + + .dark { + --background: hsl(222.2 84% 4.9%); + --foreground: hsl(60 9.1% 97.8%); + --primary: hsl(222.2 84% 4.9%); + } + + @theme inline { + --color-background: var(--background); + --foo: var(--foo); --color-primary: var(--primary); + --color-foreground: var(--foreground); } @layer base { @@ -383,6 +464,7 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); :root { --background: hsl(215 20.2% 65.1%); @@ -398,8 +480,8 @@ describe("transformCssVarsV4", () => { @theme inline { --color-background: var(--background); - --color-foreground: var(--foreground); --color-primary: var(--primary); + --color-foreground: var(--foreground); } @layer base { @@ -434,19 +516,24 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { --background: hsl(0 0% 100%); --foreground: hsl(240 10% 3.9%); } + .dark { --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); } + @layer base { * { @apply border-border; @@ -479,19 +566,24 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { --background: hsl(0 0% 100%); --foreground: hsl(240 10% 3.9%); } + .dark { --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); } + @layer base { * { @apply border-border; @@ -524,19 +616,24 @@ describe("transformCssVarsV4", () => { ) ).toMatchInlineSnapshot(` "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { --background: rgb(255, 255, 255); --foreground: hsl(240 10% 3.9%); } + .dark { --background: hsl(240 10% 3.9%); --foreground: #000fff; } + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); } + @layer base { * { @apply border-border; @@ -548,4 +645,503 @@ describe("transformCssVarsV4", () => { " `) }) + + test("should add --radius-* if radius present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + { + light: { + radius: "0.125rem", + }, + dark: { + radius: "0.5rem", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + + :root { + --radius: 0.125rem; + } + + .dark { + --radius: 0.5rem; + } + + @theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should NOT add --radius-* if already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --radius: 0.125rem; + } + @theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + } + `, + { + light: { + radius: "0.125rem", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --radius: 0.125rem; + } + @theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should add plugin if not present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { plugins: ['require("tailwindcss-animate")'] }, + } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @plugin "tailwindcss-animate"; + + @custom-variant dark (&:is(.dark *)); + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should NOT add plugin if already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + @plugin "tailwindcss-animate"; + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { + plugins: [ + 'require("tailwindcss-animate")', + 'require("@tailwindcss/typography")', + ], + }, + } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + @plugin "tailwindcss-animate"; + + @plugin "@tailwindcss/typography"; + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should preserve quotes", async () => { + expect( + await transformCssVars( + `@import 'tailwindcss'; + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { + plugins: [ + 'require("tailwindcss-animate")', + 'require("@tailwindcss/typography")', + ], + }, + } + ) + ).toMatchInlineSnapshot(` + "@import 'tailwindcss'; + + @plugin '@tailwindcss/typography'; + + @plugin 'tailwindcss-animate'; + + @custom-variant dark (&:is(.dark *)); + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should add @keyframes if not present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { + theme: { + extend: { + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + }, + }, + }, + } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + + @theme inline { + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should NOT add @keyframes if already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + + @theme inline { + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + } + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { + theme: { + extend: { + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + }, + }, + }, + } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + + @theme inline { + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should add --animate if not present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { + theme: { + extend: { + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + }, + } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + + @theme inline { + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should NOT add --animate if already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + @theme inline { + --animate-accordion-up: accordion-up 0.3s ease-out; + } + `, + {}, + { tailwind: { cssVariables: true } }, + { + tailwindVersion: "v4", + tailwindConfig: { + theme: { + extend: { + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + }, + } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @custom-variant dark (&:is(.dark *)); + @theme inline { + --animate-accordion-up: accordion-up 0.3s ease-out; + --animate-accordion-down: accordion-down 0.2s ease-out; + + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordion-up { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) +}) + +describe("isLocalHSLValue", () => { + test.each([ + ["210 40% 98%", true], + ["rgb(210 40% 98%)", false], + ["oklch(210 40% 98%)", false], + ["10 42 98%", false], + ["hsl(210 40% 98% / 0.5)", false], + ])("%s -> %s", (value, expected) => { + expect(isLocalHSLValue(value)).toBe(expected) + }) })