Improve support for custom variants in group-*, peer-*, has-*, and not-* variants (#14743)

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Jordan Pittman 2024-10-24 13:27:27 -04:00 committed by GitHub
parent 5a1c2e7480
commit 148d8707b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1119 additions and 199 deletions

View File

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added `not-*` versions of all builtin media query and supports variants ([#14743](https://github.com/tailwindlabs/tailwindcss/pull/14743))
- Improved support for custom variants with `group-*`, `peer-*`, `has-*`, and `not-*` ([#14743](https://github.com/tailwindlabs/tailwindcss/pull/14743))
### Changed
- Don't convert underscores in the first argument to `var()` and `theme()` to spaces ([#14776](https://github.com/tailwindlabs/tailwindcss/pull/14776), [#14781](https://github.com/tailwindlabs/tailwindcss/pull/14781))
@ -17,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Don't migrate important modifiers inside conditional statements in Vue and Alpine (e.g. `<div v-if="!border" />`) ([#14774](https://github.com/tailwindlabs/tailwindcss/pull/14774))
- Ensure third-party plugins with `exports` in their `package.json` are resolved correctly ([#14775](https://github.com/tailwindlabs/tailwindcss/pull/14775))
- Ensure underscores in the `url()` function are never unescaped ([#14776](https://github.com/tailwindlabs/tailwindcss/pull/14776))
- Fixed display of complex variants in Intellisense ([#14743](https://github.com/tailwindlabs/tailwindcss/pull/14743))
- _Upgrade (experimental)_: Ensure `@import` statements for relative CSS files are actually migrated to use relative path syntax ([#14769](https://github.com/tailwindlabs/tailwindcss/pull/14769))
- _Upgrade (experimental)_: Only generate Preflight compatibility styles when Preflight is used ([#14773](https://github.com/tailwindlabs/tailwindcss/pull/14773))
- _Upgrade (experimental)_: Don't escape underscores when printing theme values migrated to CSS variables in arbitrary values (e.g. `m-[var(--spacing-1_5)]` instead of `m-[var(--spacing-1\_5)]`) ([#14778](https://github.com/tailwindlabs/tailwindcss/pull/14778))

View File

@ -3859,7 +3859,68 @@ exports[`getVariants 1`] = `
"isArbitrary": true,
"name": "not",
"selectors": [Function],
"values": [],
"values": [
"not",
"group",
"peer",
"first",
"last",
"only",
"odd",
"even",
"first-of-type",
"last-of-type",
"only-of-type",
"visited",
"target",
"open",
"default",
"checked",
"indeterminate",
"placeholder-shown",
"autofill",
"optional",
"required",
"valid",
"invalid",
"in-range",
"out-of-range",
"read-only",
"empty",
"focus-within",
"hover",
"focus",
"focus-visible",
"active",
"enabled",
"disabled",
"inert",
"has",
"aria",
"data",
"nth",
"nth-last",
"nth-of-type",
"nth-last-of-type",
"supports",
"motion-safe",
"motion-reduce",
"contrast-more",
"contrast-less",
"max",
"sm",
"min",
"@max",
"@",
"@min",
"portrait",
"landscape",
"ltr",
"rtl",
"dark",
"print",
"forced-colors",
],
},
{
"hasDash": true,

View File

@ -87,25 +87,30 @@ export function walk(
parent: AstNode | null
replaceWith(newNode: AstNode | AstNode[]): void
context: Record<string, string>
path: AstNode[]
},
) => void | WalkAction,
parent: AstNode | null = null,
parentPath: AstNode[] = [],
context: Record<string, string> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let path = [...parentPath, node]
let parent = parentPath.at(-1) ?? null
// We want context nodes to be transparent in walks. This means that
// whenever we encounter one, we immediately walk through its children and
// furthermore we also don't update the parent.
if (node.kind === 'context') {
walk(node.nodes, visit, parent, { ...context, ...node.context })
walk(node.nodes, visit, parentPath, { ...context, ...node.context })
continue
}
let status =
visit(node, {
parent,
context,
path,
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
// We want to visit the newly replaced node(s), which start at the
@ -113,7 +118,6 @@ export function walk(
// will process this position (containing the replaced node) again.
i--
},
context,
}) ?? WalkAction.Continue
// Stop the walk entirely
@ -123,11 +127,52 @@ export function walk(
if (status === WalkAction.Skip) continue
if (node.kind === 'rule') {
walk(node.nodes, visit, node, context)
walk(node.nodes, visit, path, context)
}
}
}
// This is a depth-first traversal of the AST
export function walkDepth(
ast: AstNode[],
visit: (
node: AstNode,
utils: {
parent: AstNode | null
path: AstNode[]
context: Record<string, string>
replaceWith(newNode: AstNode[]): void
},
) => void,
parentPath: AstNode[] = [],
context: Record<string, string> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let path = [...parentPath, node]
let parent = parentPath.at(-1) ?? null
if (node.kind === 'rule') {
walkDepth(node.nodes, visit, path, context)
} else if (node.kind === 'context') {
walkDepth(node.nodes, visit, parentPath, { ...context, ...node.context })
continue
}
visit(node, {
parent,
context,
path,
replaceWith(newNode) {
ast.splice(i, 1, ...newNode)
// Skip over the newly inserted nodes (being depth-first it doesn't make sense to visit them)
i += newNode.length - 1
},
})
}
}
export function toCss(ast: AstNode[]) {
let atRoots: string = ''
let seenAtProperties = new Set<string>()

View File

@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
import { Utilities } from './utilities'
import { Variants } from './variants'
import { Compounds, Variants } from './variants'
function run(
candidate: string,
@ -109,7 +109,6 @@ it('should parse a simple utility with a variant', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "static",
"root": "hover",
},
@ -137,12 +136,10 @@ it('should parse a simple utility with stacked variants', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "static",
"root": "hover",
},
{
"compounds": true,
"kind": "static",
"root": "focus",
},
@ -166,7 +163,6 @@ it('should parse a simple utility with an arbitrary variant', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "& p",
@ -194,7 +190,6 @@ it('should parse a simple utility with a parameterized variant', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "functional",
"modifier": null,
"root": "data",
@ -214,7 +209,7 @@ it('should parse compound variants with an arbitrary value as an arbitrary varia
utilities.static('flex', () => [])
let variants = new Variants()
variants.compound('group', () => {})
variants.compoundWith('group', Compounds.StyleRules, () => {})
expect(run('group-[&_p]/parent-name:flex', { utilities, variants })).toMatchInlineSnapshot(`
[
@ -226,7 +221,6 @@ it('should parse compound variants with an arbitrary value as an arbitrary varia
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "compound",
"modifier": {
"kind": "named",
@ -234,7 +228,6 @@ it('should parse compound variants with an arbitrary value as an arbitrary varia
},
"root": "group",
"variant": {
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "& p",
@ -251,7 +244,7 @@ it('should parse a simple utility with a parameterized variant and a modifier',
utilities.static('flex', () => [])
let variants = new Variants()
variants.compound('group', () => {})
variants.compoundWith('group', Compounds.StyleRules, () => {})
variants.functional('aria', () => {})
expect(run('group-aria-[disabled]/parent-name:flex', { utilities, variants }))
@ -265,7 +258,6 @@ it('should parse a simple utility with a parameterized variant and a modifier',
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "compound",
"modifier": {
"kind": "named",
@ -273,7 +265,6 @@ it('should parse a simple utility with a parameterized variant and a modifier',
},
"root": "group",
"variant": {
"compounds": true,
"kind": "functional",
"modifier": null,
"root": "aria",
@ -295,7 +286,7 @@ it('should parse compound group with itself group-group-*', () => {
let variants = new Variants()
variants.static('hover', () => {})
variants.compound('group', () => {})
variants.compoundWith('group', Compounds.StyleRules, () => {})
expect(run('group-group-group-hover/parent-name:flex', { utilities, variants }))
.toMatchInlineSnapshot(`
@ -308,7 +299,6 @@ it('should parse compound group with itself group-group-*', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "compound",
"modifier": {
"kind": "named",
@ -316,17 +306,14 @@ it('should parse compound group with itself group-group-*', () => {
},
"root": "group",
"variant": {
"compounds": true,
"kind": "compound",
"modifier": null,
"root": "group",
"variant": {
"compounds": true,
"kind": "compound",
"modifier": null,
"root": "group",
"variant": {
"compounds": true,
"kind": "static",
"root": "hover",
},
@ -353,7 +340,6 @@ it('should parse a simple utility with an arbitrary media variant', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "@media(width>=123px)",
@ -478,7 +464,6 @@ it('should parse a utility with a modifier and a variant', () => {
},
"variants": [
{
"compounds": true,
"kind": "static",
"root": "hover",
},
@ -895,7 +880,6 @@ it('should parse a static variant starting with @', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "static",
"root": "@lg",
},
@ -922,7 +906,6 @@ it('should parse a functional variant with a modifier', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "functional",
"modifier": {
"kind": "named",
@ -957,7 +940,6 @@ it('should parse a functional variant starting with @', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "functional",
"modifier": null,
"root": "@",
@ -989,7 +971,6 @@ it('should parse a functional variant starting with @ and a modifier', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "functional",
"modifier": {
"kind": "named",
@ -1204,7 +1185,6 @@ it('should parse arbitrary properties with a variant', () => {
"value": "red",
"variants": [
{
"compounds": true,
"kind": "static",
"root": "hover",
},
@ -1230,12 +1210,10 @@ it('should parse arbitrary properties with stacked variants', () => {
"value": "red",
"variants": [
{
"compounds": true,
"kind": "static",
"root": "hover",
},
{
"compounds": true,
"kind": "static",
"root": "focus",
},
@ -1257,13 +1235,11 @@ it('should parse arbitrary properties that are important and using stacked arbit
"value": "red",
"variants": [
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "& p",
},
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "@media(width>=123px)",
@ -1279,7 +1255,7 @@ it('should not parse compound group with a non-compoundable variant', () => {
utilities.static('flex', () => [])
let variants = new Variants()
variants.compound('group', () => {})
variants.compoundWith('group', Compounds.StyleRules, () => {})
expect(run('group-*:flex', { utilities, variants })).toMatchInlineSnapshot(`[]`)
})
@ -1301,7 +1277,6 @@ it('should parse a variant containing an arbitrary string with unbalanced parens
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "functional",
"modifier": null,
"root": "string",
@ -1349,7 +1324,6 @@ it('should parse candidates with a prefix', () => {
"root": "flex",
"variants": [
{
"compounds": true,
"kind": "static",
"root": "hover",
},

View File

@ -100,9 +100,6 @@ export type Variant =
kind: 'arbitrary'
selector: string
// If true, it can be applied as a child of a compound variant
compounds: boolean
// Whether or not the selector is a relative selector
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors/Selector_structure#relative_selector
relative: boolean
@ -116,9 +113,6 @@ export type Variant =
| {
kind: 'static'
root: string
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
/**
@ -138,9 +132,6 @@ export type Variant =
root: string
value: ArbitraryVariantValue | NamedVariantValue | null
modifier: ArbitraryModifier | NamedModifier | null
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
/**
@ -157,9 +148,6 @@ export type Variant =
root: string
modifier: ArbitraryModifier | NamedModifier | null
variant: Variant
// If true, it can be applied as a child of a compound variant
compounds: boolean
}
export type Candidate =
@ -511,7 +499,6 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
return {
kind: 'arbitrary',
selector,
compounds: true,
relative,
}
}
@ -546,7 +533,6 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
return {
kind: 'static',
root,
compounds: designSystem.variants.compounds(root),
}
}
@ -557,7 +543,6 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: null,
compounds: designSystem.variants.compounds(root),
}
}
@ -570,7 +555,6 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
kind: 'arbitrary',
value: decodeArbitraryValue(value.slice(1, -1)),
},
compounds: designSystem.variants.compounds(root),
}
}
@ -579,7 +563,6 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
root,
modifier: modifier === null ? null : parseModifier(modifier),
value: { kind: 'named', value },
compounds: designSystem.variants.compounds(root),
}
}
@ -588,14 +571,15 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
let subVariant = designSystem.parseVariant(value)
if (subVariant === null) return null
if (subVariant.compounds === false) return null
// These two variants must be compatible when compounded
if (!designSystem.variants.compoundsWith(root, subVariant)) return null
return {
kind: 'compound',
root,
modifier: modifier === null ? null : { kind: 'named', value: modifier },
variant: subVariant,
compounds: designSystem.variants.compounds(root),
}
}
}

View File

@ -8,7 +8,7 @@ import { withAlpha, withNegative } from '../utilities'
import { inferDataType } from '../utils/infer-data-type'
import { segment } from '../utils/segment'
import { toKeyPath } from '../utils/to-key-path'
import { substituteAtSlot } from '../variants'
import { compoundsForSelectors, substituteAtSlot } from '../variants'
import type { ResolvedConfig, UserConfig } from './config/types'
import { createThemeFn } from './plugin-functions'
@ -92,9 +92,15 @@ export function buildPluginApi(
addVariant(name, variant) {
// Single selector or multiple parallel selectors
if (typeof variant === 'string' || Array.isArray(variant)) {
designSystem.variants.static(name, (r) => {
r.nodes = parseVariantValue(variant, r.nodes)
})
designSystem.variants.static(
name,
(r) => {
r.nodes = parseVariantValue(variant, r.nodes)
},
{
compounds: compoundsForSelectors(typeof variant === 'string' ? [variant] : variant),
},
)
}
// CSS-in-JS object

View File

@ -2309,7 +2309,7 @@ describe('@variant', () => {
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) {
.group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) {
display: flex;
}
@ -2342,6 +2342,37 @@ describe('@variant', () => {
}"
`)
})
test('style-rules and at-rules', async () => {
let { build } = await compile(css`
@variant cant-hover (&:not(:hover), &:not(:active), @media not (any-hover: hover), @media not (pointer: fine));
@layer utilities {
@tailwind utilities;
}
`)
let compiled = build(['cant-hover:focus:underline'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
:is(.cant-hover\\:focus\\:underline:not(:hover), .cant-hover\\:focus\\:underline:not(:active)):focus {
text-decoration-line: underline;
}
@media not (any-hover: hover) {
.cant-hover\\:focus\\:underline:focus {
text-decoration-line: underline;
}
}
@media not (pointer: fine) {
.cant-hover\\:focus\\:underline:focus {
text-decoration-line: underline;
}
}
}"
`)
})
})
describe('body with @slot syntax', () => {
@ -2752,6 +2783,12 @@ describe('@variant', () => {
),
).toMatchInlineSnapshot(`
"@layer utilities {
@media not foo {
.not-foo\\:flex {
display: flex;
}
}
@media foo {
.foo\\:flex {
display: flex;

View File

@ -22,6 +22,7 @@ import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme, ThemeOptions } from './theme'
import { segment } from './utils/segment'
import { compoundsForSelectors } from './variants'
export type Config = UserConfig
const IS_VALID_PREFIX = /^[a-z]+$/
@ -164,10 +165,39 @@ async function parseCss(
let selectors = segment(selector.slice(1, -1), ',')
let atRuleSelectors: string[] = []
let styleRuleSelectors: string[] = []
for (let selector of selectors) {
selector = selector.trim()
if (selector[0] === '@') {
atRuleSelectors.push(selector)
} else {
styleRuleSelectors.push(selector)
}
}
customVariants.push((designSystem) => {
designSystem.variants.static(name, (r) => {
r.nodes = selectors.map((selector) => rule(selector, r.nodes))
})
designSystem.variants.static(
name,
(r) => {
let nodes: AstNode[] = []
if (styleRuleSelectors.length > 0) {
nodes.push(rule(styleRuleSelectors.join(', '), r.nodes))
}
for (let selector of atRuleSelectors) {
nodes.push(rule(selector, r.nodes))
}
r.nodes = nodes
},
{
compounds: compoundsForSelectors([...styleRuleSelectors, ...atRuleSelectors]),
},
)
})
return

View File

@ -0,0 +1,56 @@
import { bench } from 'vitest'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
function loadDesignSystem() {
let theme = new Theme()
theme.add('--spacing-0_5', '0.125rem')
theme.add('--spacing-1', '0.25rem')
theme.add('--spacing-3', '0.75rem')
theme.add('--spacing-4', '1rem')
theme.add('--width-4', '1rem')
theme.add('--colors-red-500', 'red')
theme.add('--colors-blue-500', 'blue')
theme.add('--breakpoint-sm', '640px')
theme.add('--font-size-xs', '0.75rem')
theme.add('--font-size-xs--line-height', '1rem')
theme.add('--perspective-dramatic', '100px')
theme.add('--perspective-normal', '500px')
theme.add('--opacity-background', '0.3')
return buildDesignSystem(theme)
}
let design = loadDesignSystem()
bench('getClassList', () => {
design.getClassList()
})
bench('getVariants', () => {
design.getVariants()
})
bench('getVariants -> selectors(…)', () => {
let variants = design.getVariants()
let group = variants.find((v) => v.name === 'group')!
// A selector-based variant
group.selectors({ value: 'hover' })
// A selector-based variant with a modifier
group.selectors({ value: 'hover', modifier: 'sidebar' })
// A nested, compound, selector-based variant
group.selectors({ value: 'group-hover' })
// This variant produced an at rule
group.selectors({ value: 'sm' })
// This variant does not exist
group.selectors({ value: 'md' })
})
bench('candidatesToCss', () => {
design.candidatesToCss(['underline', 'i-dont-exist', 'bg-[#fff]', 'bg-[#000]'])
})

View File

@ -72,14 +72,41 @@ test('getVariants compound', () => {
]
expect(list).toEqual([
['&:is(:where(.group):hover *)'],
['&:is(:where(.group\\/sidebar):hover *)'],
['&:is(:where(.group):is(:where(.group):hover *) *)'],
['@media (hover: hover) { &:is(:where(.group):hover *) }'],
['@media (hover: hover) { &:is(:where(.group\\/sidebar):hover *) }'],
['@media (hover: hover) { &:is(:where(.group):is(:where(.group):hover *) *) }'],
[],
[],
])
})
test('variant selectors are in the correct order', async () => {
let input = css`
@variant overactive {
&:hover {
@media (hover: hover) {
&:focus {
&:active {
@slot;
}
}
}
}
}
`
let design = await __unstable__loadDesignSystem(input)
let variants = design.getVariants()
let overactive = variants.find((v) => v.name === 'overactive')!
expect(overactive).toBeTruthy()
expect(overactive.selectors({})).toMatchInlineSnapshot(`
[
"@media (hover: hover) { &:hover { &:focus { &:active } } }",
]
`)
})
test('The variant `has-force` does not crash', () => {
let design = loadDesignSystem()
let variants = design.getVariants()
@ -350,3 +377,58 @@ test('Functional utilities from plugins are listed in hovers and completions', a
expect(classNames).not.toContain('custom-3-unknown')
})
test('Custom at-rule variants do not show up as a value under `group`', async () => {
let input = css`
@import 'tailwindcss/utilities';
@variant variant-1 (@media foo);
@variant variant-2 {
@media bar {
@slot;
}
}
@plugin "./plugin.js";
`
let design = await __unstable__loadDesignSystem(input, {
loadStylesheet: async (_, base) => ({
base,
content: '@tailwind utilities;',
}),
loadModule: async () => ({
base: '',
module: plugin(({ addVariant }) => {
addVariant('variant-3', '@media baz')
addVariant('variant-4', ['@media qux', '@media cat'])
}),
}),
})
let variants = design.getVariants()
let v1 = variants.find((v) => v.name === 'variant-1')!
let v2 = variants.find((v) => v.name === 'variant-2')!
let v3 = variants.find((v) => v.name === 'variant-3')!
let v4 = variants.find((v) => v.name === 'variant-4')!
let group = variants.find((v) => v.name === 'group')!
let not = variants.find((v) => v.name === 'not')!
// All the variants should exist
expect(v1).not.toBeUndefined()
expect(v2).not.toBeUndefined()
expect(v3).not.toBeUndefined()
expect(v4).not.toBeUndefined()
expect(group).not.toBeUndefined()
expect(not).not.toBeUndefined()
// Group should not have variant-1, variant-2, or variant-3
expect(group.values).not.toContain('variant-1')
expect(group.values).not.toContain('variant-2')
expect(group.values).not.toContain('variant-3')
expect(group.values).not.toContain('variant-4')
// Not should have variant-1, variant-2, or variant-3
expect(not.values).toContain('variant-1')
expect(not.values).toContain('variant-2')
expect(not.values).toContain('variant-3')
expect(not.values).toContain('variant-4')
})

View File

@ -1,4 +1,4 @@
import { decl, rule } from './ast'
import { rule, walkDepth } from './ast'
import { applyVariant } from './compile'
import type { DesignSystem } from './design-system'
@ -69,7 +69,7 @@ export function getVariants(design: DesignSystem) {
if (!variant) return []
// Apply the variant to a placeholder rule
let node = rule('.__placeholder__', [decl('color', 'red')])
let node = rule('.__placeholder__', [])
// If the rule produces no nodes it means the variant does not apply
if (applyVariant(node, variant, design.variants) === null) {
@ -79,11 +79,41 @@ export function getVariants(design: DesignSystem) {
// Now look at the selector(s) inside the rule
let selectors: string[] = []
for (let child of node.nodes) {
if (child.kind === 'rule') {
selectors.push(child.selector)
// Produce v3-style selector strings in the face of nested rules
// this is more visible for things like group-*, not-*, etc…
walkDepth(node.nodes, (node, { path }) => {
if (node.kind !== 'rule') return
if (node.nodes.length > 0) return
// Sort at-rules before style rules
path.sort((a, b) => {
// This won't actually happen, but it's here to make TypeScript happy
if (a.kind !== 'rule' || b.kind !== 'rule') return 0
let aIsAtRule = a.selector[0] === '@'
let bIsAtRule = b.selector[0] === '@'
if (aIsAtRule && !bIsAtRule) return -1
if (!aIsAtRule && bIsAtRule) return 1
return 0
})
// A list of the selectors / at rules encountered to get to this point
let group = path.flatMap((node) => {
if (node.kind !== 'rule') return []
return node.selector === '&' ? [] : [node.selector]
})
// Build a v3-style nested selector
let selector = ''
for (let i = group.length - 1; i >= 0; i--) {
selector = selector === '' ? group[i] : `${group[i]} { ${selector} }`
}
}
selectors.push(selector)
})
return selectors
}

View File

@ -1,5 +1,6 @@
import { expect, test } from 'vitest'
import { compileCss, run } from './test-utils/run'
import { Compounds, compoundsForSelectors } from './variants'
const css = String.raw
@ -1687,16 +1688,33 @@ test('not', async () => {
@slot;
}
}
@variant device-hocus {
@media (hover: hover) {
&:hover,
&:focus {
@slot;
}
}
}
@theme {
--breakpoint-sm: 640px;
}
@tailwind utilities;
`,
[
'not-[:checked]:flex',
'not-hocus:flex',
'not-device-hocus:flex',
'group-not-[:checked]:flex',
'group-not-[:checked]/parent-name:flex',
'group-not-checked:flex',
'group-not-hocus:flex',
// 'group-not-hover:flex',
// 'group-not-device-hocus:flex',
'group-not-hocus/parent-name:flex',
'peer-not-[:checked]:flex',
@ -1704,13 +1722,332 @@ test('not', async () => {
'peer-not-checked:flex',
'peer-not-hocus:flex',
'peer-not-hocus/sibling-name:flex',
// Not versions of built-in variants
'not-first:flex',
'not-last:flex',
'not-only:flex',
'not-odd:flex',
'not-even:flex',
'not-first-of-type:flex',
'not-last-of-type:flex',
'not-only-of-type:flex',
'not-visited:flex',
'not-target:flex',
'not-open:flex',
'not-default:flex',
'not-checked:flex',
'not-indeterminate:flex',
'not-placeholder-shown:flex',
'not-autofill:flex',
'not-optional:flex',
'not-required:flex',
'not-valid:flex',
'not-invalid:flex',
'not-in-range:flex',
'not-out-of-range:flex',
'not-read-only:flex',
'not-empty:flex',
'not-focus-within:flex',
'not-hover:flex',
'not-focus:flex',
'not-focus-visible:flex',
'not-active:flex',
'not-enabled:flex',
'not-disabled:flex',
'not-inert:flex',
'not-ltr:flex',
'not-rtl:flex',
'not-motion-safe:flex',
'not-motion-reduce:flex',
'not-dark:flex',
'not-print:flex',
'not-supports-grid:flex',
'not-has-checked:flex',
'not-aria-selected:flex',
'not-data-foo:flex',
'not-portrait:flex',
'not-landscape:flex',
'not-contrast-more:flex',
'not-contrast-less:flex',
'not-forced-colors:flex',
'not-nth-2:flex',
'not-sm:flex',
'not-min-sm:flex',
'not-min-[130px]:flex',
'not-max-sm:flex',
'not-max-[130px]:flex',
],
),
).toMatchInlineSnapshot(`
".not-hocus\\:flex:not(:hover, :focus) {
":root {
--breakpoint-sm: 640px;
}
.not-first\\:flex:not(:first-child) {
display: flex;
}
.not-last\\:flex:not(:last-child) {
display: flex;
}
.not-only\\:flex:not(:only-child) {
display: flex;
}
.not-odd\\:flex:not(:nth-child(odd)) {
display: flex;
}
.not-even\\:flex:not(:nth-child(2n)) {
display: flex;
}
.not-first-of-type\\:flex:not(:first-of-type) {
display: flex;
}
.not-last-of-type\\:flex:not(:last-of-type) {
display: flex;
}
.not-only-of-type\\:flex:not(:only-of-type) {
display: flex;
}
.not-visited\\:flex:not(:visited) {
display: flex;
}
.not-target\\:flex:not(:target) {
display: flex;
}
.not-open\\:flex:not([open], :popover-open) {
display: flex;
}
.not-default\\:flex:not(:default) {
display: flex;
}
.not-checked\\:flex:not(:checked) {
display: flex;
}
.not-indeterminate\\:flex:not(:indeterminate) {
display: flex;
}
.not-placeholder-shown\\:flex:not(:placeholder-shown) {
display: flex;
}
.not-autofill\\:flex:not(:autofill) {
display: flex;
}
.not-optional\\:flex:not(:optional) {
display: flex;
}
.not-required\\:flex:not(:required) {
display: flex;
}
.not-valid\\:flex:not(:valid) {
display: flex;
}
.not-invalid\\:flex:not(:invalid) {
display: flex;
}
.not-in-range\\:flex:not(:in-range) {
display: flex;
}
.not-out-of-range\\:flex:not(:out-of-range) {
display: flex;
}
.not-read-only\\:flex:not(:read-only) {
display: flex;
}
.not-empty\\:flex:not(:empty) {
display: flex;
}
.not-focus-within\\:flex:not(:focus-within) {
display: flex;
}
.not-hover\\:flex:not(:hover) {
display: flex;
}
@media not (hover: hover) {
.not-hover\\:flex {
display: flex;
}
}
.not-focus\\:flex:not(:focus) {
display: flex;
}
.not-focus-visible\\:flex:not(:focus-visible) {
display: flex;
}
.not-active\\:flex:not(:active) {
display: flex;
}
.not-enabled\\:flex:not(:enabled) {
display: flex;
}
.not-disabled\\:flex:not(:disabled) {
display: flex;
}
.not-inert\\:flex:not([inert], [inert] *) {
display: flex;
}
.not-has-checked\\:flex:not(:has(:checked)) {
display: flex;
}
.not-aria-selected\\:flex:not([aria-selected="true"]) {
display: flex;
}
.not-data-foo\\:flex:not([data-foo]) {
display: flex;
}
.not-nth-2\\:flex:not(:nth-child(2)) {
display: flex;
}
@supports not (grid: var(--tw)) {
.not-supports-grid\\:flex {
display: flex;
}
}
@media not (prefers-reduced-motion: no-preference) {
.not-motion-safe\\:flex {
display: flex;
}
}
@media not (prefers-reduced-motion: reduce) {
.not-motion-reduce\\:flex {
display: flex;
}
}
@media not (prefers-contrast: more) {
.not-contrast-more\\:flex {
display: flex;
}
}
@media not (prefers-contrast: less) {
.not-contrast-less\\:flex {
display: flex;
}
}
@media not (width < 640px) {
.not-max-sm\\:flex {
display: flex;
}
}
@media not (width < 130px) {
.not-max-\\[130px\\]\\:flex {
display: flex;
}
}
@media not (width >= 130px) {
.not-min-\\[130px\\]\\:flex {
display: flex;
}
}
@media not (width >= 640px) {
.not-min-sm\\:flex {
display: flex;
}
}
@media not (width >= 640px) {
.not-sm\\:flex {
display: flex;
}
}
@media not (orientation: portrait) {
.not-portrait\\:flex {
display: flex;
}
}
@media not (orientation: landscape) {
.not-landscape\\:flex {
display: flex;
}
}
.not-ltr\\:flex:not(:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *)) {
display: flex;
}
.not-rtl\\:flex:not(:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)) {
display: flex;
}
@media not (prefers-color-scheme: dark) {
.not-dark\\:flex {
display: flex;
}
}
@media not print {
.not-print\\:flex {
display: flex;
}
}
@media not (forced-colors: active) {
.not-forced-colors\\:flex {
display: flex;
}
}
.not-hocus\\:flex:not(:hover, :focus) {
display: flex;
}
.not-device-hocus\\:flex:not(:hover, :focus) {
display: flex;
}
@media not (hover: hover) {
.not-device-hocus\\:flex {
display: flex;
}
}
.not-\\[\\:checked\\]\\:flex:not(:checked) {
display: flex;
}
@ -1759,8 +2096,19 @@ test('not', async () => {
expect(
await compileCss(
css`
@variant custom-at-rule (@media foo);
@variant nested-selectors {
@variant nested-at-rules {
@media foo {
@media bar {
@slot;
}
}
}
@variant multiple-media-conditions {
@media foo, bar {
@slot;
}
}
@variant nested-style-rules {
&:hover {
&:focus {
@slot;
@ -1774,9 +2122,39 @@ test('not', async () => {
'not-[+img]:flex',
'not-[~img]:flex',
'not-[:checked]/foo:flex',
'not-[@media_print]:flex',
'not-custom-at-rule:flex',
'not-nested-selectors:flex',
'not-nested-at-rules:flex',
'not-nested-style-rules:flex',
'not-multiple-media-conditions:flex',
'not-starting:flex',
// The following built-in variants don't have not-* versions because
// there is no sensible negative version of them.
// These just don't make sense as not-*
'not-force',
'not-*',
// These contain pseudo-elements
'not-first-letter',
'not-first-line',
'not-marker',
'not-selection',
'not-file',
'not-placeholder',
'not-backdrop',
'not-before',
'not-after',
// This is not a conditional at rule
'not-starting:flex',
// TODO:
// 'not-group-[...]:flex',
// 'not-group-*:flex',
// 'not-peer-[...]:flex',
// 'not-peer-*:flex',
// 'not-max-*:flex',
// 'not-min-*:flex',
],
),
).toEqual('')
@ -2882,3 +3260,29 @@ test('variant order', async () => {
}"
`)
})
test.each([
// These are style rules
[['.foo'], Compounds.StyleRules],
[['&:is(:hover)'], Compounds.StyleRules],
// These are conditional at rules
[['@media foo'], Compounds.AtRules],
[['@supports foo'], Compounds.AtRules],
[['@container foo'], Compounds.AtRules],
// These are both
[['.foo', '@media foo'], Compounds.StyleRules | Compounds.AtRules],
// These are never compoundable because:
// - Pseudo-elements are not compoundable
// - Non-conditional at-rules are not compoundable
[['.foo::before'], Compounds.Never],
[['@starting-style'], Compounds.Never],
// The presence of a single non-compoundable selector makes the whole list non-compoundable
[['.foo', '@media foo', '.foo::before'], Compounds.Never],
[['.foo', '@media foo', '@starting-style'], Compounds.Never],
])('compoundsForSelectors: %s', (selectors, expected) => {
expect(compoundsForSelectors(selectors)).toBe(expected)
})

View File

@ -12,6 +12,12 @@ type VariantFn<T extends Variant['kind']> = (
type CompareFn = (a: Variant, z: Variant) => number
export const enum Compounds {
Never = 0,
AtRules = 1 << 0,
StyleRules = 1 << 1,
}
export class Variants {
public compareFns = new Map<number, CompareFn>()
public variants = new Map<
@ -20,7 +26,13 @@ export class Variants {
kind: Variant['kind']
order: number
applyFn: VariantFn<any>
compounds: boolean
// The kind of rules that are allowed in this compound variant
compoundsWith: Compounds
// The kind of rules that are generated by this variant
// Determines whether or not a compound variant can use this variant
compounds: Compounds
}
>()
@ -42,33 +54,66 @@ export class Variants {
static(
name: string,
applyFn: VariantFn<'static'>,
{ compounds, order }: { compounds?: boolean; order?: number } = {},
{ compounds, order }: { compounds?: Compounds; order?: number } = {},
) {
this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true, order })
this.set(name, {
kind: 'static',
applyFn,
compoundsWith: Compounds.Never,
compounds: compounds ?? Compounds.StyleRules,
order,
})
}
fromAst(name: string, ast: AstNode[]) {
this.static(name, (r) => {
let body = structuredClone(ast)
substituteAtSlot(body, r.nodes)
r.nodes = body
let selectors: string[] = []
walk(ast, (node) => {
if (node.kind !== 'rule') return
if (node.selector === '@slot') return
selectors.push(node.selector)
})
this.static(
name,
(r) => {
let body = structuredClone(ast)
substituteAtSlot(body, r.nodes)
r.nodes = body
},
{
compounds: compoundsForSelectors(selectors),
},
)
}
functional(
name: string,
applyFn: VariantFn<'functional'>,
{ compounds, order }: { compounds?: boolean; order?: number } = {},
{ compounds, order }: { compounds?: Compounds; order?: number } = {},
) {
this.set(name, { kind: 'functional', applyFn, compounds: compounds ?? true, order })
this.set(name, {
kind: 'functional',
applyFn,
compoundsWith: Compounds.Never,
compounds: compounds ?? Compounds.StyleRules,
order,
})
}
compound(
compoundWith(
name: string,
compoundsWith: Compounds,
applyFn: VariantFn<'compound'>,
{ compounds, order }: { compounds?: boolean; order?: number } = {},
{ compounds, order }: { compounds?: Compounds; order?: number } = {},
) {
this.set(name, { kind: 'compound', applyFn, compounds: compounds ?? true, order })
this.set(name, {
kind: 'compound',
applyFn,
compoundsWith,
compounds: compounds ?? Compounds.StyleRules,
order,
})
}
group(fn: () => void, compareFn?: CompareFn) {
@ -90,8 +135,39 @@ export class Variants {
return this.variants.get(name)?.kind!
}
compounds(name: string) {
return this.variants.get(name)?.compounds!
compoundsWith(parent: string, child: string | Variant) {
let parentInfo = this.variants.get(parent)
let childInfo =
typeof child === 'string'
? this.variants.get(child)
: child.kind === 'arbitrary'
? // This isn't strictly necessary but it'll allow us to bail quickly
// when parsing candidates
{ compounds: compoundsForSelectors([child.selector]) }
: this.variants.get(child.root)
// One of the variants don't exist
if (!parentInfo || !childInfo) return false
// The parent variant is not a compound variant
if (parentInfo.kind !== 'compound') return false
// The variant `parent` may _compound with_ `child` if `parent` supports the
// rules that `child` generates. We instead use static registration metadata
// about what `parent` and `child` support instead of trying to apply the
// variant at runtime to see if the rules are compatible.
// The `child` variant cannot compound *ever*
if (childInfo.compounds === Compounds.Never) return false
// The `parent` variant cannot compound *ever*
// This shouldn't ever happen because `kind` is `compound`
if (parentInfo.compoundsWith === Compounds.Never) return false
// Any rule that `child` may generate must be supported by `parent`
if ((parentInfo.compoundsWith & childInfo.compounds) === 0) return false
return true
}
suggest(name: string, suggestions: () => string[]) {
@ -154,8 +230,15 @@ export class Variants {
kind,
applyFn,
compounds,
compoundsWith,
order,
}: { kind: T; applyFn: VariantFn<T>; compounds: boolean; order?: number },
}: {
kind: T
applyFn: VariantFn<T>
compoundsWith: Compounds
compounds: Compounds
order?: number
},
) {
let existing = this.variants.get(name)
if (existing) {
@ -169,6 +252,7 @@ export class Variants {
kind,
applyFn,
order,
compoundsWith,
compounds,
})
}
@ -179,6 +263,35 @@ export class Variants {
}
}
export function compoundsForSelectors(selectors: string[]) {
let compounds = Compounds.Never
for (let sel of selectors) {
if (sel[0] === '@') {
// Non-conditional at-rules are present so we can't compound
if (
!sel.startsWith('@media') &&
!sel.startsWith('@supports') &&
!sel.startsWith('@container')
) {
return Compounds.Never
}
compounds |= Compounds.AtRules
continue
}
// Pseudo-elements are present so we can't compound
if (sel.includes('::')) {
return Compounds.Never
}
compounds |= Compounds.StyleRules
}
return compounds
}
export function createVariants(theme: Theme): Variants {
// In the future we may want to support returning a rule here if some complex
// variant requires it. For now pure mutation is sufficient and will be the
@ -191,8 +304,10 @@ export function createVariants(theme: Theme): Variants {
function staticVariant(
name: string,
selectors: string[],
{ compounds }: { compounds?: boolean } = {},
{ compounds }: { compounds?: Compounds } = {},
) {
compounds = compounds ?? compoundsForSelectors(selectors)
variants.static(
name,
(r) => {
@ -202,92 +317,196 @@ export function createVariants(theme: Theme): Variants {
)
}
variants.static('force', () => {}, { compounds: false })
staticVariant('*', [':where(& > *)'], { compounds: false })
variants.static('force', () => {}, { compounds: Compounds.Never })
staticVariant('*', [':where(& > *)'], { compounds: Compounds.Never })
variants.compound('not', (ruleNode, variant) => {
function negateConditions(ruleName: string, conditions: string[]) {
return conditions.map((condition) => {
condition = condition.trim()
let parts = segment(condition, ' ')
// @media not {query}
// @supports not {query}
// @container not {query}
if (parts[0] === 'not') {
return parts.slice(1).join(' ')
}
if (ruleName === 'container') {
// @container {query}
if (parts[0][0] === '(') {
return `not ${condition}`
}
// @container {name} not {query}
else if (parts[1] === 'not') {
return `${parts[0]} ${parts.slice(2).join(' ')}`
}
// @container {name} {query}
else {
return `${parts[0]} not ${parts.slice(1).join(' ')}`
}
}
return `not ${condition}`
})
}
function negateSelector(selector: string) {
if (selector[0] === '@') {
let name = selector.slice(1, selector.indexOf(' '))
let params = selector.slice(selector.indexOf(' ') + 1)
if (name === 'media' || name === 'supports' || name === 'container') {
let conditions = segment(params, ',')
// We don't support things like `@media screen, print` because
// the negation would be `@media not screen and print` and we don't
// want to deal with that complexity.
if (conditions.length > 1) return null
conditions = negateConditions(name, conditions)
return `@${name} ${conditions.join(', ')}`
}
return null
}
if (selector.includes('::')) return null
let selectors = segment(selector, ',').map((sel) => {
// Remove unnecessary wrapping &:is(…) to reduce the selector size
if (sel.startsWith('&:is(') && sel.endsWith(')')) {
sel = sel.slice(5, -1)
}
// Replace `&` in target variant with `*`, so variants like `&:hover`
// become `&:not(*:hover)`. The `*` will often be optimized away.
sel = sel.replaceAll('&', '*')
return sel
})
return `&:not(${selectors.join(', ')})`
}
variants.compoundWith('not', Compounds.StyleRules | Compounds.AtRules, (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
if (variant.modifier) return null
let didApply = false
walk([ruleNode], (node) => {
walk([ruleNode], (node, { path }) => {
if (node.kind !== 'rule') return WalkAction.Continue
if (node.nodes.length > 0) return WalkAction.Continue
// Skip past at-rules, and continue traversing the children of the at-rule
if (node.selector[0] === '@') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
let atRules: Rule[] = []
let styleRules: Rule[] = []
// Throw out any candidates with variants using nested selectors
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
for (let parent of path) {
if (parent.kind !== 'rule') continue
if (parent.selector[0] === '@') {
atRules.push(parent)
} else {
styleRules.push(parent)
}
}
// Replace `&` in target variant with `*`, so variants like `&:hover`
// become `&:not(*:hover)`. The `*` will often be optimized away.
node.selector = `&:not(${node.selector.replaceAll('&', '*')})`
if (atRules.length > 1) return WalkAction.Stop
if (styleRules.length > 1) return WalkAction.Stop
let rules: Rule[] = []
for (let styleRule of styleRules) {
let selector = negateSelector(styleRule.selector)
if (!selector) {
didApply = false
return WalkAction.Stop
}
rules.push(rule(selector, []))
}
for (let atRule of atRules) {
let selector = negateSelector(atRule.selector)
if (!selector) {
didApply = false
return WalkAction.Stop
}
rules.push(rule(selector, []))
}
ruleNode.selector = '&'
ruleNode.nodes = rules
// Track that the variant was actually applied
didApply = true
return WalkAction.Skip
})
// TODO: Tweak group, peer, has to ignore intermediate `&` selectors (maybe?)
if (ruleNode.selector === '&' && ruleNode.nodes.length === 1) {
ruleNode.selector = (ruleNode.nodes[0] as Rule).selector
ruleNode.nodes = (ruleNode.nodes[0] as Rule).nodes
}
// If the node wasn't modified, this variant is not compatible with
// `not-*` so discard the candidate.
if (!didApply) {
return null
}
if (!didApply) return null
})
variants.compound('group', (ruleNode, variant) => {
variants.suggest('not', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.compoundsWith('not', name)
})
})
variants.compoundWith('group', Compounds.StyleRules, (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
// Name the group by appending the modifier to `group` class itself if
// present.
let groupSelector = variant.modifier
let variantSelector = variant.modifier
? `:where(.group\\/${variant.modifier.value})`
: ':where(.group)'
let didApply = false
walk([ruleNode], (node) => {
walk([ruleNode], (node, { path }) => {
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
// Throw out any candidates with variants using nested selectors
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
if (parent.kind !== 'rule') continue
if (parent.selector[0] === '@') continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
didApply = false
return WalkAction.Stop
}
// 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)
let selector = node.selector.replaceAll('&', variantSelector)
// 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})`
if (segment(selector, ',').length > 1) {
selector = `:is(${selector})`
}
node.selector = `&:is(${node.selector} *)`
node.selector = `&:is(${selector} *)`
// Track that the variant was actually applied
didApply = true
@ -295,60 +514,55 @@ export function createVariants(theme: Theme): Variants {
// If the node wasn't modified, this variant is not compatible with
// `group-*` so discard the candidate.
if (!didApply) {
return null
}
if (!didApply) return null
})
variants.suggest('group', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.get(name)?.compounds ?? false
return variants.compoundsWith('group', name)
})
})
variants.compound('peer', (ruleNode, variant) => {
variants.compoundWith('peer', Compounds.StyleRules, (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
// Name the peer by appending the modifier to `peer` class itself if
// present.
let peerSelector = variant.modifier
let variantSelector = variant.modifier
? `:where(.peer\\/${variant.modifier.value})`
: ':where(.peer)'
let didApply = false
walk([ruleNode], (node) => {
walk([ruleNode], (node, { path }) => {
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
// Throw out any candidates with variants using nested selectors
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
if (parent.kind !== 'rule') continue
if (parent.selector[0] === '@') continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
didApply = false
return WalkAction.Stop
}
// 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)
let selector = node.selector.replaceAll('&', variantSelector)
// 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})`
if (segment(selector, ',').length > 1) {
selector = `:is(${selector})`
}
node.selector = `&:is(${node.selector} ~ *)`
node.selector = `&:is(${selector} ~ *)`
// Track that the variant was actually applied
didApply = true
@ -356,27 +570,25 @@ export function createVariants(theme: Theme): Variants {
// If the node wasn't modified, this variant is not compatible with
// `peer-*` so discard the candidate.
if (!didApply) {
return null
}
if (!didApply) return null
})
variants.suggest('peer', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.get(name)?.compounds ?? false
return variants.compoundsWith('peer', name)
})
})
staticVariant('first-letter', ['&::first-letter'], { compounds: false })
staticVariant('first-line', ['&::first-line'], { compounds: false })
staticVariant('first-letter', ['&::first-letter'])
staticVariant('first-line', ['&::first-line'])
// TODO: Remove alpha vars or no?
staticVariant('marker', ['& *::marker', '&::marker'], { compounds: false })
staticVariant('marker', ['& *::marker', '&::marker'])
staticVariant('selection', ['& *::selection', '&::selection'], { compounds: false })
staticVariant('file', ['&::file-selector-button'], { compounds: false })
staticVariant('placeholder', ['&::placeholder'], { compounds: false })
staticVariant('backdrop', ['&::backdrop'], { compounds: false })
staticVariant('selection', ['& *::selection', '&::selection'])
staticVariant('file', ['&::file-selector-button'])
staticVariant('placeholder', ['&::placeholder'])
staticVariant('backdrop', ['&::backdrop'])
{
function contentProperties() {
@ -399,7 +611,7 @@ export function createVariants(theme: Theme): Variants {
]),
]
},
{ compounds: false },
{ compounds: Compounds.Never },
)
variants.static(
@ -409,7 +621,7 @@ export function createVariants(theme: Theme): Variants {
rule('&::after', [contentProperties(), decl('content', 'var(--tw-content)'), ...v.nodes]),
]
},
{ compounds: false },
{ compounds: Compounds.Never },
)
}
@ -458,27 +670,24 @@ export function createVariants(theme: Theme): Variants {
staticVariant('inert', ['&:is([inert], [inert] *)'])
variants.compound('has', (ruleNode, variant) => {
variants.compoundWith('has', Compounds.StyleRules, (ruleNode, variant) => {
if (variant.modifier) return null
let didApply = false
walk([ruleNode], (node) => {
walk([ruleNode], (node, { path }) => {
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
// Throw out any candidates with variants using nested selectors
if (didApply) {
walk([node], (childNode) => {
if (childNode.kind !== 'rule' || childNode.selector[0] === '@') return WalkAction.Continue
// Throw out any candidates with variants using nested style rules
for (let parent of path.slice(0, -1)) {
if (parent.kind !== 'rule') continue
if (parent.selector[0] === '@') continue
didApply = false
return WalkAction.Stop
})
return didApply ? WalkAction.Skip : WalkAction.Stop
didApply = false
return WalkAction.Stop
}
// Replace `&` in target variant with `*`, so variants like `&:hover`
@ -491,14 +700,12 @@ export function createVariants(theme: Theme): Variants {
// If the node wasn't modified, this variant is not compatible with
// `has-*` so discard the candidate.
if (!didApply) {
return null
}
if (!didApply) return null
})
variants.suggest('has', () => {
return Array.from(variants.keys()).filter((name) => {
return variants.get(name)?.compounds ?? false
return variants.compoundsWith('has', name)
})
})
@ -609,16 +816,14 @@ export function createVariants(theme: Theme): Variants {
ruleNode.nodes = [rule(`@supports ${value}`, ruleNode.nodes)]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
staticVariant('motion-safe', ['@media (prefers-reduced-motion: no-preference)'], {
compounds: false,
})
staticVariant('motion-reduce', ['@media (prefers-reduced-motion: reduce)'], { compounds: false })
staticVariant('motion-safe', ['@media (prefers-reduced-motion: no-preference)'])
staticVariant('motion-reduce', ['@media (prefers-reduced-motion: reduce)'])
staticVariant('contrast-more', ['@media (prefers-contrast: more)'], { compounds: false })
staticVariant('contrast-less', ['@media (prefers-contrast: less)'], { compounds: false })
staticVariant('contrast-more', ['@media (prefers-contrast: more)'])
staticVariant('contrast-less', ['@media (prefers-contrast: less)'])
{
// Helper to compare variants by their resolved values, this is used by the
@ -730,7 +935,7 @@ export function createVariants(theme: Theme): Variants {
ruleNode.nodes = [rule(`@media (width < ${value})`, ruleNode.nodes)]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
},
(a, z) => compareBreakpoints(a, z, 'desc', resolvedBreakpoints),
@ -752,7 +957,7 @@ export function createVariants(theme: Theme): Variants {
(ruleNode) => {
ruleNode.nodes = [rule(`@media (width >= ${value})`, ruleNode.nodes)]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
}
@ -765,7 +970,7 @@ export function createVariants(theme: Theme): Variants {
ruleNode.nodes = [rule(`@media (width >= ${value})`, ruleNode.nodes)]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
},
(a, z) => compareBreakpoints(a, z, 'asc', resolvedBreakpoints),
@ -823,7 +1028,7 @@ export function createVariants(theme: Theme): Variants {
),
]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
},
(a, z) => compareBreakpoints(a, z, 'desc', resolvedWidths),
@ -851,7 +1056,7 @@ export function createVariants(theme: Theme): Variants {
),
]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
variants.functional(
'@min',
@ -868,7 +1073,7 @@ export function createVariants(theme: Theme): Variants {
),
]
},
{ compounds: false },
{ compounds: Compounds.AtRules },
)
},
(a, z) => compareBreakpoints(a, z, 'asc', resolvedWidths),
@ -881,19 +1086,19 @@ export function createVariants(theme: Theme): Variants {
}
}
staticVariant('portrait', ['@media (orientation: portrait)'], { compounds: false })
staticVariant('landscape', ['@media (orientation: landscape)'], { compounds: false })
staticVariant('portrait', ['@media (orientation: portrait)'])
staticVariant('landscape', ['@media (orientation: landscape)'])
staticVariant('ltr', ['&:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *)'])
staticVariant('rtl', ['&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *)'])
staticVariant('dark', ['@media (prefers-color-scheme: dark)'], { compounds: false })
staticVariant('dark', ['@media (prefers-color-scheme: dark)'])
staticVariant('starting', ['@starting-style'], { compounds: false })
staticVariant('starting', ['@starting-style'])
staticVariant('print', ['@media print'], { compounds: false })
staticVariant('print', ['@media print'])
staticVariant('forced-colors', ['@media (forced-colors: active)'], { compounds: false })
staticVariant('forced-colors', ['@media (forced-colors: active)'])
return variants
}