diff --git a/packages/shadcn/src/utils/updaters/update-css-vars.ts b/packages/shadcn/src/utils/updaters/update-css-vars.ts index 2c773609616..4e4b8bfb9e7 100644 --- a/packages/shadcn/src/utils/updaters/update-css-vars.ts +++ b/packages/shadcn/src/utils/updaters/update-css-vars.ts @@ -87,6 +87,7 @@ export async function transformCssVars( if (options.tailwindConfig) { plugins.push(updateTailwindConfigPlugin(options.tailwindConfig)) + plugins.push(updateTailwindConfigAnimationPlugin(options.tailwindConfig)) plugins.push(updateTailwindConfigKeyframesPlugin(options.tailwindConfig)) } } @@ -513,7 +514,7 @@ function updateTailwindConfigKeyframesPlugin( } const themeNode = upsertThemeNode(root) - const keyframesNode = themeNode.nodes?.find( + const existingKeyFrameNodes = themeNode.nodes?.filter( (node): node is AtRule => node.type === "atrule" && node.name === "keyframes" ) @@ -537,11 +538,14 @@ function updateTailwindConfigKeyframesPlugin( } if ( - keyframesNode?.nodes?.find( - (node): node is postcss.Declaration => - node.type === "decl" && node.prop === keyframeName + existingKeyFrameNodes?.find( + (node): node is postcss.AtRule => + node.type === "atrule" && + node.name === "keyframes" && + node.params === keyframeName ) ) { + console.log("Keyframe already present", keyframeName) continue } @@ -573,6 +577,50 @@ function updateTailwindConfigKeyframesPlugin( } } +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("--animation-") + ) + + 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 = `--animation-${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() 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 26a51eee78b..82e6b1795f9 100644 --- a/packages/shadcn/test/utils/updaters/update-css-vars.test.ts +++ b/packages/shadcn/test/utils/updaters/update-css-vars.test.ts @@ -668,10 +668,6 @@ describe("transformCssVarsV4", () => { to: { height: "0" }, }, }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, }, }, }, @@ -709,4 +705,212 @@ describe("transformCssVarsV4", () => { " `) }) + + 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 --animation 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 { + --animation-accordion-down: accordion-down 0.2s ease-out; + --animation-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 --animation if already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + @theme inline { + --animation-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 { + --animation-accordion-up: accordion-up 0.3s ease-out; + --animation-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; + } + } + " + `) + }) })