Skip to content

Commit 3ccb53d

Browse files
committed
implement @variant
1 parent 988e90e commit 3ccb53d

File tree

2 files changed

+251
-1
lines changed

2 files changed

+251
-1
lines changed

packages/tailwindcss/src/index.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3315,3 +3315,189 @@ describe('`@import "…" reference`', () => {
33153315
`)
33163316
})
33173317
})
3318+
3319+
describe('@variant', () => {
3320+
it('should convert legacy body-less `@variant` as a `@custom-variant`', async () => {
3321+
await expect(
3322+
compileCss(
3323+
css`
3324+
@variant hocus (&:hover, &:focus);
3325+
@tailwind utilities;
3326+
`,
3327+
['hocus:underline'],
3328+
),
3329+
).resolves.toMatchInlineSnapshot(`
3330+
".hocus\\:underline:hover, .hocus\\:underline:focus {
3331+
text-decoration-line: underline;
3332+
}"
3333+
`)
3334+
})
3335+
3336+
it('should convert legacy `@variant` with `@slot` as a `@custom-variant`', async () => {
3337+
await expect(
3338+
compileCss(
3339+
css`
3340+
@variant hocus {
3341+
&:hover {
3342+
@slot;
3343+
}
3344+
3345+
&:focus {
3346+
@slot;
3347+
}
3348+
}
3349+
@tailwind utilities;
3350+
`,
3351+
['hocus:underline'],
3352+
),
3353+
).resolves.toMatchInlineSnapshot(`
3354+
".hocus\\:underline:hover, .hocus\\:underline:focus {
3355+
text-decoration-line: underline;
3356+
}"
3357+
`)
3358+
})
3359+
3360+
it('should be possible to use `@variant` in your CSS', async () => {
3361+
await expect(
3362+
compileCss(
3363+
css`
3364+
.btn {
3365+
background: black;
3366+
3367+
@variant dark {
3368+
background: white;
3369+
}
3370+
}
3371+
`,
3372+
[],
3373+
),
3374+
).resolves.toMatchInlineSnapshot(`
3375+
".btn {
3376+
background: #000;
3377+
}
3378+
3379+
@media (prefers-color-scheme: dark) {
3380+
.btn {
3381+
background: #fff;
3382+
}
3383+
}"
3384+
`)
3385+
})
3386+
3387+
it('should be possible to use `@variant` in your CSS with a `@custom-variant` that is defined later', async () => {
3388+
await expect(
3389+
compileCss(
3390+
css`
3391+
.btn {
3392+
background: black;
3393+
3394+
@variant hocus {
3395+
background: white;
3396+
}
3397+
}
3398+
3399+
@custom-variant hocus (&:hover, &:focus);
3400+
`,
3401+
[],
3402+
),
3403+
).resolves.toMatchInlineSnapshot(`
3404+
".btn {
3405+
background: #000;
3406+
}
3407+
3408+
.btn:hover, .btn:focus {
3409+
background: #fff;
3410+
}"
3411+
`)
3412+
})
3413+
3414+
it('should be possible to use nested `@variant` rules', async () => {
3415+
await expect(
3416+
compileCss(
3417+
css`
3418+
.btn {
3419+
background: black;
3420+
3421+
@variant disabled {
3422+
@variant focus {
3423+
background: white;
3424+
}
3425+
}
3426+
}
3427+
@tailwind utilities;
3428+
`,
3429+
['disabled:focus:underline'],
3430+
),
3431+
).resolves.toMatchInlineSnapshot(`
3432+
".btn {
3433+
background: #000;
3434+
}
3435+
3436+
.btn:disabled:focus {
3437+
background: #fff;
3438+
}
3439+
3440+
.disabled\\:focus\\:underline:disabled:focus {
3441+
text-decoration-line: underline;
3442+
}"
3443+
`)
3444+
})
3445+
3446+
it('should be possible to use multiple `@variant` params at once', async () => {
3447+
await expect(
3448+
compileCss(
3449+
css`
3450+
.btn {
3451+
background: black;
3452+
3453+
@variant hover:focus {
3454+
background: white;
3455+
}
3456+
}
3457+
`,
3458+
[],
3459+
),
3460+
).resolves.toMatchInlineSnapshot(`
3461+
".btn {
3462+
background: #000;
3463+
}
3464+
3465+
@media (hover: hover) {
3466+
.btn:hover:focus {
3467+
background: #fff;
3468+
}
3469+
}"
3470+
`)
3471+
})
3472+
3473+
it('should be possible to use `@variant` with a funky looking variants', async () => {
3474+
await expect(
3475+
compileCss(
3476+
css`
3477+
@theme inline reference {
3478+
--container-md: 768px;
3479+
}
3480+
3481+
.btn {
3482+
background: black;
3483+
3484+
@variant @md:[&.foo] {
3485+
background: white;
3486+
}
3487+
}
3488+
`,
3489+
[],
3490+
),
3491+
).resolves.toMatchInlineSnapshot(`
3492+
".btn {
3493+
background: #000;
3494+
}
3495+
3496+
@container (width >= 768px) {
3497+
.btn.foo {
3498+
background: #fff;
3499+
}
3500+
}"
3501+
`)
3502+
})
3503+
})

packages/tailwindcss/src/index.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { substituteAtImports } from './at-import'
2121
import { applyCompatibilityHooks } from './compat/apply-compat-hooks'
2222
import type { UserConfig } from './compat/config/types'
2323
import { type Plugin } from './compat/plugin-api'
24-
import { compileCandidates } from './compile'
24+
import { applyVariant, compileCandidates } from './compile'
2525
import { substituteFunctions } from './css-functions'
2626
import * as CSS from './css-parser'
2727
import { buildDesignSystem, type DesignSystem } from './design-system'
@@ -97,6 +97,9 @@ export const enum Features {
9797

9898
// `@tailwind utilities` was used
9999
Utilities = 1 << 4,
100+
101+
// `@variant` was used
102+
Variants = 1 << 5,
100103
}
101104

102105
async function parseCss(
@@ -118,6 +121,7 @@ async function parseCss(
118121
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
119122
let firstThemeRule = null as StyleRule | null
120123
let utilitiesNode = null as AtRule | null
124+
let variantNodes: AtRule[] = []
121125
let globs: { base: string; pattern: string }[] = []
122126
let root = null as Root
123127

@@ -213,6 +217,42 @@ async function parseCss(
213217
return
214218
}
215219

220+
// Apply `@variant` at-rules
221+
if (node.name === '@variant') {
222+
// Legacy `@variant` at-rules containing `@slot` or without a body should
223+
// be considered a `@custom-variant` at-rule.
224+
if (parent === null) {
225+
// Body-less `@variant`, e.g.: `@variant foo (…);`
226+
if (node.nodes.length === 0) {
227+
node.name = '@custom-variant'
228+
}
229+
230+
// Using `@slot`:
231+
//
232+
// ```css
233+
// @variant foo {
234+
// &:hover {
235+
// @slot;
236+
// }
237+
// }
238+
// ```
239+
else {
240+
walk(node.nodes, (child) => {
241+
if (child.kind === 'at-rule' && child.name === '@slot') {
242+
node.name = '@custom-variant'
243+
return WalkAction.Stop
244+
}
245+
})
246+
}
247+
}
248+
249+
// Collect all the `@variant` at-rules, we will replace them later once
250+
// all variants are registered in the system.
251+
else {
252+
variantNodes.push(node)
253+
}
254+
}
255+
216256
// Register custom variants from `@custom-variant` at-rules
217257
if (node.name === '@custom-variant') {
218258
if (parent !== null) {
@@ -501,6 +541,30 @@ async function parseCss(
501541
node.context = {}
502542
}
503543

544+
// Replace the `@variant` at-rules with the actual variant rules.
545+
if (variantNodes.length > 0) {
546+
for (let variantNode of variantNodes) {
547+
// Starting with the `&` rule node
548+
let node = styleRule('&', variantNode.nodes)
549+
550+
for (let variant of segment(variantNode.params, ':').reverse()) {
551+
let variantAst = designSystem.parseVariant(variant)
552+
if (variantAst === null) {
553+
throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`)
554+
}
555+
556+
let result = applyVariant(node, variantAst, designSystem.variants)
557+
if (result === null) {
558+
throw new Error(`Cannot use \`@variant\` with variant: ${variant}`)
559+
}
560+
}
561+
562+
// Update the variant at-rule node, to be the `&` rule node
563+
Object.assign(variantNode, node)
564+
}
565+
features |= Features.Variants
566+
}
567+
504568
features |= substituteFunctions(ast, designSystem)
505569
features |= substituteAtApply(ast, designSystem)
506570

0 commit comments

Comments
 (0)