Add support for custom variants via CSS (#13992)

* 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 <jordan@cryptica.me>
This commit is contained in:
Robin Malfait 2024-07-16 22:23:23 +02:00 committed by GitHub
parent 54474086c8
commit 5ebd5896d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 796 additions and 42 deletions

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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<typeof parseVariant>[]
}
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
}

View File

@ -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;
}
}"
`)
})
})
})

View File

@ -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

View File

@ -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<T extends Variant['kind']> = (
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', () => {