From 0ee38a8ae650b1e20f0dd0b560fbcda959492871 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 11 Jul 2024 17:30:33 +0200 Subject: [PATCH 01/25] implement `@variant` in CSS --- packages/tailwindcss/src/design-system.ts | 18 +- packages/tailwindcss/src/index.test.ts | 393 ++++++++++++++++++++++ packages/tailwindcss/src/index.ts | 98 +++++- 3 files changed, 491 insertions(+), 18 deletions(-) diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 494b7bd53c15..bebd82a33c23 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -1,4 +1,4 @@ -import { rule, toCss } from './ast' +import { toCss } from './ast' import { parseCandidate, parseVariant } from './candidate' import { compileAstNodes, compileCandidates } from './compile' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' @@ -8,10 +8,6 @@ import { Utilities, createUtilities } from './utilities' import { DefaultMap } from './utils/default-map' import { Variants, createVariants } from './variants' -export type Plugin = (api: { - addVariant: (name: string, selector: string | string[]) => void -}) => void - export type DesignSystem = { theme: Theme utilities: Utilities @@ -29,7 +25,7 @@ export type DesignSystem = { getUsedVariants(): ReturnType[] } -export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignSystem { +export function buildDesignSystem(theme: Theme): DesignSystem { let utilities = createUtilities(theme) let variants = createVariants(theme) @@ -81,15 +77,5 @@ export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignS }, } - for (let plugin of plugins) { - plugin({ - addVariant: (name: string, selectors: string | string[]) => { - variants.static(name, (r) => { - r.nodes = ([] as string[]).concat(selectors).map((selector) => rule(selector, r.nodes)) - }) - }, - }) - } - return designSystem } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 0b8d184832df..247dcb64fdab 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1182,3 +1182,396 @@ describe('plugins', () => { `) }) }) + +describe('custom variants via CSS `@variant` at-rules', () => { + describe('simple one-liner based', () => { + test('selector', () => { + let compiled = compile(css` + @variant hocus (&:hover, &:focus); + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('media query', () => { + let compiled = compile(css` + @variant any-hover (@media (any-hover: hover)); + + @layer utilities { + @tailwind utilities; + } + `).build(['any-hover:hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .any-hover\\:hover\\:underline:hover { + text-decoration-line: underline; + } + } + }" + `) + }) + }) + + describe('body block based', () => { + test('selector and @slot', () => { + let compiled = compile(css` + @variant custom-hover { + &:hover { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-hover:underline', 'group-custom-hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-custom-hover\\:underline:is(:where(.group):hover *) { + text-decoration-line: underline; + } + + .custom-hover\\:underline:hover { + text-decoration-line: underline; + } + }" + `) + }) + + test('parallel selector and single @slot', () => { + let compiled = compile(css` + @variant hocus { + &:hover, + &:focus { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:underline:is(:where(.group):hover, .group-hocus\\:underline:focus *) { + text-decoration-line: underline; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('parallel selector and multiple @slot', () => { + let compiled = compile(css` + @variant hocus { + &:hover { + @slot; + } + + &:focus { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:underline:is(:where(.group):hover *), .group-hocus\\:underline:is(:where(.group):focus *) { + text-decoration-line: underline; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('selector, nesting and @slot', () => { + let compiled = compile(css` + @variant custom-hover { + &.custom-hover { + &:hover { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-hover:underline', 'group-custom-hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-custom-hover\\:underline:is(:where(.group).custom-hover *):hover { + text-decoration-line: underline; + } + + .custom-hover\\:underline.custom-hover:hover { + text-decoration-line: underline; + } + }" + `) + }) + + test('parallel selector, nesting and single @slot', () => { + let compiled = compile(css` + @variant hocus { + &.hocus { + &:hover, + &:focus { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:underline:is(:where(.group).hocus *):hover, .group-hocus\\:underline:is(:where(.group).hocus *):focus { + text-decoration-line: underline; + } + + .hocus\\:underline.hocus:hover, .hocus\\:underline.hocus:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('parallel selector, nesting and multiple @slot', () => { + let compiled = compile(css` + @variant hocus { + .hocus { + &:hover { + @slot; + } + + &:focus { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:underline:is(.hocus *):hover, .group-hocus\\:underline:is(.hocus *):focus { + text-decoration-line: underline; + } + + .hocus\\:underline .hocus:hover, .hocus\\:underline .hocus:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('media and @slot', () => { + let compiled = compile(css` + @variant custom-hover { + @media (any-hover: hover) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .custom-hover\\:underline { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('parallel media and single @slot', () => { + let compiled = compile(css` + @variant print-desktop { + @media screen, print { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['print-desktop:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media screen, print { + .print-desktop\\:underline { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('parallel media and multiple @slot', () => { + let compiled = compile(css` + @variant desktop { + @media (any-hover: hover) { + @slot; + } + + @media (pointer: fine) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['desktop:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .desktop\\:underline { + text-decoration-line: underline; + } + } + + @media (pointer: fine) { + .desktop\\:underline { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('media, nesting and @slot', () => { + let compiled = compile(css` + @variant custom-hover { + @media (any-hover: hover) { + &:hover { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .custom-hover\\:underline:hover { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('parallel media, nesting and single @slot', () => { + let compiled = compile(css` + @variant desktop { + @media screen, print { + &:hover, + &:focus { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['desktop:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media screen, print { + .desktop\\:underline:hover, .desktop\\:underline:focus { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('parallel media, nesting and multiple @slot', () => { + let compiled = compile(css` + @variant custom-variant { + @media print { + @media screen { + @slot; + } + + @media all { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-variant:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media print { + @media screen { + .custom-variant\\:underline { + text-decoration-line: underline; + } + } + + @media all { + .custom-variant\\:underline { + text-decoration-line: underline; + } + } + } + }" + `) + }) + }) +}) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index e15d9926bb4f..b2c921924670 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -2,8 +2,12 @@ import { version } from '../package.json' import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast' import { compileCandidates } from './compile' import * as CSS from './css-parser' -import { buildDesignSystem, type Plugin } from './design-system' +import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme } from './theme' +import { segment } from './utils/segment' + +type PluginAPI = { addVariant: (name: string, selector: string | string[]) => void } +type Plugin = (api: PluginAPI) => void type CompileOptions = { loadPlugin?: (path: string) => Plugin @@ -34,6 +38,7 @@ export function compile( // Find all `@theme` declarations let theme = new Theme() let plugins: Plugin[] = [] + let customVariants: ((designSystem: DesignSystem) => void)[] = [] let firstThemeRule: Rule | null = null let keyframesRules: Rule[] = [] @@ -47,6 +52,79 @@ export function compile( return } + // Register custom variants from `@variant` at-rules + if (node.selector.startsWith('@variant ')) { + // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` + if (node.nodes.length === 0) { + let [name, selector] = segment(node.selector.slice(9), ' ') + + // Remove variants without selector and without a body, e.g.: `@variant foo {}` + if (!selector) { + replaceWith([]) + return + } + + let selectors = segment(selector.slice(1, -1), ',') + customVariants.push((designSystem) => { + designSystem.variants.static(name, (r) => { + r.nodes = selectors.map((selector) => rule(selector, r.nodes)) + }) + }) + replaceWith([]) + return + } + + // Variants without a selector, but with a body: + // + // E.g.: + // + // ```css + // @variant hocus { + // &:hover { + // @slot; + // } + // + // &:focus { + // @slot; + // } + // } + // ``` + else { + let name = node.selector.slice(9).trim() + + customVariants.push((designSystem) => { + designSystem.variants.static(name, (r) => { + let body = structuredClone(node.nodes) + + walk(body, (node, { replaceWith }) => { + // Inject existing nodes in `@slot` + if (node.kind === 'rule' && node.selector === '@slot') { + replaceWith(r.nodes) + return + } + + // Wrap `@keyframes` and `@property` in `@at-root` + else if ( + node.kind === 'rule' && + node.selector[0] === '@' && + (node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property ')) + ) { + Object.assign(node, { + selector: '@at-root', + nodes: [rule(node.selector, node.nodes)], + }) + return WalkAction.Skip + } + }) + + r.nodes = body + }) + }) + replaceWith([]) + return + } + } + // Drop instances of `@media reference` // // We support `@import "tailwindcss/theme" reference` as a way to import an external theme file @@ -144,7 +222,23 @@ export function compile( firstThemeRule.nodes = nodes } - let designSystem = buildDesignSystem(theme, plugins) + let designSystem = buildDesignSystem(theme) + + for (let customVariant of customVariants) { + customVariant(designSystem) + } + + let api: PluginAPI = { + addVariant(name, selector) { + designSystem.variants.static(name, (r) => { + r.nodes = ([] as string[]).concat(selector).map((selector) => rule(selector, r.nodes)) + }) + }, + } + + for (let plugin of plugins) { + plugin(api) + } let tailwindUtilitiesNode: Rule | null = null From 73d689d8985cd5cf60f28e065d58db6c41d49ef7 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 11 Jul 2024 17:56:24 +0200 Subject: [PATCH 02/25] implement `addVariant(name, objectTree)` --- packages/tailwindcss/src/ast.ts | 20 +++++++ packages/tailwindcss/src/index.test.ts | 82 ++++++++++++++++++++++++++ packages/tailwindcss/src/index.ts | 69 ++++++++++++---------- packages/tailwindcss/src/variants.ts | 31 +++++++++- 4 files changed, 169 insertions(+), 33 deletions(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index bfdd63b6981e..75e838579307 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -42,6 +42,26 @@ export function comment(value: string): Comment { } } +export interface CssTree extends Record {} + +export function objectToAst(obj: CssTree): AstNode[] { + let ast: AstNode[] = [] + + for (let [name, value] of Object.entries(obj)) { + if (typeof value === 'string') { + if (value === '@slot') { + ast.push(rule(name, [rule('@slot', [])])) + } else { + ast.push(decl(name, value)) + } + } else { + ast.push(rule(name, objectToAst(value))) + } + } + + return ast +} + export enum WalkAction { /** Continue walking, which is the default */ Continue, diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 247dcb64fdab..a835429a7dbb 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1181,6 +1181,88 @@ describe('plugins', () => { }" `) }) + + test('addVariant with object syntax and @slot', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: () => { + return ({ addVariant }) => { + addVariant('hocus', { + '&:hover': '@slot', + '&:focus': '@slot', + }) + } + }, + }, + ).build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with object syntax, media, nesting and multiple @slot', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: () => { + return ({ addVariant }) => { + addVariant('hocus', { + '.hocus': { + '@media (hover: hover)': { + '&:hover': '@slot', + }, + '&:focus': '@slot', + }, + }) + } + }, + }, + ).build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .group-hocus\\:flex:is(.hocus *):hover { + display: flex; + } + } + + .group-hocus\\:flex:is(.hocus *):focus { + display: flex; + } + + @media (hover: hover) { + .hocus\\:underline .hocus:hover { + text-decoration-line: underline; + } + } + + .hocus\\:underline .hocus:focus { + text-decoration-line: underline; + } + }" + `) + }) }) describe('custom variants via CSS `@variant` at-rules', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index b2c921924670..c03048157a3e 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -1,12 +1,27 @@ import { version } from '../package.json' -import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast' +import { + WalkAction, + comment, + decl, + objectToAst, + rule, + toCss, + walk, + type AstNode, + type CssTree, + type Rule, +} from './ast' import { compileCandidates } from './compile' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme } from './theme' import { segment } from './utils/segment' -type PluginAPI = { addVariant: (name: string, selector: string | string[]) => void } +type PluginAPI = { + addVariant(name: string, selector: string): void + addVariant(name: string, selector: string[]): void + addVariant(name: string, tree: CssTree): void +} type Plugin = (api: PluginAPI) => void type CompileOptions = { @@ -93,32 +108,7 @@ export function compile( let name = node.selector.slice(9).trim() customVariants.push((designSystem) => { - designSystem.variants.static(name, (r) => { - let body = structuredClone(node.nodes) - - walk(body, (node, { replaceWith }) => { - // Inject existing nodes in `@slot` - if (node.kind === 'rule' && node.selector === '@slot') { - replaceWith(r.nodes) - return - } - - // Wrap `@keyframes` and `@property` in `@at-root` - else if ( - node.kind === 'rule' && - node.selector[0] === '@' && - (node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property ')) - ) { - Object.assign(node, { - selector: '@at-root', - nodes: [rule(node.selector, node.nodes)], - }) - return WalkAction.Skip - } - }) - - r.nodes = body - }) + designSystem.variants.fromAst(name, node.nodes) }) replaceWith([]) return @@ -229,10 +219,25 @@ export function compile( } let api: PluginAPI = { - addVariant(name, selector) { - designSystem.variants.static(name, (r) => { - r.nodes = ([] as string[]).concat(selector).map((selector) => rule(selector, r.nodes)) - }) + addVariant(name, variant) { + // Single selector + if (typeof variant === 'string') { + designSystem.variants.static(name, (r) => { + r.nodes = [rule(variant, r.nodes)] + }) + } + + // Multiple parallel selectors + else if (Array.isArray(variant)) { + designSystem.variants.static(name, (r) => { + r.nodes = variant.map((selector) => rule(selector, r.nodes)) + }) + } + + // CSS Tree + else if (typeof variant === 'object') { + designSystem.variants.fromAst(name, objectToAst(variant)) + } }, } diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index a95315075af9..5206cf4f06df 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1,4 +1,4 @@ -import { decl, rule, type Rule } from './ast' +import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast' import { type Variant } from './candidate' import type { Theme } from './theme' import { DefaultMap } from './utils/default-map' @@ -41,6 +41,35 @@ export class Variants { this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true }) } + fromAst(name: string, ast: AstNode[]) { + this.static(name, (r) => { + let body = structuredClone(ast) + + walk(body, (node, { replaceWith }) => { + // Inject existing nodes in `@slot` + if (node.kind === 'rule' && node.selector === '@slot') { + replaceWith(r.nodes) + return + } + + // Wrap `@keyframes` and `@property` in `@at-root` + else if ( + node.kind === 'rule' && + node.selector[0] === '@' && + (node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property ')) + ) { + Object.assign(node, { + selector: '@at-root', + nodes: [rule(node.selector, node.nodes)], + }) + return WalkAction.Skip + } + }) + + r.nodes = body + }) + } + functional( name: string, applyFn: VariantFn<'functional'>, From d0d6fd4449ac8df4053ffb1807adb7c532272862 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 11 Jul 2024 18:11:28 +0200 Subject: [PATCH 03/25] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98ee385c44f..6510ffc3d473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982)) +- Add support for custom variants via CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992)) ## [4.0.0-alpha.17] - 2024-07-04 From 0bc2bbf206aef93827fc46356f6b40702815c13d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 11 Jul 2024 18:23:23 +0200 Subject: [PATCH 04/25] ensure that `@variant` can only be used top-level --- packages/tailwindcss/src/ast.ts | 5 ++++- packages/tailwindcss/src/index.test.ts | 10 ++++++++++ packages/tailwindcss/src/index.ts | 6 +++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 75e838579307..2dc4a2b7be29 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -78,14 +78,17 @@ export function walk( visit: ( node: AstNode, utils: { + parent: AstNode | null replaceWith(newNode: AstNode | AstNode[]): void }, ) => void | WalkAction, + parent: AstNode | null = null, ) { for (let i = 0; i < ast.length; i++) { let node = ast[i] let status = visit(node, { + parent, replaceWith(newNode) { ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode])) // We want to visit the newly replaced node(s), which start at the @@ -102,7 +105,7 @@ export function walk( if (status === WalkAction.Skip) continue if (node.kind === 'rule') { - walk(node.nodes, visit) + walk(node.nodes, visit, node) } } } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index a835429a7dbb..61bc67787a2b 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1266,6 +1266,16 @@ describe('plugins', () => { }) describe('custom variants via CSS `@variant` at-rules', () => { + test('@variant should be top-level and can not be nested', () => { + expect(() => + compileCss(css` + .foo { + @variant hocus (&:hover, &:focus); + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` can not be nested.]`) + }) + describe('simple one-liner based', () => { test('selector', () => { let compiled = compile(css` diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index c03048157a3e..e3e868ecd763 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -57,7 +57,7 @@ export function compile( let firstThemeRule: Rule | null = null let keyframesRules: Rule[] = [] - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, { parent, replaceWith }) => { if (node.kind !== 'rule') return // Collect paths from `@plugin` at-rules @@ -69,6 +69,10 @@ export function compile( // Register custom variants from `@variant` at-rules if (node.selector.startsWith('@variant ')) { + if (parent !== null) { + throw new Error('`@variant` can not be nested.') + } + // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` if (node.nodes.length === 0) { let [name, selector] = segment(node.selector.slice(9), ' ') From e17d03756c95c605fb6db4929b5d5c6a0e871f72 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 11 Jul 2024 18:25:03 +0200 Subject: [PATCH 05/25] simplify Plugin API type --- packages/tailwindcss/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index e3e868ecd763..1415c170589d 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -18,9 +18,7 @@ import { Theme } from './theme' import { segment } from './utils/segment' type PluginAPI = { - addVariant(name: string, selector: string): void - addVariant(name: string, selector: string[]): void - addVariant(name: string, tree: CssTree): void + addVariant(name: string, variant: string | string[] | CssTree): void } type Plugin = (api: PluginAPI) => void From 962cb7dc4bbc4483a7a313ee7bec953934440242 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:10:18 -0400 Subject: [PATCH 06/25] Use type instead of interface (for now) --- packages/tailwindcss/src/ast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 2dc4a2b7be29..670b4f4f4c5f 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -42,7 +42,7 @@ export function comment(value: string): Comment { } } -export interface CssTree extends Record {} +export type CssTree = { [key: string]: string | CssTree } export function objectToAst(obj: CssTree): AstNode[] { let ast: AstNode[] = [] From df44b71e0ffb0e1f108fa275ae2d50165677035a Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:12:07 -0400 Subject: [PATCH 07/25] Use more realistic variant for test --- packages/tailwindcss/src/index.test.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 61bc67787a2b..5787114b840f 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1227,12 +1227,10 @@ describe('plugins', () => { loadPlugin: () => { return ({ addVariant }) => { addVariant('hocus', { - '.hocus': { - '@media (hover: hover)': { - '&:hover': '@slot', - }, - '&:focus': '@slot', + '@media (hover: hover)': { + '&:hover': '@slot', }, + '&:focus': '@slot', }) } }, @@ -1242,22 +1240,22 @@ describe('plugins', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { @media (hover: hover) { - .group-hocus\\:flex:is(.hocus *):hover { + .group-hocus\\:flex:is(:where(.group):hover *) { display: flex; } } - .group-hocus\\:flex:is(.hocus *):focus { + .group-hocus\\:flex:is(:where(.group):focus *) { display: flex; } @media (hover: hover) { - .hocus\\:underline .hocus:hover { + .hocus\\:underline:hover { text-decoration-line: underline; } } - .hocus\\:underline .hocus:focus { + .hocus\\:underline:focus { text-decoration-line: underline; } }" From 743c05076f113a5fddb039714b9f8ec872432745 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:12:31 -0400 Subject: [PATCH 08/25] Allow custom properties to use `@slot` as content --- packages/tailwindcss/src/ast.ts | 2 +- packages/tailwindcss/src/index.test.ts | 36 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 670b4f4f4c5f..add2096268e6 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -49,7 +49,7 @@ export function objectToAst(obj: CssTree): AstNode[] { for (let [name, value] of Object.entries(obj)) { if (typeof value === 'string') { - if (value === '@slot') { + if (!name.startsWith('--') && value === '@slot') { ast.push(rule(name, [rule('@slot', [])])) } else { ast.push(decl(name, value)) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 5787114b840f..1d3ea2f3f055 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1261,6 +1261,42 @@ describe('plugins', () => { }" `) }) + + test('@slot is preserved when used as a custom property value', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: () => { + return ({ addVariant }) => { + addVariant('hocus', { + '&': { + '--custom-property': '@slot', + '&:hover': '@slot', + '&:focus': '@slot', + }, + }) + } + }, + }, + ).build(['hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .hocus\\:underline { + --custom-property: @slot; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) }) describe('custom variants via CSS `@variant` at-rules', () => { From f4065f1d235429b6054dd26f27a82b325da4a6fe Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:13:50 -0400 Subject: [PATCH 09/25] Use "cannot" instead of "can not" --- packages/tailwindcss/src/index.test.ts | 4 ++-- packages/tailwindcss/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 1d3ea2f3f055..6dec8dd5cfad 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1300,14 +1300,14 @@ describe('plugins', () => { }) describe('custom variants via CSS `@variant` at-rules', () => { - test('@variant should be top-level and can not be nested', () => { + test('@variant should be top-level and cannot be nested', () => { expect(() => compileCss(css` .foo { @variant hocus (&:hover, &:focus); } `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` can not be nested.]`) + ).toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`) }) describe('simple one-liner based', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 1415c170589d..34adcf0e48b6 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -68,7 +68,7 @@ export function compile( // Register custom variants from `@variant` at-rules if (node.selector.startsWith('@variant ')) { if (parent !== null) { - throw new Error('`@variant` can not be nested.') + throw new Error('`@variant` cannot be nested.') } // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` From 884e01b81f929fab5c8e1f5beed30bd42518dfd6 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:21:00 -0400 Subject: [PATCH 10/25] Remove `@variant` right away --- packages/tailwindcss/src/index.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 34adcf0e48b6..1a7d77c7a017 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -71,23 +71,20 @@ export function compile( throw new Error('`@variant` cannot be nested.') } + // Remove `@variant` at-rule so it's not included in the compiled CSS + replaceWith([]) + // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` if (node.nodes.length === 0) { let [name, selector] = segment(node.selector.slice(9), ' ') - - // Remove variants without selector and without a body, e.g.: `@variant foo {}` - if (!selector) { - replaceWith([]) - return - } - let selectors = segment(selector.slice(1, -1), ',') + customVariants.push((designSystem) => { designSystem.variants.static(name, (r) => { r.nodes = selectors.map((selector) => rule(selector, r.nodes)) }) }) - replaceWith([]) + return } @@ -112,7 +109,7 @@ export function compile( customVariants.push((designSystem) => { designSystem.variants.fromAst(name, node.nodes) }) - replaceWith([]) + return } } From fee34b0cb4bd576c313868cdfbbe77d3bd90c048 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:24:58 -0400 Subject: [PATCH 11/25] Throw when `@variant` is missing a selector or body --- packages/tailwindcss/src/index.test.ts | 17 +++++++++++++++++ packages/tailwindcss/src/index.ts | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 6dec8dd5cfad..4ef7b5648557 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1310,6 +1310,23 @@ describe('custom variants via CSS `@variant` at-rules', () => { ).toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`) }) + test('@variant with no body must include a selector', () => { + expect(() => + compileCss(css` + @variant hocus; + `), + ).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]') + }) + + test('@variant with selector must include a body', () => { + expect(() => + compileCss(css` + @variant hocus { + } + `), + ).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]') + }) + describe('simple one-liner based', () => { test('selector', () => { let compiled = compile(css` diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 1a7d77c7a017..8fa7a707e32a 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -77,6 +77,11 @@ export function compile( // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` if (node.nodes.length === 0) { let [name, selector] = segment(node.selector.slice(9), ' ') + + if (!selector) { + throw new Error(`\`@variant ${name}\` has no selector or body.`) + } + let selectors = segment(selector.slice(1, -1), ',') customVariants.push((designSystem) => { From ed77cc94477cf49fe81a2346a566b300bdba234a Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:28:13 -0400 Subject: [PATCH 12/25] Use "CSS-in-JS" terminology instead of "CSS Tree" --- packages/tailwindcss/src/ast.ts | 4 ++-- packages/tailwindcss/src/index.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index add2096268e6..66b37daf01e7 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -42,9 +42,9 @@ export function comment(value: string): Comment { } } -export type CssTree = { [key: string]: string | CssTree } +export type CssInJs = { [key: string]: string | CssInJs } -export function objectToAst(obj: CssTree): AstNode[] { +export function objectToAst(obj: CssInJs): AstNode[] { let ast: AstNode[] = [] for (let [name, value] of Object.entries(obj)) { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 8fa7a707e32a..966c694eafbe 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -8,7 +8,7 @@ import { toCss, walk, type AstNode, - type CssTree, + type CssInJs, type Rule, } from './ast' import { compileCandidates } from './compile' @@ -18,7 +18,7 @@ import { Theme } from './theme' import { segment } from './utils/segment' type PluginAPI = { - addVariant(name: string, variant: string | string[] | CssTree): void + addVariant(name: string, variant: string | string[] | CssInJs): void } type Plugin = (api: PluginAPI) => void @@ -238,7 +238,7 @@ export function compile( }) } - // CSS Tree + // CSS-in-JS object else if (typeof variant === 'object') { designSystem.variants.fromAst(name, objectToAst(variant)) } From c1222f863cfa7baa22af1d97a61bcfb6e91df80b Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:39:06 -0400 Subject: [PATCH 13/25] Rename tests --- packages/tailwindcss/src/index.test.ts | 87 ++++++++++++++------------ 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 4ef7b5648557..b2ef8feb387b 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1299,8 +1299,8 @@ describe('plugins', () => { }) }) -describe('custom variants via CSS `@variant` at-rules', () => { - test('@variant should be top-level and cannot be nested', () => { +describe('@variant', () => { + test('@variant must be top-level and cannot be nested', () => { expect(() => compileCss(css` .foo { @@ -1327,8 +1327,8 @@ describe('custom variants via CSS `@variant` at-rules', () => { ).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]') }) - describe('simple one-liner based', () => { - test('selector', () => { + describe('body-less syntax', () => { + test('selector variant', () => { let compiled = compile(css` @variant hocus (&:hover, &:focus); @@ -1350,7 +1350,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('media query', () => { + test('at-rule variant', () => { let compiled = compile(css` @variant any-hover (@media (any-hover: hover)); @@ -1371,8 +1371,8 @@ describe('custom variants via CSS `@variant` at-rules', () => { }) }) - describe('body block based', () => { - test('selector and @slot', () => { + describe('body with @slot syntax', () => { + test('selector with @slot', () => { let compiled = compile(css` @variant custom-hover { &:hover { @@ -1398,7 +1398,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('parallel selector and single @slot', () => { + test('grouped selectors with @slot', () => { let compiled = compile(css` @variant hocus { &:hover, @@ -1425,7 +1425,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('parallel selector and multiple @slot', () => { + test('multiple selectors with @slot', () => { let compiled = compile(css` @variant hocus { &:hover { @@ -1455,7 +1455,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('selector, nesting and @slot', () => { + test('nested selectors with @slot', () => { let compiled = compile(css` @variant custom-hover { &.custom-hover { @@ -1483,7 +1483,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('parallel selector, nesting and single @slot', () => { + test('grouped nested selectors with @slot', () => { let compiled = compile(css` @variant hocus { &.hocus { @@ -1544,7 +1544,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('media and @slot', () => { + test('at-rule with @slot', () => { let compiled = compile(css` @variant custom-hover { @media (any-hover: hover) { @@ -1568,31 +1568,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('parallel media and single @slot', () => { - let compiled = compile(css` - @variant print-desktop { - @media screen, print { - @slot; - } - } - - @layer utilities { - @tailwind utilities; - } - `).build(['print-desktop:underline']) - - expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` - "@layer utilities { - @media screen, print { - .print-desktop\\:underline { - text-decoration-line: underline; - } - } - }" - `) - }) - - test('parallel media and multiple @slot', () => { + test('multiple at-rules with @slot', () => { let compiled = compile(css` @variant desktop { @media (any-hover: hover) { @@ -1626,7 +1602,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('media, nesting and @slot', () => { + test('at-rule with nesting with @slot', () => { let compiled = compile(css` @variant custom-hover { @media (any-hover: hover) { @@ -1652,7 +1628,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('parallel media, nesting and single @slot', () => { + test('at-rule with nested grouped selectors with @slot', () => { let compiled = compile(css` @variant desktop { @media screen, print { @@ -1679,7 +1655,7 @@ describe('custom variants via CSS `@variant` at-rules', () => { `) }) - test('parallel media, nesting and multiple @slot', () => { + test('nested at-rules with @slot', () => { let compiled = compile(css` @variant custom-variant { @media print { @@ -1716,5 +1692,36 @@ describe('custom variants via CSS `@variant` at-rules', () => { }" `) }) + + test('at-rule and selector with @slot', () => { + let compiled = compile(css` + @variant custom-dark { + @media (prefers-color-scheme: dark) { + @slot; + } + &:is(.dark *) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-dark:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (prefers-color-scheme: dark) { + .custom-dark\\:underline { + text-decoration-line: underline; + } + } + + .custom-dark\\:underline:is(.dark *) { + text-decoration-line: underline; + } + }" + `) + }) }) }) From 72b334bdef7eeafad46ccb061c02857ccc9def4c Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:39:16 -0400 Subject: [PATCH 14/25] Mark some tests that seem wrong --- packages/tailwindcss/src/index.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index b2ef8feb387b..97dc51d85e01 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1215,7 +1215,7 @@ describe('plugins', () => { `) }) - test('addVariant with object syntax, media, nesting and multiple @slot', () => { + test.skip('addVariant with object syntax, media, nesting and multiple @slot', () => { let compiled = compile( css` @plugin "my-plugin"; @@ -1512,7 +1512,7 @@ describe('@variant', () => { `) }) - test('parallel selector, nesting and multiple @slot', () => { + test.skip('nested multiple selectors with @slot', () => { let compiled = compile(css` @variant hocus { .hocus { @@ -1531,6 +1531,7 @@ describe('@variant', () => { } `).build(['hocus:underline', 'group-hocus:underline']) + // I feel like this is wrong — shouldn't `:hover` be attached to `.hocus`? expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { .group-hocus\\:underline:is(.hocus *):hover, .group-hocus\\:underline:is(.hocus *):focus { From 12f176b3b9b32885ff26de9a6ac359d75029e519 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 12:42:33 -0400 Subject: [PATCH 15/25] Tweak comment, remove unnecessary return --- packages/tailwindcss/src/variants.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 5206cf4f06df..875ced9856d3 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -46,10 +46,9 @@ export class Variants { let body = structuredClone(ast) walk(body, (node, { replaceWith }) => { - // Inject existing nodes in `@slot` + // Replace `@slot` with rule nodes if (node.kind === 'rule' && node.selector === '@slot') { replaceWith(r.nodes) - return } // Wrap `@keyframes` and `@property` in `@at-root` From d424c1f709a54e0d534eda223a00739496f6cd91 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 14:31:33 -0400 Subject: [PATCH 16/25] Ensure group is usable with custom selector lists --- packages/tailwindcss/src/index.test.ts | 8 ++++++-- packages/tailwindcss/src/variants.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 97dc51d85e01..db9b547dd2ea 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1141,10 +1141,14 @@ describe('plugins', () => { } }, }, - ).build(['hocus:underline']) + ).build(['hocus:underline', 'group-hocus:flex']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { + .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + display: flex; + } + .hocus\\:underline:hover, .hocus\\:underline:focus { text-decoration-line: underline; } @@ -1414,7 +1418,7 @@ describe('@variant', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - .group-hocus\\:underline:is(:where(.group):hover, .group-hocus\\:underline:focus *) { + .group-hocus\\:underline:is(:is(:where(.group):hover, :where(.group):focus) *) { text-decoration-line: underline; } diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 875ced9856d3..2d888b921968 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -222,7 +222,14 @@ export function createVariants(theme: Theme): Variants { // selector, but there is no way to use CSS nesting to make `&` refer to // just the `.group` class the way we'd need to for these variants, so we // need to replace it in the selector ourselves. - ruleNode.selector = ruleNode.selector.replace('&', groupSelector) + ruleNode.selector = ruleNode.selector.replaceAll('&', groupSelector) + + // This selector is wrapped in `:is` given that `ruleNode.selector` might be + // a selector list when compounding a variant the behavior needs to stay + // consistent with the original variant / selector. + // TODO: Ideally this would only be done when there are combinators in the + // selector, but that's a bit more complex to implement. + ruleNode.selector = `:is(${ruleNode.selector})` // Use `:where` to make sure the specificity of group variants isn't higher // than the specificity of other variants. From 8e6e9483b729153f929683ad12fedc25c15082f3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 14:33:33 -0400 Subject: [PATCH 17/25] =?UTF-8?q?Only=20apply=20extra=20`:is(=E2=80=A6)`?= =?UTF-8?q?=20when=20there=20are=20multiple=20selectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/variants.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 2d888b921968..3f341d47b7bc 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -2,6 +2,7 @@ import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast' import { type Variant } from './candidate' import type { Theme } from './theme' import { DefaultMap } from './utils/default-map' +import { segment } from './utils/segment' type VariantFn = ( rule: Rule, @@ -227,9 +228,11 @@ export function createVariants(theme: Theme): Variants { // This selector is wrapped in `:is` given that `ruleNode.selector` might be // a selector list when compounding a variant the behavior needs to stay // consistent with the original variant / selector. - // TODO: Ideally this would only be done when there are combinators in the - // selector, but that's a bit more complex to implement. - ruleNode.selector = `:is(${ruleNode.selector})` + // TODO: This should probably check for "are there any combinators" and not + // multiple selectors + if (segment(ruleNode.selector, ',').length > 1) { + ruleNode.selector = `:is(${ruleNode.selector})` + } // Use `:where` to make sure the specificity of group variants isn't higher // than the specificity of other variants. From e8d625c7220f68e5fa0784c3f2bc7c96cab998b4 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 14:36:45 -0400 Subject: [PATCH 18/25] Tweak comment --- packages/tailwindcss/src/variants.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 3f341d47b7bc..303c23758eb4 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -225,11 +225,9 @@ export function createVariants(theme: Theme): Variants { // need to replace it in the selector ourselves. ruleNode.selector = ruleNode.selector.replaceAll('&', groupSelector) - // This selector is wrapped in `:is` given that `ruleNode.selector` might be - // a selector list when compounding a variant the behavior needs to stay - // consistent with the original variant / selector. - // TODO: This should probably check for "are there any combinators" and not - // multiple selectors + // When the selector is a selector _list_ we need to wrap it in `:is` + // to make sure the matching behavior is consistent with the original + // variant / selector. if (segment(ruleNode.selector, ',').length > 1) { ruleNode.selector = `:is(${ruleNode.selector})` } From a4707e673f69bea11763fc5eb0b56de26c3b1933 Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:54:31 -0400 Subject: [PATCH 19/25] Throw when @variant has both selector and body --- packages/tailwindcss/src/index.test.ts | 14 ++++++++++++++ packages/tailwindcss/src/index.ts | 10 ++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index db9b547dd2ea..6774363f2689 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1331,6 +1331,20 @@ describe('@variant', () => { ).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]') }) + test('@variant cannot have both a selector and a body', () => { + expect(() => + compileCss(css` + @variant hocus (&:hover, &:focus) { + &:is(.potato) { + @slot; + } + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: \`@variant hocus\` cannot have both a selector and a body.]`, + ) + }) + describe('body-less syntax', () => { test('selector variant', () => { let compiled = compile(css` diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 966c694eafbe..9d2e90edae6f 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -74,10 +74,14 @@ export function compile( // Remove `@variant` at-rule so it's not included in the compiled CSS replaceWith([]) + let [name, selector] = segment(node.selector.slice(9), ' ') + + if (node.nodes.length > 0 && selector) { + throw new Error(`\`@variant ${name}\` cannot have both a selector and a body.`) + } + // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` if (node.nodes.length === 0) { - let [name, selector] = segment(node.selector.slice(9), ' ') - if (!selector) { throw new Error(`\`@variant ${name}\` has no selector or body.`) } @@ -109,8 +113,6 @@ export function compile( // } // ``` else { - let name = node.selector.slice(9).trim() - customVariants.push((designSystem) => { designSystem.variants.fromAst(name, node.nodes) }) From 48ccf46dd5800f444bbb34cfe27198047bffbe6a Mon Sep 17 00:00:00 2001 From: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:54:42 -0400 Subject: [PATCH 20/25] Rework tests to use more realistic examples --- packages/tailwindcss/src/index.test.ts | 168 ++++++++++++------------- 1 file changed, 80 insertions(+), 88 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 6774363f2689..fa1293e655e1 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1392,8 +1392,8 @@ describe('@variant', () => { describe('body with @slot syntax', () => { test('selector with @slot', () => { let compiled = compile(css` - @variant custom-hover { - &:hover { + @variant selected { + &[data-selected] { @slot; } } @@ -1401,15 +1401,15 @@ describe('@variant', () => { @layer utilities { @tailwind utilities; } - `).build(['custom-hover:underline', 'group-custom-hover:underline']) + `).build(['selected:underline', 'group-selected:underline']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - .group-custom-hover\\:underline:is(:where(.group):hover *) { + .group-selected\\:underline:is(:where(.group)[data-selected] *) { text-decoration-line: underline; } - .custom-hover\\:underline:hover { + .selected\\:underline[data-selected] { text-decoration-line: underline; } }" @@ -1473,11 +1473,12 @@ describe('@variant', () => { `) }) - test('nested selectors with @slot', () => { + test('nested selector with @slot', () => { let compiled = compile(css` - @variant custom-hover { - &.custom-hover { - &:hover { + @variant custom-before { + & { + --has-before: 1; + &::before { @slot; } } @@ -1486,15 +1487,15 @@ describe('@variant', () => { @layer utilities { @tailwind utilities; } - `).build(['custom-hover:underline', 'group-custom-hover:underline']) + `).build(['custom-before:underline']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - .group-custom-hover\\:underline:is(:where(.group).custom-hover *):hover { - text-decoration-line: underline; + .custom-before\\:underline { + --has-before: 1; } - .custom-hover\\:underline.custom-hover:hover { + .custom-before\\:underline:before { text-decoration-line: underline; } }" @@ -1503,11 +1504,14 @@ describe('@variant', () => { test('grouped nested selectors with @slot', () => { let compiled = compile(css` - @variant hocus { - &.hocus { - &:hover, - &:focus { - @slot; + @variant custom-before { + & { + --has-before: 1; + &::before { + &:hover, + &:focus { + @slot; + } } } } @@ -1515,32 +1519,32 @@ describe('@variant', () => { @layer utilities { @tailwind utilities; } - `).build(['hocus:underline', 'group-hocus:underline']) + `).build(['custom-before:underline']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - .group-hocus\\:underline:is(:where(.group).hocus *):hover, .group-hocus\\:underline:is(:where(.group).hocus *):focus { - text-decoration-line: underline; + .custom-before\\:underline { + --has-before: 1; } - .hocus\\:underline.hocus:hover, .hocus\\:underline.hocus:focus { + .custom-before\\:underline:before:hover, .custom-before\\:underline:before:focus { text-decoration-line: underline; } }" `) }) - test.skip('nested multiple selectors with @slot', () => { + test('nested multiple selectors with @slot', () => { let compiled = compile(css` @variant hocus { - .hocus { - &:hover { + &:hover { + @media (hover: hover) { @slot; } + } - &:focus { - @slot; - } + &:focus { + @slot; } } @@ -1549,71 +1553,54 @@ describe('@variant', () => { } `).build(['hocus:underline', 'group-hocus:underline']) - // I feel like this is wrong — shouldn't `:hover` be attached to `.hocus`? expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - .group-hocus\\:underline:is(.hocus *):hover, .group-hocus\\:underline:is(.hocus *):focus { - text-decoration-line: underline; + @media (hover: hover) { + .group-hocus\\:underline:is(:where(.group):hover *) { + text-decoration-line: underline; + } } - .hocus\\:underline .hocus:hover, .hocus\\:underline .hocus:focus { + .group-hocus\\:underline:is(:where(.group):focus *) { text-decoration-line: underline; } - }" - `) - }) - - test('at-rule with @slot', () => { - let compiled = compile(css` - @variant custom-hover { - @media (any-hover: hover) { - @slot; - } - } - - @layer utilities { - @tailwind utilities; - } - `).build(['custom-hover:underline']) - expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` - "@layer utilities { - @media (any-hover: hover) { - .custom-hover\\:underline { + @media (hover: hover) { + .hocus\\:underline:hover { text-decoration-line: underline; } } + + .hocus\\:underline:focus { + text-decoration-line: underline; + } }" `) }) - test('multiple at-rules with @slot', () => { + test.skip('selector nested under at-rule with @slot', () => { let compiled = compile(css` - @variant desktop { - @media (any-hover: hover) { - @slot; - } - - @media (pointer: fine) { - @slot; + @variant hocus { + @media (hover: hover) { + &:hover { + @slot; + } } } @layer utilities { @tailwind utilities; } - `).build(['desktop:underline']) + `).build(['hocus:underline', 'group-hocus:underline']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - @media (any-hover: hover) { - .desktop\\:underline { - text-decoration-line: underline; - } + .group-hocus\\:underline:is():hover { + text-decoration-line: underline; } - @media (pointer: fine) { - .desktop\\:underline { + @media (hover: hover) { + .hocus\\:underline:hover { text-decoration-line: underline; } } @@ -1621,25 +1608,23 @@ describe('@variant', () => { `) }) - test('at-rule with nesting with @slot', () => { + test('at-rule with @slot', () => { let compiled = compile(css` - @variant custom-hover { + @variant any-hover { @media (any-hover: hover) { - &:hover { - @slot; - } + @slot; } } @layer utilities { @tailwind utilities; } - `).build(['custom-hover:underline']) + `).build(['any-hover:underline']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { @media (any-hover: hover) { - .custom-hover\\:underline:hover { + .any-hover\\:underline { text-decoration-line: underline; } } @@ -1647,14 +1632,15 @@ describe('@variant', () => { `) }) - test('at-rule with nested grouped selectors with @slot', () => { + test('multiple at-rules with @slot', () => { let compiled = compile(css` @variant desktop { - @media screen, print { - &:hover, - &:focus { - @slot; - } + @media (any-hover: hover) { + @slot; + } + + @media (pointer: fine) { + @slot; } } @@ -1665,8 +1651,14 @@ describe('@variant', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - @media screen, print { - .desktop\\:underline:hover, .desktop\\:underline:focus { + @media (any-hover: hover) { + .desktop\\:underline { + text-decoration-line: underline; + } + } + + @media (pointer: fine) { + .desktop\\:underline { text-decoration-line: underline; } } @@ -1677,13 +1669,13 @@ describe('@variant', () => { test('nested at-rules with @slot', () => { let compiled = compile(css` @variant custom-variant { - @media print { + @media (orientation: landscape) { @media screen { @slot; } - @media all { - @slot; + @media print { + display: none; } } } @@ -1695,16 +1687,16 @@ describe('@variant', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - @media print { + @media (orientation: landscape) { @media screen { .custom-variant\\:underline { text-decoration-line: underline; } } - @media all { + @media print { .custom-variant\\:underline { - text-decoration-line: underline; + display: none; } } } From aaae982deaaf8a0bb2dbfefaef1dacdcb3fde9ec Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 16:07:05 -0400 Subject: [PATCH 21/25] Compound variants on an isolated copy This prevents traversals from leaking across variants --- packages/tailwindcss/src/compile.ts | 32 ++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index c9f604919513..cac07451d941 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -1,4 +1,4 @@ -import { rule, type AstNode, type Rule } from './ast' +import { WalkAction, rule, walk, type AstNode, type Rule } from './ast' import { type Candidate, type Variant } from './candidate' import { type DesignSystem } from './design-system' import GLOBAL_PROPERTY_ORDER from './property-order' @@ -170,10 +170,25 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants): let { applyFn } = variants.get(variant.root)! if (variant.kind === 'compound') { - let result = applyVariant(node, variant.variant, variants) + // Some variants traverse the AST to mutate the nodes. E.g.: `group-*` wants + // to prefix every selector of the variant it's compounding with `.group`. + // + // E.g.: + // ``` + // group-hover:[&_p]:flex + // ``` + // + // Should only prefix the `group-hover` part with `.group`, and not the `&_p` part. + // + // To solve this, we provide an isolated placeholder node to the variant. + // The variant can now apply its logic to the isolated node without + // affecting the original node. + let isolatedNode = rule('@slot', []) + + let result = applyVariant(isolatedNode, variant.variant, variants) if (result === null) return null - for (let child of node.nodes) { + for (let child of isolatedNode.nodes) { // Only some variants wrap children in rules. For example, the `force` // variant is a noop on the AST. And the `has` variant modifies the // selector rather than the children. @@ -186,6 +201,17 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants): let result = applyFn(child as Rule, variant) if (result === null) return null } + + // Replace the placeholder node with the actual node + { + walk(isolatedNode.nodes, (child) => { + if (child.kind === 'rule' && child.nodes.length <= 0) { + child.nodes = node.nodes + return WalkAction.Skip + } + }) + node.nodes = isolatedNode.nodes + } return } From c6600ea0eb8801a40a37478ae54761d64ab49e6c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 16:13:13 -0400 Subject: [PATCH 22/25] Handle selector lists for peer variants --- packages/tailwindcss/src/variants.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 303c23758eb4..c52efc2d351b 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -256,6 +256,13 @@ export function createVariants(theme: Theme): Variants { // need to replace it in the selector ourselves. ruleNode.selector = ruleNode.selector.replace('&', peerSelector) + // When the selector is a selector _list_ we need to wrap it in `:is` + // to make sure the matching behavior is consistent with the original + // variant / selector. + if (segment(ruleNode.selector, ',').length > 1) { + ruleNode.selector = `:is(${ruleNode.selector})` + } + // Use `:where` to make sure the specificity of peer variants isn't higher // than the specificity of other variants. ruleNode.selector = `&:is(${ruleNode.selector} ~ *)` From 63efa4b389d609e7c24d451770e9c8a5f91fd9ad Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 16:14:12 -0400 Subject: [PATCH 23/25] Ignore at rules when compounding group and peer variants --- packages/tailwindcss/src/variants.ts | 74 +++++++++++++++++----------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index c52efc2d351b..d5d51883c9c0 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -219,22 +219,29 @@ export function createVariants(theme: Theme): Variants { ? `:where(.group\\/${variant.modifier.value})` : ':where(.group)' - // For most variants we rely entirely on CSS nesting to build-up the final - // selector, but there is no way to use CSS nesting to make `&` refer to - // just the `.group` class the way we'd need to for these variants, so we - // need to replace it in the selector ourselves. - ruleNode.selector = ruleNode.selector.replaceAll('&', groupSelector) - - // When the selector is a selector _list_ we need to wrap it in `:is` - // to make sure the matching behavior is consistent with the original - // variant / selector. - if (segment(ruleNode.selector, ',').length > 1) { - ruleNode.selector = `:is(${ruleNode.selector})` - } + walk([ruleNode], (node) => { + if (node.kind !== 'rule') return WalkAction.Continue + + // Skip past at-rules, and continue traversing the children of the at-rule + if (node.selector[0] === '@') return WalkAction.Continue + + // For most variants we rely entirely on CSS nesting to build-up the final + // selector, but there is no way to use CSS nesting to make `&` refer to + // just the `.group` class the way we'd need to for these variants, so we + // need to replace it in the selector ourselves. + node.selector = node.selector.replaceAll('&', groupSelector) + + // When the selector is a selector _list_ we need to wrap it in `:is` + // to make sure the matching behavior is consistent with the original + // variant / selector. + if (segment(node.selector, ',').length > 1) { + node.selector = `:is(${node.selector})` + } - // Use `:where` to make sure the specificity of group variants isn't higher - // than the specificity of other variants. - ruleNode.selector = `&:is(${ruleNode.selector} *)` + // Use `:where` to make sure the specificity of group variants isn't higher + // than the specificity of other variants. + node.selector = `&:is(${node.selector} *)` + }) }) variants.suggest('group', () => { @@ -250,22 +257,29 @@ export function createVariants(theme: Theme): Variants { ? `:where(.peer\\/${variant.modifier.value})` : ':where(.peer)' - // For most variants we rely entirely on CSS nesting to build-up the final - // selector, but there is no way to use CSS nesting to make `&` refer to - // just the `.peer` class the way we'd need to for these variants, so we - // need to replace it in the selector ourselves. - ruleNode.selector = ruleNode.selector.replace('&', peerSelector) - - // When the selector is a selector _list_ we need to wrap it in `:is` - // to make sure the matching behavior is consistent with the original - // variant / selector. - if (segment(ruleNode.selector, ',').length > 1) { - ruleNode.selector = `:is(${ruleNode.selector})` - } + walk([ruleNode], (node) => { + if (node.kind !== 'rule') return WalkAction.Continue + + // Skip past at-rules, and continue traversing the children of the at-rule + if (node.selector[0] === '@') return WalkAction.Continue - // Use `:where` to make sure the specificity of peer variants isn't higher - // than the specificity of other variants. - ruleNode.selector = `&:is(${ruleNode.selector} ~ *)` + // For most variants we rely entirely on CSS nesting to build-up the final + // selector, but there is no way to use CSS nesting to make `&` refer to + // just the `.group` class the way we'd need to for these variants, so we + // need to replace it in the selector ourselves. + node.selector = node.selector.replaceAll('&', peerSelector) + + // When the selector is a selector _list_ we need to wrap it in `:is` + // to make sure the matching behavior is consistent with the original + // variant / selector. + if (segment(node.selector, ',').length > 1) { + node.selector = `:is(${node.selector})` + } + + // Use `:where` to make sure the specificity of group variants isn't higher + // than the specificity of other variants. + node.selector = `&:is(${node.selector} ~ *)` + }) }) variants.suggest('peer', () => { From e76038c8cd9b469129cf0a046e8a899ea2e2dee4 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 16:14:25 -0400 Subject: [PATCH 24/25] Re-enable skipped tests --- packages/tailwindcss/src/index.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index fa1293e655e1..a7ba81a7d534 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1219,7 +1219,7 @@ describe('plugins', () => { `) }) - test.skip('addVariant with object syntax, media, nesting and multiple @slot', () => { + test('addVariant with object syntax, media, nesting and multiple @slot', () => { let compiled = compile( css` @plugin "my-plugin"; @@ -1578,7 +1578,7 @@ describe('@variant', () => { `) }) - test.skip('selector nested under at-rule with @slot', () => { + test('selector nested under at-rule with @slot', () => { let compiled = compile(css` @variant hocus { @media (hover: hover) { @@ -1595,8 +1595,10 @@ describe('@variant', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { - .group-hocus\\:underline:is():hover { - text-decoration-line: underline; + @media (hover: hover) { + .group-hocus\\:underline:is(:where(.group):hover *) { + text-decoration-line: underline; + } } @media (hover: hover) { From e6c16e9601db2472cfdcf4b4eeb17dfb1638f2b5 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 16 Jul 2024 16:20:21 -0400 Subject: [PATCH 25/25] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6510ffc3d473..c9e1de745087 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982)) -- Add support for custom variants via CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992)) +- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992)) ## [4.0.0-alpha.17] - 2024-07-04