diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b13ad86b..3c7025f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - _Experimental_: add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901)) +- Allow using `@variant` with stacked variants (e.g. `@variant hover:focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996)) +- Allow using `@variant` with compound variants (e.g. `@variant hover, focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996)) ### Fixed diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index bf1c72c42..b99476558 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { describe, expect, it, test } from 'vitest' import { compile, Features, Polyfills } from '.' +import { cartesian } from './cartesian' import type { PluginAPI } from './compat/plugin-api' import plugin from './plugin' import { compileCss, optimizeCss, run } from './test-utils/run' @@ -5459,6 +5460,427 @@ describe('@variant', () => { `) }) + describe('comma-separated `@variant` rules', () => { + it('should be possible to use comma-separated `@variant` rules', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover, focus { + background: red; + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover { + background: red; + } + } + + .btn:focus { + background: red; + }" + `) + + expect( + await compileCss(css` + .btn { + background: black; + + @variant hover, focus { + background: red; + } + } + @tailwind utilities; + `), + ).toEqual( + await compileCss(css` + .btn { + background: black; + + @variant hover { + background: red; + } + @variant focus { + background: red; + } + } + @tailwind utilities; + `), + ) + }) + + it.each( + Array.from( + cartesian( + ['', ' ', ' ', '\t', '\t\t'], // Before + ['', ' ', ' ', '\t', '\t\t'], // After + ), + ), + )( + "should handle optional whitespace ('%s', '%s') between `@variant` variants", + async (before, after) => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover${before},${after}focus { + background: red; + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover { + background: red; + } + } + + .btn:focus { + background: red; + }" + `) + }, + ) + + it('should handle variants containing a `,` inside', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant [&:is(:hover,:focus)], disabled { + background: red; + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + .btn:is(:hover, :focus), .btn:disabled { + background: red; + }" + `) + }) + + it('should handle nested comma-separated variants', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover, focus { + background: red; + + @variant active, disabled { + background: blue; + } + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover { + background: red; + } + + .btn:hover:active, .btn:hover:disabled { + background: #00f; + } + } + + .btn:focus { + background: red; + } + + .btn:focus:active, .btn:focus:disabled { + background: #00f; + }" + `) + + expect( + await compileCss(css` + .btn { + background: black; + + @variant hover, focus { + background: red; + + @variant active, disabled { + background: blue; + } + } + } + @tailwind utilities; + `), + ).toEqual( + await compileCss(css` + .btn { + background: black; + + @variant hover { + background: red; + + @variant active { + background: blue; + } + + @variant disabled { + background: blue; + } + } + + @variant focus { + background: red; + + @variant active { + background: blue; + } + + @variant disabled { + background: blue; + } + } + } + @tailwind utilities; + `), + ) + }) + + it('should error on invalid variants (trailing comma)', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover,focus, { + background: red; + } + } + @tailwind utilities; + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot use \`@variant\` with empty variant]`, + ) + }) + + it('should error on invalid variants (double comma)', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover,,focus { + background: red; + } + } + @tailwind utilities; + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot use \`@variant\` with empty variant]`, + ) + }) + }) + + describe('stacked `@variant` rules', () => { + it('should handle stacked variants', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover:focus { + background: red; + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover:focus { + background: red; + } + }" + `) + }) + + it('should handle stacked variants & comma-separated variants', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant hover:focus, disabled { + background: red; + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover:focus { + background: red; + } + } + + .btn:disabled { + background: red; + }" + `) + }) + + it('should handle variants containing a `:` inside', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant [&:is(:hover,:focus)]:disabled, aria-disabled:hover { + background: red; + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + .btn:is(:hover, :focus):disabled { + background: red; + } + + @media (hover: hover) { + .btn[aria-disabled="true"]:hover { + background: red; + } + }" + `) + }) + }) + + it('should be possible to use compound and stacked variants in `@variant`', async () => { + await expect( + compileCss(css` + .btn { + background: black; + + @variant data-a, data-b:data-c { + background: red; + + @variant data-d, data-e:data-f { + background: blue; + } + } + } + @tailwind utilities; + `), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + .btn[data-a] { + background: red; + } + + .btn[data-a][data-d], .btn[data-a][data-e][data-f] { + background: #00f; + } + + .btn[data-b][data-c] { + background: red; + } + + .btn[data-b][data-c][data-d], .btn[data-b][data-c][data-e][data-f] { + background: #00f; + }" + `) + + expect( + await compileCss(css` + .btn { + background: black; + + @variant data-a, data-b:data-c { + background: red; + + @variant data-d, data-e:data-f { + background: blue; + } + } + } + @tailwind utilities; + `), + ).toEqual( + await compileCss(css` + .btn { + background: black; + + @variant data-a { + background: red; + + @variant data-d { + background: blue; + } + + @variant data-e { + @variant data-f { + background: blue; + } + } + } + + @variant data-b { + @variant data-c { + background: red; + + @variant data-d { + background: blue; + } + + @variant data-e { + @variant data-f { + background: blue; + } + } + } + } + } + @tailwind utilities; + `), + ) + }) + it('should be possible to use `@variant` with a funky looking variants', async () => { await expect( compileCss( diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 02d8a434a..52ccc68ce 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -395,6 +395,31 @@ test('@apply generates source maps', async ({ expect }) => { ]) }) +test('@variant generates source maps', async ({ expect }) => { + let { sources, annotations } = await run({ + input: css` + .foo { + @variant hover { + color: red; + } + + @variant focus:disabled, hover:aria-expanded { + color: blue; + } + } + `, + }) + + expect(sources).toEqual(['input.css']) + + expect(annotations).toEqual([ + 'input.css: 1:0-5 <- 1:0-5', + 'input.css: 4:6-16 <- 3:4-14', + 'input.css: 9:6-17 <- 7:4-15', + 'input.css: 15:8-19 <- 7:4-15', + ]) +}) + test('license comments preserve source locations', async ({ expect }) => { let { sources, annotations } = await run({ input: `/*! some comment */`, diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 0b4bcb0c5..76ead2dc9 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1212,24 +1212,46 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): walk(ast, (variantNode) => { if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return - // Starting with the `&` rule node - let node = styleRule('&', variantNode.nodes) + let nodes: AstNode[] = [] + let compoundVariants = segment(variantNode.params, ',') + for (let [idx, compoundVariant] of compoundVariants.entries()) { + // Starting with the `&` rule node + // + // Only clone the nodes when we have multiple compound variants to deal + // with. The last one can use the original nodes. We do need unique AST + // nodes for sourcemap `dst` location information. + let node = styleRule( + '&', + idx === compoundVariants.length - 1 + ? variantNode.nodes + : variantNode.nodes.map(cloneAstNode), + ) - let variant = variantNode.params + let stackedVariants = segment(compoundVariant, ':') + for (let i = stackedVariants.length - 1; i >= 0; --i) { + let variant = stackedVariants[i].trim() - let variantAst = designSystem.parseVariant(variant) - if (variantAst === null) { - throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`) - } + if (!variant) { + throw new Error(`Cannot use \`@variant\` with empty variant`) + } - let result = applyVariant(node, variantAst, designSystem.variants) - if (result === null) { - throw new Error(`Cannot use \`@variant\` with variant: ${variant}`) + 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}`) + } + } + + nodes.push(node) } // Update the variant at-rule node, to be the `&` rule node features |= Features.Variants - return WalkAction.Replace(node) + return WalkAction.Replace(nodes) }) return features }