Simplify @variant usage, allow compound and stacked variants (#19996)
This PR improves and simplifies the `@variant` usage.
When we originally added support for `@variant`, we wanted to keep
things simple, where we could only use a single variant at a time. The
original PR did have a more complex system with all these features
enabled, but we wanted to make sure that we only introduced the
additional complexity when the community felt like it was needed.
But of course we still wanted to make sure that you could do compound
and stacked variants, it just required some additional code.
For compound variants, where you want to use variant `a` and variant
`b`, you could duplicate the rules as siblings:
```css
.foo {
@variant a {
display: flex;
}
@variant b {
display: flex;
}
}
```
But with this PR, you can comma separate each variant to get the same
effect:
```css
.foo {
@variant a, b {
display: flex;
}
}
```
You can think of this as-if we are expanding this syntax into the
aforementioned syntax. In other words, we would do the duplication for
you.
Additionally, you also want to be able to stack variants. For that you
had to nest your `@variant` rules:
```css
.foo {
@variant a {
@variant b {
display: flex;
}
}
}
```
Not the end of the world, but it can get pretty nested if you want to
use multiple variants. Luckily we already have a syntax for this in
normal Tailwind CSS classes: `a🅱️flex`. Which is exactly what we can
use here as well:
```css
.foo {
@variant a:b {
display: flex;
}
}
```
Again, conceptually you can think of this syntax being expanded into the
syntax from above.
Last but not least, we can also combine these:
```css
.foo {
background: black;
@variant a, b:c {
background: red;
@variant d, e:f {
background: blue;
}
}
}
```
This conceptually translates into the much more verbose version today:
```css
.foo {
background: black;
@variant a {
background: red;
@variant d {
background: blue;
}
@variant e {
@variant f {
background: blue;
}
}
}
@variant b {
@variant c {
background: red;
@variant d {
background: blue;
}
@variant e {
@variant f {
background: blue;
}
}
}
}
}
```
The biggest downside is that this could potentially easily balloon your
CSS file size if you're not careful. Because with this, it's pretty easy
to add one more variant that introduces a lot of duplicated CSS.
This feature is completely backwards compatible, you can still nest your
`@variant` calls yourself if you want, and combine them with these
features if you want.
This is also a continuation of #19526 and #19884, but for some reason I
don't have push rights, so I'm creating this new PR instead. I did keep
the original commits of those PRs so these contributors are still
properly marked as contributors.
<img width="808" height="135" alt="image"
src="https://github.com/user-attachments/assets/bee334ab-39d7-4d4d-a48f-afa2253cf17b"
/>
<img width="349" height="75" alt="image"
src="https://github.com/user-attachments/assets/fb68906c-db74-43f7-83e4-918ad3d4a036"
/>
Closes: #19526
Closes: #19884
## Test plan
1. Added a bunch of new tests to verify this new behavior
2. Added tests that compare the short (new) version, and the long (old)
version
3. Added a sourcemap related test to ensure that the src and dst
locations are correct
4. Existing tests still pass
---------
Co-authored-by: orteth01 <tortega128@gmail.com>
Co-authored-by: Ray Knight <array.knight+github@gmail.com>
This commit is contained in:
parent
6cf1af26b3
commit
e1201bc6e3
4 changed files with 482 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue