Skip to content

Commit 5ebd589

Browse files
RobinMalfaitadamwathanthecrypticace
authored
Add support for custom variants via CSS (tailwindlabs#13992)
* implement `@variant` in CSS * implement `addVariant(name, objectTree)` * update changelog * ensure that `@variant` can only be used top-level * simplify Plugin API type * Use type instead of interface (for now) * Use more realistic variant for test * Allow custom properties to use `@slot` as content * Use "cannot" instead of "can not" * Remove `@variant` right away * Throw when `@variant` is missing a selector or body * Use "CSS-in-JS" terminology instead of "CSS Tree" * Rename tests * Mark some tests that seem wrong * Tweak comment, remove unnecessary return * Ensure group is usable with custom selector lists * Only apply extra `:is(…)` when there are multiple selectors * Tweak comment * Throw when @variant has both selector and body * Rework tests to use more realistic examples * Compound variants on an isolated copy This prevents traversals from leaking across variants * Handle selector lists for peer variants * Ignore at rules when compounding group and peer variants * Re-enable skipped tests * Update changelog --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Co-authored-by: Jordan Pittman <jordan@cryptica.me>
1 parent 5447408 commit 5ebd589

File tree

7 files changed

+796
-42
lines changed

7 files changed

+796
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
### Added
1919

2020
- Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982))
21+
- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992))
2122

2223
## [4.0.0-alpha.17] - 2024-07-04
2324

packages/tailwindcss/src/ast.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ export function comment(value: string): Comment {
4242
}
4343
}
4444

45+
export type CssInJs = { [key: string]: string | CssInJs }
46+
47+
export function objectToAst(obj: CssInJs): AstNode[] {
48+
let ast: AstNode[] = []
49+
50+
for (let [name, value] of Object.entries(obj)) {
51+
if (typeof value === 'string') {
52+
if (!name.startsWith('--') && value === '@slot') {
53+
ast.push(rule(name, [rule('@slot', [])]))
54+
} else {
55+
ast.push(decl(name, value))
56+
}
57+
} else {
58+
ast.push(rule(name, objectToAst(value)))
59+
}
60+
}
61+
62+
return ast
63+
}
64+
4565
export enum WalkAction {
4666
/** Continue walking, which is the default */
4767
Continue,
@@ -58,14 +78,17 @@ export function walk(
5878
visit: (
5979
node: AstNode,
6080
utils: {
81+
parent: AstNode | null
6182
replaceWith(newNode: AstNode | AstNode[]): void
6283
},
6384
) => void | WalkAction,
85+
parent: AstNode | null = null,
6486
) {
6587
for (let i = 0; i < ast.length; i++) {
6688
let node = ast[i]
6789
let status =
6890
visit(node, {
91+
parent,
6992
replaceWith(newNode) {
7093
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
7194
// We want to visit the newly replaced node(s), which start at the
@@ -82,7 +105,7 @@ export function walk(
82105
if (status === WalkAction.Skip) continue
83106

84107
if (node.kind === 'rule') {
85-
walk(node.nodes, visit)
108+
walk(node.nodes, visit, node)
86109
}
87110
}
88111
}

packages/tailwindcss/src/compile.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rule, type AstNode, type Rule } from './ast'
1+
import { WalkAction, rule, walk, type AstNode, type Rule } from './ast'
22
import { type Candidate, type Variant } from './candidate'
33
import { type DesignSystem } from './design-system'
44
import GLOBAL_PROPERTY_ORDER from './property-order'
@@ -170,10 +170,25 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants):
170170
let { applyFn } = variants.get(variant.root)!
171171

172172
if (variant.kind === 'compound') {
173-
let result = applyVariant(node, variant.variant, variants)
173+
// Some variants traverse the AST to mutate the nodes. E.g.: `group-*` wants
174+
// to prefix every selector of the variant it's compounding with `.group`.
175+
//
176+
// E.g.:
177+
// ```
178+
// group-hover:[&_p]:flex
179+
// ```
180+
//
181+
// Should only prefix the `group-hover` part with `.group`, and not the `&_p` part.
182+
//
183+
// To solve this, we provide an isolated placeholder node to the variant.
184+
// The variant can now apply its logic to the isolated node without
185+
// affecting the original node.
186+
let isolatedNode = rule('@slot', [])
187+
188+
let result = applyVariant(isolatedNode, variant.variant, variants)
174189
if (result === null) return null
175190

176-
for (let child of node.nodes) {
191+
for (let child of isolatedNode.nodes) {
177192
// Only some variants wrap children in rules. For example, the `force`
178193
// variant is a noop on the AST. And the `has` variant modifies the
179194
// selector rather than the children.
@@ -186,6 +201,17 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants):
186201
let result = applyFn(child as Rule, variant)
187202
if (result === null) return null
188203
}
204+
205+
// Replace the placeholder node with the actual node
206+
{
207+
walk(isolatedNode.nodes, (child) => {
208+
if (child.kind === 'rule' && child.nodes.length <= 0) {
209+
child.nodes = node.nodes
210+
return WalkAction.Skip
211+
}
212+
})
213+
node.nodes = isolatedNode.nodes
214+
}
189215
return
190216
}
191217

packages/tailwindcss/src/design-system.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { rule, toCss } from './ast'
1+
import { toCss } from './ast'
22
import { parseCandidate, parseVariant } from './candidate'
33
import { compileAstNodes, compileCandidates } from './compile'
44
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
@@ -8,10 +8,6 @@ import { Utilities, createUtilities } from './utilities'
88
import { DefaultMap } from './utils/default-map'
99
import { Variants, createVariants } from './variants'
1010

11-
export type Plugin = (api: {
12-
addVariant: (name: string, selector: string | string[]) => void
13-
}) => void
14-
1511
export type DesignSystem = {
1612
theme: Theme
1713
utilities: Utilities
@@ -29,7 +25,7 @@ export type DesignSystem = {
2925
getUsedVariants(): ReturnType<typeof parseVariant>[]
3026
}
3127

32-
export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignSystem {
28+
export function buildDesignSystem(theme: Theme): DesignSystem {
3329
let utilities = createUtilities(theme)
3430
let variants = createVariants(theme)
3531

@@ -81,15 +77,5 @@ export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignS
8177
},
8278
}
8379

84-
for (let plugin of plugins) {
85-
plugin({
86-
addVariant: (name: string, selectors: string | string[]) => {
87-
variants.static(name, (r) => {
88-
r.nodes = ([] as string[]).concat(selectors).map((selector) => rule(selector, r.nodes))
89-
})
90-
},
91-
})
92-
}
93-
9480
return designSystem
9581
}

0 commit comments

Comments
 (0)