From 5ebd5896d7bafb5aecd351c6527d9b8ad7b01c8d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 16 Jul 2024 22:23:23 +0200 Subject: [PATCH] Add support for custom variants via CSS (#13992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement `@variant` in CSS * implement `addVariant(name, objectTree)` * update changelog * ensure that `@variant` can only be used top-level * simplify Plugin API type * Use type instead of interface (for now) * Use more realistic variant for test * Allow custom properties to use `@slot` as content * Use "cannot" instead of "can not" * Remove `@variant` right away * Throw when `@variant` is missing a selector or body * Use "CSS-in-JS" terminology instead of "CSS Tree" * Rename tests * Mark some tests that seem wrong * Tweak comment, remove unnecessary return * Ensure group is usable with custom selector lists * Only apply extra `:is(…)` when there are multiple selectors * Tweak comment * Throw when @variant has both selector and body * Rework tests to use more realistic examples * Compound variants on an isolated copy This prevents traversals from leaking across variants * Handle selector lists for peer variants * Ignore at rules when compounding group and peer variants * Re-enable skipped tests * Update changelog --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Co-authored-by: Jordan Pittman --- CHANGELOG.md | 1 + packages/tailwindcss/src/ast.ts | 25 +- packages/tailwindcss/src/compile.ts | 32 +- packages/tailwindcss/src/design-system.ts | 18 +- packages/tailwindcss/src/index.test.ts | 558 +++++++++++++++++++++- packages/tailwindcss/src/index.ts | 113 ++++- packages/tailwindcss/src/variants.ts | 91 +++- 7 files changed, 796 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98ee385c..c9e1de745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982)) +- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992)) ## [4.0.0-alpha.17] - 2024-07-04 diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index bfdd63b69..66b37daf0 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -42,6 +42,26 @@ export function comment(value: string): Comment { } } +export type CssInJs = { [key: string]: string | CssInJs } + +export function objectToAst(obj: CssInJs): AstNode[] { + let ast: AstNode[] = [] + + for (let [name, value] of Object.entries(obj)) { + if (typeof value === 'string') { + if (!name.startsWith('--') && value === '@slot') { + ast.push(rule(name, [rule('@slot', [])])) + } else { + ast.push(decl(name, value)) + } + } else { + ast.push(rule(name, objectToAst(value))) + } + } + + return ast +} + export enum WalkAction { /** Continue walking, which is the default */ Continue, @@ -58,14 +78,17 @@ export function walk( visit: ( node: AstNode, utils: { + parent: AstNode | null replaceWith(newNode: AstNode | AstNode[]): void }, ) => void | WalkAction, + parent: AstNode | null = null, ) { for (let i = 0; i < ast.length; i++) { let node = ast[i] let status = visit(node, { + parent, replaceWith(newNode) { ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode])) // We want to visit the newly replaced node(s), which start at the @@ -82,7 +105,7 @@ export function walk( if (status === WalkAction.Skip) continue if (node.kind === 'rule') { - walk(node.nodes, visit) + walk(node.nodes, visit, node) } } } diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index c9f604919..cac07451d 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -1,4 +1,4 @@ -import { rule, type AstNode, type Rule } from './ast' +import { WalkAction, rule, walk, type AstNode, type Rule } from './ast' import { type Candidate, type Variant } from './candidate' import { type DesignSystem } from './design-system' import GLOBAL_PROPERTY_ORDER from './property-order' @@ -170,10 +170,25 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants): let { applyFn } = variants.get(variant.root)! if (variant.kind === 'compound') { - let result = applyVariant(node, variant.variant, variants) + // Some variants traverse the AST to mutate the nodes. E.g.: `group-*` wants + // to prefix every selector of the variant it's compounding with `.group`. + // + // E.g.: + // ``` + // group-hover:[&_p]:flex + // ``` + // + // Should only prefix the `group-hover` part with `.group`, and not the `&_p` part. + // + // To solve this, we provide an isolated placeholder node to the variant. + // The variant can now apply its logic to the isolated node without + // affecting the original node. + let isolatedNode = rule('@slot', []) + + let result = applyVariant(isolatedNode, variant.variant, variants) if (result === null) return null - for (let child of node.nodes) { + for (let child of isolatedNode.nodes) { // Only some variants wrap children in rules. For example, the `force` // variant is a noop on the AST. And the `has` variant modifies the // selector rather than the children. @@ -186,6 +201,17 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants): let result = applyFn(child as Rule, variant) if (result === null) return null } + + // Replace the placeholder node with the actual node + { + walk(isolatedNode.nodes, (child) => { + if (child.kind === 'rule' && child.nodes.length <= 0) { + child.nodes = node.nodes + return WalkAction.Skip + } + }) + node.nodes = isolatedNode.nodes + } return } diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 494b7bd53..bebd82a33 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -1,4 +1,4 @@ -import { rule, toCss } from './ast' +import { toCss } from './ast' import { parseCandidate, parseVariant } from './candidate' import { compileAstNodes, compileCandidates } from './compile' import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense' @@ -8,10 +8,6 @@ import { Utilities, createUtilities } from './utilities' import { DefaultMap } from './utils/default-map' import { Variants, createVariants } from './variants' -export type Plugin = (api: { - addVariant: (name: string, selector: string | string[]) => void -}) => void - export type DesignSystem = { theme: Theme utilities: Utilities @@ -29,7 +25,7 @@ export type DesignSystem = { getUsedVariants(): ReturnType[] } -export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignSystem { +export function buildDesignSystem(theme: Theme): DesignSystem { let utilities = createUtilities(theme) let variants = createVariants(theme) @@ -81,15 +77,5 @@ export function buildDesignSystem(theme: Theme, plugins: Plugin[] = []): DesignS }, } - for (let plugin of plugins) { - plugin({ - addVariant: (name: string, selectors: string | string[]) => { - variants.static(name, (r) => { - r.nodes = ([] as string[]).concat(selectors).map((selector) => rule(selector, r.nodes)) - }) - }, - }) - } - return designSystem } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 0b8d18483..a7ba81a7d 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1141,10 +1141,14 @@ describe('plugins', () => { } }, }, - ).build(['hocus:underline']) + ).build(['hocus:underline', 'group-hocus:flex']) expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { + .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + display: flex; + } + .hocus\\:underline:hover, .hocus\\:underline:focus { text-decoration-line: underline; } @@ -1181,4 +1185,556 @@ describe('plugins', () => { }" `) }) + + test('addVariant with object syntax and @slot', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: () => { + return ({ addVariant }) => { + addVariant('hocus', { + '&:hover': '@slot', + '&:focus': '@slot', + }) + } + }, + }, + ).build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('addVariant with object syntax, media, nesting and multiple @slot', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: () => { + return ({ addVariant }) => { + addVariant('hocus', { + '@media (hover: hover)': { + '&:hover': '@slot', + }, + '&:focus': '@slot', + }) + } + }, + }, + ).build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .group-hocus\\:flex:is(:where(.group):hover *) { + display: flex; + } + } + + .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + @media (hover: hover) { + .hocus\\:underline:hover { + text-decoration-line: underline; + } + } + + .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('@slot is preserved when used as a custom property value', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadPlugin: () => { + return ({ addVariant }) => { + addVariant('hocus', { + '&': { + '--custom-property': '@slot', + '&:hover': '@slot', + '&:focus': '@slot', + }, + }) + } + }, + }, + ).build(['hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .hocus\\:underline { + --custom-property: @slot; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) +}) + +describe('@variant', () => { + test('@variant must be top-level and cannot be nested', () => { + expect(() => + compileCss(css` + .foo { + @variant hocus (&:hover, &:focus); + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: \`@variant\` cannot be nested.]`) + }) + + test('@variant with no body must include a selector', () => { + expect(() => + compileCss(css` + @variant hocus; + `), + ).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]') + }) + + test('@variant with selector must include a body', () => { + expect(() => + compileCss(css` + @variant hocus { + } + `), + ).toThrowErrorMatchingInlineSnapshot('[Error: `@variant hocus` has no selector or body.]') + }) + + test('@variant cannot have both a selector and a body', () => { + expect(() => + compileCss(css` + @variant hocus (&:hover, &:focus) { + &:is(.potato) { + @slot; + } + } + `), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: \`@variant hocus\` cannot have both a selector and a body.]`, + ) + }) + + describe('body-less syntax', () => { + test('selector variant', () => { + let compiled = compile(css` + @variant hocus (&:hover, &:focus); + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:flex']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:flex:is(:where(.group):hover *), .group-hocus\\:flex:is(:where(.group):focus *) { + display: flex; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('at-rule variant', () => { + let compiled = compile(css` + @variant any-hover (@media (any-hover: hover)); + + @layer utilities { + @tailwind utilities; + } + `).build(['any-hover:hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .any-hover\\:hover\\:underline:hover { + text-decoration-line: underline; + } + } + }" + `) + }) + }) + + describe('body with @slot syntax', () => { + test('selector with @slot', () => { + let compiled = compile(css` + @variant selected { + &[data-selected] { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['selected:underline', 'group-selected:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-selected\\:underline:is(:where(.group)[data-selected] *) { + text-decoration-line: underline; + } + + .selected\\:underline[data-selected] { + text-decoration-line: underline; + } + }" + `) + }) + + test('grouped selectors with @slot', () => { + let compiled = compile(css` + @variant hocus { + &:hover, + &:focus { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:underline:is(:is(:where(.group):hover, :where(.group):focus) *) { + text-decoration-line: underline; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('multiple selectors with @slot', () => { + let compiled = compile(css` + @variant hocus { + &:hover { + @slot; + } + + &:focus { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .group-hocus\\:underline:is(:where(.group):hover *), .group-hocus\\:underline:is(:where(.group):focus *) { + text-decoration-line: underline; + } + + .hocus\\:underline:hover, .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('nested selector with @slot', () => { + let compiled = compile(css` + @variant custom-before { + & { + --has-before: 1; + &::before { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-before:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .custom-before\\:underline { + --has-before: 1; + } + + .custom-before\\:underline:before { + text-decoration-line: underline; + } + }" + `) + }) + + test('grouped nested selectors with @slot', () => { + let compiled = compile(css` + @variant custom-before { + & { + --has-before: 1; + &::before { + &:hover, + &:focus { + @slot; + } + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-before:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + .custom-before\\:underline { + --has-before: 1; + } + + .custom-before\\:underline:before:hover, .custom-before\\:underline:before:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('nested multiple selectors with @slot', () => { + let compiled = compile(css` + @variant hocus { + &:hover { + @media (hover: hover) { + @slot; + } + } + + &:focus { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .group-hocus\\:underline:is(:where(.group):hover *) { + text-decoration-line: underline; + } + } + + .group-hocus\\:underline:is(:where(.group):focus *) { + text-decoration-line: underline; + } + + @media (hover: hover) { + .hocus\\:underline:hover { + text-decoration-line: underline; + } + } + + .hocus\\:underline:focus { + text-decoration-line: underline; + } + }" + `) + }) + + test('selector nested under at-rule with @slot', () => { + let compiled = compile(css` + @variant hocus { + @media (hover: hover) { + &:hover { + @slot; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['hocus:underline', 'group-hocus:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (hover: hover) { + .group-hocus\\:underline:is(:where(.group):hover *) { + text-decoration-line: underline; + } + } + + @media (hover: hover) { + .hocus\\:underline:hover { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('at-rule with @slot', () => { + let compiled = compile(css` + @variant any-hover { + @media (any-hover: hover) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['any-hover:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .any-hover\\:underline { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('multiple at-rules with @slot', () => { + let compiled = compile(css` + @variant desktop { + @media (any-hover: hover) { + @slot; + } + + @media (pointer: fine) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['desktop:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (any-hover: hover) { + .desktop\\:underline { + text-decoration-line: underline; + } + } + + @media (pointer: fine) { + .desktop\\:underline { + text-decoration-line: underline; + } + } + }" + `) + }) + + test('nested at-rules with @slot', () => { + let compiled = compile(css` + @variant custom-variant { + @media (orientation: landscape) { + @media screen { + @slot; + } + + @media print { + display: none; + } + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-variant:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (orientation: landscape) { + @media screen { + .custom-variant\\:underline { + text-decoration-line: underline; + } + } + + @media print { + .custom-variant\\:underline { + display: none; + } + } + } + }" + `) + }) + + test('at-rule and selector with @slot', () => { + let compiled = compile(css` + @variant custom-dark { + @media (prefers-color-scheme: dark) { + @slot; + } + &:is(.dark *) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `).build(['custom-dark:underline']) + + expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` + "@layer utilities { + @media (prefers-color-scheme: dark) { + .custom-dark\\:underline { + text-decoration-line: underline; + } + } + + .custom-dark\\:underline:is(.dark *) { + text-decoration-line: underline; + } + }" + `) + }) + }) }) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index e15d9926b..9d2e90eda 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -1,9 +1,26 @@ import { version } from '../package.json' -import { WalkAction, comment, decl, rule, toCss, walk, type AstNode, type Rule } from './ast' +import { + WalkAction, + comment, + decl, + objectToAst, + rule, + toCss, + walk, + type AstNode, + type CssInJs, + type Rule, +} from './ast' import { compileCandidates } from './compile' import * as CSS from './css-parser' -import { buildDesignSystem, type Plugin } from './design-system' +import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme } from './theme' +import { segment } from './utils/segment' + +type PluginAPI = { + addVariant(name: string, variant: string | string[] | CssInJs): void +} +type Plugin = (api: PluginAPI) => void type CompileOptions = { loadPlugin?: (path: string) => Plugin @@ -34,10 +51,11 @@ export function compile( // Find all `@theme` declarations let theme = new Theme() let plugins: Plugin[] = [] + let customVariants: ((designSystem: DesignSystem) => void)[] = [] let firstThemeRule: Rule | null = null let keyframesRules: Rule[] = [] - walk(ast, (node, { replaceWith }) => { + walk(ast, (node, { parent, replaceWith }) => { if (node.kind !== 'rule') return // Collect paths from `@plugin` at-rules @@ -47,6 +65,62 @@ export function compile( return } + // Register custom variants from `@variant` at-rules + if (node.selector.startsWith('@variant ')) { + if (parent !== null) { + throw new Error('`@variant` cannot be nested.') + } + + // Remove `@variant` at-rule so it's not included in the compiled CSS + replaceWith([]) + + let [name, selector] = segment(node.selector.slice(9), ' ') + + if (node.nodes.length > 0 && selector) { + throw new Error(`\`@variant ${name}\` cannot have both a selector and a body.`) + } + + // Variants with a selector, but without a body, e.g.: `@variant hocus (&:hover, &:focus);` + if (node.nodes.length === 0) { + if (!selector) { + throw new Error(`\`@variant ${name}\` has no selector or body.`) + } + + let selectors = segment(selector.slice(1, -1), ',') + + customVariants.push((designSystem) => { + designSystem.variants.static(name, (r) => { + r.nodes = selectors.map((selector) => rule(selector, r.nodes)) + }) + }) + + return + } + + // Variants without a selector, but with a body: + // + // E.g.: + // + // ```css + // @variant hocus { + // &:hover { + // @slot; + // } + // + // &:focus { + // @slot; + // } + // } + // ``` + else { + customVariants.push((designSystem) => { + designSystem.variants.fromAst(name, node.nodes) + }) + + return + } + } + // Drop instances of `@media reference` // // We support `@import "tailwindcss/theme" reference` as a way to import an external theme file @@ -144,7 +218,38 @@ export function compile( firstThemeRule.nodes = nodes } - let designSystem = buildDesignSystem(theme, plugins) + let designSystem = buildDesignSystem(theme) + + for (let customVariant of customVariants) { + customVariant(designSystem) + } + + let api: PluginAPI = { + addVariant(name, variant) { + // Single selector + if (typeof variant === 'string') { + designSystem.variants.static(name, (r) => { + r.nodes = [rule(variant, r.nodes)] + }) + } + + // Multiple parallel selectors + else if (Array.isArray(variant)) { + designSystem.variants.static(name, (r) => { + r.nodes = variant.map((selector) => rule(selector, r.nodes)) + }) + } + + // CSS-in-JS object + else if (typeof variant === 'object') { + designSystem.variants.fromAst(name, objectToAst(variant)) + } + }, + } + + for (let plugin of plugins) { + plugin(api) + } let tailwindUtilitiesNode: Rule | null = null diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index a95315075..d5d51883c 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1,7 +1,8 @@ -import { decl, rule, type Rule } from './ast' +import { WalkAction, decl, rule, walk, type AstNode, type Rule } from './ast' import { type Variant } from './candidate' import type { Theme } from './theme' import { DefaultMap } from './utils/default-map' +import { segment } from './utils/segment' type VariantFn = ( rule: Rule, @@ -41,6 +42,34 @@ export class Variants { this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true }) } + fromAst(name: string, ast: AstNode[]) { + this.static(name, (r) => { + let body = structuredClone(ast) + + walk(body, (node, { replaceWith }) => { + // Replace `@slot` with rule nodes + if (node.kind === 'rule' && node.selector === '@slot') { + replaceWith(r.nodes) + } + + // Wrap `@keyframes` and `@property` in `@at-root` + else if ( + node.kind === 'rule' && + node.selector[0] === '@' && + (node.selector.startsWith('@keyframes ') || node.selector.startsWith('@property ')) + ) { + Object.assign(node, { + selector: '@at-root', + nodes: [rule(node.selector, node.nodes)], + }) + return WalkAction.Skip + } + }) + + r.nodes = body + }) + } + functional( name: string, applyFn: VariantFn<'functional'>, @@ -190,15 +219,29 @@ export function createVariants(theme: Theme): Variants { ? `:where(.group\\/${variant.modifier.value})` : ':where(.group)' - // 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. - ruleNode.selector = ruleNode.selector.replace('&', groupSelector) + walk([ruleNode], (node) => { + if (node.kind !== 'rule') return WalkAction.Continue - // Use `:where` to make sure the specificity of group variants isn't higher - // than the specificity of other variants. - ruleNode.selector = `&:is(${ruleNode.selector} *)` + // Skip past at-rules, and continue traversing the children of the at-rule + if (node.selector[0] === '@') return WalkAction.Continue + + // 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) + + // 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})` + } + + // Use `:where` to make sure the specificity of group variants isn't higher + // than the specificity of other variants. + node.selector = `&:is(${node.selector} *)` + }) }) variants.suggest('group', () => { @@ -214,15 +257,29 @@ export function createVariants(theme: Theme): Variants { ? `:where(.peer\\/${variant.modifier.value})` : ':where(.peer)' - // 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 `.peer` class the way we'd need to for these variants, so we - // need to replace it in the selector ourselves. - ruleNode.selector = ruleNode.selector.replace('&', peerSelector) + walk([ruleNode], (node) => { + if (node.kind !== 'rule') return WalkAction.Continue - // Use `:where` to make sure the specificity of peer variants isn't higher - // than the specificity of other variants. - ruleNode.selector = `&:is(${ruleNode.selector} ~ *)` + // Skip past at-rules, and continue traversing the children of the at-rule + if (node.selector[0] === '@') return WalkAction.Continue + + // 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) + + // 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})` + } + + // Use `:where` to make sure the specificity of group variants isn't higher + // than the specificity of other variants. + node.selector = `&:is(${node.selector} ~ *)` + }) }) variants.suggest('peer', () => {