diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2b3c2879f9..5b7d8ca4aa29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reintroduce `max-w-screen-*` utilities that read from the `--breakpoint` namespace as deprecated utilities ([#15013](https://github.com/tailwindlabs/tailwindcss/pull/15013)) - Support using CSS variables as arbitrary values without `var(…)` by using parentheses instead of square brackets (e.g. `bg-(--my-color)`) ([#15020](https://github.com/tailwindlabs/tailwindcss/pull/15020)) +- Add new `in-*` variant ([#15025](https://github.com/tailwindlabs/tailwindcss/pull/15025)) - _Upgrade (experimental)_: Migrate `[&>*]` to the `*` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022)) - _Upgrade (experimental)_: Migrate `[&_*]` to the `**` variant ([#15022](https://github.com/tailwindlabs/tailwindcss/pull/15022)) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts index f5a2c3d7e6d4..51d26a107eab 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.test.ts @@ -18,6 +18,11 @@ test.each([ ['[&:first-child]:flex', 'first:flex'], ['[&:not(:first-child)]:flex', 'not-first:flex'], + // in-* variants + ['[p_&]:flex', 'in-[p]:flex'], + ['[.foo_&]:flex', 'in-[.foo]:flex'], + ['[[data-visible]_&]:flex', 'in-data-visible:flex'], + // nth-child ['[&:nth-child(2)]:flex', 'nth-2:flex'], ['[&:not(:nth-child(2))]:flex', 'not-nth-2:flex'], diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts index 7bcd252eb797..3efa3f5ddd38 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/modernize-arbitrary-values.ts @@ -15,6 +15,19 @@ export function modernizeArbitraryValues( let changed = false for (let [variant, parent] of variants(clone)) { + // Forward modifier from the root to the compound variant + if ( + variant.kind === 'compound' && + (variant.root === 'has' || variant.root === 'not' || variant.root === 'in') + ) { + if (variant.modifier !== null) { + if ('modifier' in variant.variant) { + variant.variant.modifier = variant.modifier + variant.modifier = null + } + } + } + // Expecting an arbitrary variant if (variant.kind !== 'arbitrary') continue @@ -98,6 +111,61 @@ export function modernizeArbitraryValues( prefixedVariant = designSystem.parseVariant('**') } + // Handling a child/parent combinator. E.g.: `[[data-visible]_&]` => `in-data-visible` + if ( + // Only top-level, so `has-[&_[data-visible]]` is not supported + parent === null && + // [[data-visible]___&]:flex + // ^^^^^^^^^^^^^^ ^ ^ + ast.nodes[0].length === 3 && + ast.nodes[0].nodes[0].type === 'attribute' && + ast.nodes[0].nodes[1].type === 'combinator' && + ast.nodes[0].nodes[1].value === ' ' && + ast.nodes[0].nodes[2].type === 'nesting' && + ast.nodes[0].nodes[2].value === '&' + ) { + ast.nodes[0].nodes = [ast.nodes[0].nodes[0]] + changed = true + // When handling a compound like `in-[[data-visible]]`, we will first + // handle `[[data-visible]]`, then the parent `in-*` part. This means + // that we can convert `[[data-visible]_&]` to `in-[[data-visible]]`. + // + // Later this gets converted to `in-data-visible`. + Object.assign(variant, designSystem.parseVariant(`in-[${ast.toString()}]`)) + continue + } + + // `in-*` variant + if ( + // Only top-level, so `has-[p_&]` is not supported + parent === null && + // `[data-*]` and `[aria-*]` are handled separately + !( + ast.nodes[0].nodes[0].type === 'attribute' && + (ast.nodes[0].nodes[0].attribute.startsWith('data-') || + ast.nodes[0].nodes[0].attribute.startsWith('aria-')) + ) && + // [.foo___&]:flex + // ^^^^ ^ ^ + ast.nodes[0].nodes.at(-1)?.type === 'nesting' + ) { + let selector = ast.nodes[0] + let nodes = selector.nodes + + nodes.pop() // Remove the last node `&` + + // Remove trailing whitespace + let last = nodes.at(-1) + while (last?.type === 'combinator' && last.value === ' ') { + nodes.pop() + last = nodes.at(-1) + } + + changed = true + Object.assign(variant, designSystem.parseVariant(`in-[${selector.toString().trim()}]`)) + continue + } + // Filter out `&`. E.g.: `&[data-foo]` => `[data-foo]` let selectorNodes = ast.nodes[0].filter((node) => node.type !== 'nesting') diff --git a/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap b/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap index a7a60326cd9e..aa80fc0376e7 100644 --- a/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap +++ b/packages/tailwindcss/src/__snapshots__/intellisense.test.ts.snap @@ -7554,6 +7554,7 @@ exports[`getVariants 1`] = ` "enabled", "disabled", "inert", + "in", "has", "aria", "data", @@ -7622,6 +7623,7 @@ exports[`getVariants 1`] = ` "enabled", "disabled", "inert", + "in", "has", "aria", "data", @@ -7674,6 +7676,7 @@ exports[`getVariants 1`] = ` "enabled", "disabled", "inert", + "in", "has", "aria", "data", @@ -7972,6 +7975,59 @@ exports[`getVariants 1`] = ` "selectors": [Function], "values": [], }, + { + "hasDash": true, + "isArbitrary": true, + "name": "in", + "selectors": [Function], + "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", + "in", + "has", + "aria", + "data", + "nth", + "nth-last", + "nth-of-type", + "nth-last-of-type", + "ltr", + "rtl", + ], + }, { "hasDash": true, "isArbitrary": true, @@ -8013,6 +8069,7 @@ exports[`getVariants 1`] = ` "enabled", "disabled", "inert", + "in", "has", "aria", "data", diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 54cbe2dbf5d0..c6510fc26fdc 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -1693,6 +1693,23 @@ test('not', async () => { ).toEqual('') }) +test('in', async () => { + expect( + await run([ + 'in-[p]:flex', + 'in-[.group]:flex', + 'not-in-[p]:flex', + 'not-in-[.group]:flex', + 'in-data-visible:flex', + ]), + ).toMatchInlineSnapshot(` + ".not-in-\\[\\.group\\]\\:flex:not(:where(.group) *), .not-in-\\[p\\]\\:flex:not(:where(:is(p)) *), :where([data-visible]) .in-data-visible\\:flex, :where(.group) .in-\\[\\.group\\]\\:flex, :where(:is(p)) .in-\\[p\\]\\:flex { + display: flex; + }" + `) + expect(await run(['in-p:flex', 'in-foo-bar:flex'])).toEqual('') +}) + test('has', async () => { expect( await compileCss( diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 2a636f20831c..a88f3d50660f 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -711,6 +711,41 @@ export function createVariants(theme: Theme): Variants { staticVariant('inert', ['&:is([inert], [inert] *)']) + variants.compound('in', Compounds.StyleRules, (ruleNode, variant) => { + if (variant.modifier) return null + + let didApply = false + + walk([ruleNode], (node, { path }) => { + if (node.kind !== 'rule') 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 + + didApply = false + return WalkAction.Stop + } + + // Replace `&` in target variant with `*`, so variants like `&:hover` + // become `:where(*:hover) &`. The `*` will often be optimized away. + node.selector = `:where(${node.selector.replaceAll('&', '*')}) &` + + // Track that the variant was actually applied + didApply = true + }) + + // If the node wasn't modified, this variant is not compatible with + // `in-*` so discard the candidate. + if (!didApply) return null + }) + + variants.suggest('in', () => { + return Array.from(variants.keys()).filter((name) => { + return variants.compoundsWith('in', name) + }) + }) + variants.compound('has', Compounds.StyleRules, (ruleNode, variant) => { if (variant.modifier) return null