diff --git a/CHANGELOG.md b/CHANGELOG.md
index aac350634..352bfa4fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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. `
`) ([#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))
diff --git a/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap b/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap
index 75232242c..a0727fe83 100644
--- a/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap
+++ b/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap
@@ -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,
diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts
index e9d1ce48c..2ab5a0046 100644
--- a/packages/tailwindcss/src/ast.ts
+++ b/packages/tailwindcss/src/ast.ts
@@ -87,25 +87,30 @@ export function walk(
parent: AstNode | null
replaceWith(newNode: AstNode | AstNode[]): void
context: Record
+ path: AstNode[]
},
) => void | WalkAction,
- parent: AstNode | null = null,
+ parentPath: AstNode[] = [],
context: Record = {},
) {
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
+ replaceWith(newNode: AstNode[]): void
+ },
+ ) => void,
+ parentPath: AstNode[] = [],
+ context: Record = {},
+) {
+ 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()
diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts
index 832f348e1..568326bb6 100644
--- a/packages/tailwindcss/src/candidate.test.ts
+++ b/packages/tailwindcss/src/candidate.test.ts
@@ -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",
},
diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts
index 9738c9b0f..91d6e1bb6 100644
--- a/packages/tailwindcss/src/candidate.ts
+++ b/packages/tailwindcss/src/candidate.ts
@@ -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),
}
}
}
diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts
index 1e7cd9e19..54fd8e00a 100644
--- a/packages/tailwindcss/src/compat/plugin-api.ts
+++ b/packages/tailwindcss/src/compat/plugin-api.ts
@@ -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
diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts
index c1ce01b9e..125535ba5 100644
--- a/packages/tailwindcss/src/index.test.ts
+++ b/packages/tailwindcss/src/index.test.ts
@@ -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;
diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts
index 331816d75..743e713f2 100644
--- a/packages/tailwindcss/src/index.ts
+++ b/packages/tailwindcss/src/index.ts
@@ -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
diff --git a/packages/tailwindcss/src/intellisense.bench.ts b/packages/tailwindcss/src/intellisense.bench.ts
new file mode 100644
index 000000000..c9a53749a
--- /dev/null
+++ b/packages/tailwindcss/src/intellisense.bench.ts
@@ -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]'])
+})
diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts
index 83785dfb0..fd5623613 100644
--- a/packages/tailwindcss/src/intellisense.test.ts
+++ b/packages/tailwindcss/src/intellisense.test.ts
@@ -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')
+})
diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts
index aaaedd3cf..815aa1cc0 100644
--- a/packages/tailwindcss/src/intellisense.ts
+++ b/packages/tailwindcss/src/intellisense.ts
@@ -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
}
diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts
index 97e358ef0..a6c8a4d7f 100644
--- a/packages/tailwindcss/src/variants.test.ts
+++ b/packages/tailwindcss/src/variants.test.ts
@@ -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)
+})
diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts
index 4999439e6..39c089eb4 100644
--- a/packages/tailwindcss/src/variants.ts
+++ b/packages/tailwindcss/src/variants.ts
@@ -12,6 +12,12 @@ type VariantFn = (
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()
public variants = new Map<
@@ -20,7 +26,13 @@ export class Variants {
kind: Variant['kind']
order: number
applyFn: VariantFn
- 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; compounds: boolean; order?: number },
+ }: {
+ kind: T
+ applyFn: VariantFn
+ 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
}