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 }