From 3ccb53db0d29e6942d94356eddf9ed22144c41be Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 17 Jan 2025 22:15:21 +0100 Subject: [PATCH] implement `@variant` --- packages/tailwindcss/src/index.test.ts | 186 +++++++++++++++++++++++++ packages/tailwindcss/src/index.ts | 66 ++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 60b7d921aafd..aeb700d65057 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -3315,3 +3315,189 @@ describe('`@import "…" reference`', () => { `) }) }) + +describe('@variant', () => { + it('should convert legacy body-less `@variant` as a `@custom-variant`', async () => { + await expect( + compileCss( + css` + @variant hocus (&:hover, &:focus); + @tailwind utilities; + `, + ['hocus:underline'], + ), + ).resolves.toMatchInlineSnapshot(` + ".hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + }" + `) + }) + + it('should convert legacy `@variant` with `@slot` as a `@custom-variant`', async () => { + await expect( + compileCss( + css` + @variant hocus { + &:hover { + @slot; + } + + &:focus { + @slot; + } + } + @tailwind utilities; + `, + ['hocus:underline'], + ), + ).resolves.toMatchInlineSnapshot(` + ".hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + }" + `) + }) + + it('should be possible to use `@variant` in your CSS', async () => { + await expect( + compileCss( + css` + .btn { + background: black; + + @variant dark { + background: white; + } + } + `, + [], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (prefers-color-scheme: dark) { + .btn { + background: #fff; + } + }" + `) + }) + + it('should be possible to use `@variant` in your CSS with a `@custom-variant` that is defined later', async () => { + await expect( + compileCss( + css` + .btn { + background: black; + + @variant hocus { + background: white; + } + } + + @custom-variant hocus (&:hover, &:focus); + `, + [], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + .btn:hover, .btn:focus { + background: #fff; + }" + `) + }) + + it('should be possible to use nested `@variant` rules', async () => { + await expect( + compileCss( + css` + .btn { + background: black; + + @variant disabled { + @variant focus { + background: white; + } + } + } + @tailwind utilities; + `, + ['disabled:focus:underline'], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + .btn:disabled:focus { + background: #fff; + } + + .disabled\\:focus\\:underline:disabled:focus { + text-decoration-line: underline; + }" + `) + }) + + it('should be possible to use multiple `@variant` params at once', async () => { + await expect( + compileCss( + css` + .btn { + background: black; + + @variant hover:focus { + background: white; + } + } + `, + [], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover:focus { + background: #fff; + } + }" + `) + }) + + it('should be possible to use `@variant` with a funky looking variants', async () => { + await expect( + compileCss( + css` + @theme inline reference { + --container-md: 768px; + } + + .btn { + background: black; + + @variant @md:[&.foo] { + background: white; + } + } + `, + [], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @container (width >= 768px) { + .btn.foo { + background: #fff; + } + }" + `) + }) +}) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 4266a2faccac..5bc0c72015d3 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -21,7 +21,7 @@ import { substituteAtImports } from './at-import' import { applyCompatibilityHooks } from './compat/apply-compat-hooks' import type { UserConfig } from './compat/config/types' import { type Plugin } from './compat/plugin-api' -import { compileCandidates } from './compile' +import { applyVariant, compileCandidates } from './compile' import { substituteFunctions } from './css-functions' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' @@ -97,6 +97,9 @@ export const enum Features { // `@tailwind utilities` was used Utilities = 1 << 4, + + // `@variant` was used + Variants = 1 << 5, } async function parseCss( @@ -118,6 +121,7 @@ async function parseCss( let customUtilities: ((designSystem: DesignSystem) => void)[] = [] let firstThemeRule = null as StyleRule | null let utilitiesNode = null as AtRule | null + let variantNodes: AtRule[] = [] let globs: { base: string; pattern: string }[] = [] let root = null as Root @@ -213,6 +217,42 @@ async function parseCss( return } + // Apply `@variant` at-rules + if (node.name === '@variant') { + // Legacy `@variant` at-rules containing `@slot` or without a body should + // be considered a `@custom-variant` at-rule. + if (parent === null) { + // Body-less `@variant`, e.g.: `@variant foo (…);` + if (node.nodes.length === 0) { + node.name = '@custom-variant' + } + + // Using `@slot`: + // + // ```css + // @variant foo { + // &:hover { + // @slot; + // } + // } + // ``` + else { + walk(node.nodes, (child) => { + if (child.kind === 'at-rule' && child.name === '@slot') { + node.name = '@custom-variant' + return WalkAction.Stop + } + }) + } + } + + // Collect all the `@variant` at-rules, we will replace them later once + // all variants are registered in the system. + else { + variantNodes.push(node) + } + } + // Register custom variants from `@custom-variant` at-rules if (node.name === '@custom-variant') { if (parent !== null) { @@ -501,6 +541,30 @@ async function parseCss( node.context = {} } + // Replace the `@variant` at-rules with the actual variant rules. + if (variantNodes.length > 0) { + for (let variantNode of variantNodes) { + // Starting with the `&` rule node + let node = styleRule('&', variantNode.nodes) + + for (let variant of segment(variantNode.params, ':').reverse()) { + let variantAst = designSystem.parseVariant(variant) + if (variantAst === null) { + throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`) + } + + let result = applyVariant(node, variantAst, designSystem.variants) + if (result === null) { + throw new Error(`Cannot use \`@variant\` with variant: ${variant}`) + } + } + + // Update the variant at-rule node, to be the `&` rule node + Object.assign(variantNode, node) + } + features |= Features.Variants + } + features |= substituteFunctions(ast, designSystem) features |= substituteAtApply(ast, designSystem)