diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 66b37daf0..4074ff1be 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -79,16 +79,20 @@ export function walk( node: AstNode, utils: { parent: AstNode | null + path: AstNode[] replaceWith(newNode: AstNode | AstNode[]): void }, ) => void | WalkAction, parent: AstNode | null = null, + parentPath: AstNode[] = [], ) { for (let i = 0; i < ast.length; i++) { let node = ast[i] + let path = [node, ...parentPath] let status = visit(node, { parent, + 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 @@ -105,7 +109,7 @@ export function walk( if (status === WalkAction.Skip) continue if (node.kind === 'rule') { - walk(node.nodes, visit, node) + walk(node.nodes, visit, node, path) } } } diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index cac07451d..265630b8d 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -188,6 +188,14 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants): let result = applyVariant(isolatedNode, variant.variant, variants) if (result === null) return null + // Wrap in a single `&` rule so :not(…) can have the entire list of + // selectors / nodes from the "root" + // + // TODO: Rework this because it's not a great solution + if (isolatedNode.nodes.length > 1) { + isolatedNode.nodes = [rule('&', isolatedNode.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 @@ -217,6 +225,7 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants): // All other variants let result = applyFn(node, variant) + if (result === null) return null } diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 5fb6b4f22..9e4a7362d 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -705,23 +705,23 @@ test('group-[...]', () => { 'group-[&:hover]:group-[&_p]:flex', ]), ).toMatchInlineSnapshot(` - ".group-\\[\\&_p\\]\\:flex:is(:where(.group) p *) { + ".group-\\[\\&_p\\]\\:flex:is(:where(.group):is(* p) *) { display: flex; } - .group-\\[\\&\\:hover\\]\\:group-\\[\\&_p\\]\\:flex:is(:where(.group):hover *):is(:where(.group) p *) { + .group-\\[\\&\\:hover\\]\\:group-\\[\\&_p\\]\\:flex:is(:where(.group):hover *):is(:where(.group):is(* p) *) { display: flex; } - .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover { + .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group):is(* p) *):hover { display: flex; } - .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { + .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group):is(* p) *) { display: flex; } - .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { + .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group):is(* p) *):hover { display: flex; }" `) @@ -746,6 +746,13 @@ test('group-*', () => { @slot; } } + @variant nested-selectors { + &:hover { + &:focus { + @slot; + } + } + } @tailwind utilities; `, [ @@ -755,6 +762,8 @@ test('group-*', () => { 'group-hover:group-focus:flex', 'group-focus:group-hover:flex', + + 'group-nested-selectors:flex', ], ), ).toMatchInlineSnapshot(` @@ -774,7 +783,11 @@ test('group-*', () => { display: flex; } - .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { + .group-hocus\\:flex:is(:where(.group):is(:hover, :focus) *) { + display: flex; + } + + .group-nested-selectors\\:flex:is(:where(.group):focus:hover *) { display: flex; }" `) @@ -783,16 +796,9 @@ test('group-*', () => { compileCss( css` @variant custom-at-rule (@media foo); - @variant nested-selectors { - &:hover { - &:focus { - @slot; - } - } - } @tailwind utilities; `, - ['group-custom-at-rule:flex', 'group-nested-selectors:flex'], + ['group-custom-at-rule:flex'], ), ).toEqual('') }) @@ -1557,11 +1563,19 @@ test('not', () => { @slot; } } + @variant nested-selectors { + &:hover { + &:focus { + @slot; + } + } + } @tailwind utilities; `, [ 'not-[:checked]:flex', 'not-hocus:flex', + 'not-nested-selectors:flex', 'group-not-[:checked]:flex', 'group-not-[:checked]/parent-name:flex', @@ -1581,6 +1595,10 @@ test('not', () => { display: flex; } + .not-nested-selectors\\:flex:is(:not(:focus), :not(:hover)) { + display: flex; + } + .not-\\[\\:checked\\]\\:flex:not(:checked) { display: flex; } @@ -1630,21 +1648,9 @@ test('not', () => { compileCss( css` @variant custom-at-rule (@media foo); - @variant nested-selectors { - &:hover { - &:focus { - @slot; - } - } - } @tailwind utilities; `, - [ - 'not-[:checked]/foo:flex', - 'not-[@media_print]:flex', - 'not-custom-at-rule:flex', - 'not-nested-selectors:flex', - ], + ['not-[:checked]/foo:flex', 'not-[@media_print]:flex', 'not-custom-at-rule:flex'], ), ).toEqual('') }) @@ -2518,3 +2524,48 @@ test('variant order', () => { }" `) }) + +test('not selector inversion creation thing', () => { + let input = css` + @variant omg { + &:hover { + &:focus { + &:active { + @slot; + } + + &[data-whatever] { + @slot; + } + } + + &[data-foo] { + @slot; + } + } + + &:visited { + @slot; + } + } + @tailwind utilities; + ` + + expect(compileCss(input, ['omg:flex', 'not-omg:flex'])).toMatchInlineSnapshot(` + ".not-omg\\:flex:is(:not(:active), :not(:focus), :not(:hover)):is(:not([data-whatever]), :not(:focus), :not(:hover)):is(:not([data-foo]), :not(:hover)):not(:visited) { + display: flex; + } + + .omg\\:flex:hover:focus:active, .omg\\:flex:hover:focus[data-whatever] { + display: flex; + } + + .omg\\:flex:hover[data-foo] { + display: flex; + } + + .omg\\:flex:visited { + display: flex; + }" + `) +}) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 909cfef22..22a59495c 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -206,39 +206,41 @@ export function createVariants(theme: Theme): Variants { variants.compound('not', (ruleNode, variant) => { if (variant.modifier) return null - let didApply = false + let groups: string[] = [] - 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 + // 1. Walk the tree until we find `@slot` + if (node.nodes.length !== 0) return WalkAction.Continue - didApply = false - return WalkAction.Stop - }) + let selectors = [] - return didApply ? WalkAction.Skip : WalkAction.Stop + // 2. Collect the selectors of the parents + for (let ancestor of path) { + if (ancestor.kind !== 'rule') continue + + // Skip past at-rules, and continue traversing the children of the at-rule + if (ancestor.selector[0] === '@') continue + + // Skip over the "root" node + if (ancestor.selector === '&') continue + + selectors.push(`:not(${ancestor.selector.replaceAll('&', '*')})`) } - // Replace `&` in target variant with `*`, so variants like `&:hover` - // become `&:not(*:hover)`. The `*` will often be optimized away. - node.selector = `&:not(${node.selector.replaceAll('&', '*')})` - - // Track that the variant was actually applied - didApply = true + groups.push(`:is(${selectors.join(', ')})`) }) - // If the node wasn't modified, this variant is not compatible with - // `not-*` so discard the candidate. - if (!didApply) { + if (groups.length === 0) { return null } + + ruleNode.selector = `&:is(${groups.join('')})` + ruleNode.nodes = [] }) variants.compound('group', (ruleNode, variant) => { @@ -248,50 +250,52 @@ export function createVariants(theme: Theme): Variants { ? `:where(.group\\/${variant.modifier.value})` : ':where(.group)' - let didApply = false + let groups: string[] = [] - 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 + // 1. Walk the tree until we find `@slot` + if (node.nodes.length !== 0) return WalkAction.Continue - didApply = false - return WalkAction.Stop - }) + let selectors = [] - return didApply ? WalkAction.Skip : WalkAction.Stop + // 2. Collect the selectors of the parents + for (let ancestor of path) { + if (ancestor.kind !== 'rule') continue + + // Skip past at-rules, and continue traversing the children of the at-rule + if (ancestor.selector[0] === '@') continue + + // Skip over the "root" node + if (ancestor.selector === '&') 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. + let selector = ancestor.selector.replaceAll('&', '*') + + // 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. + selectors.push(`:is(${selector})`) } - // 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) + if (selectors.length === 0) return - // 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})` - } - - node.selector = `&:is(${node.selector} *)` - - // Track that the variant was actually applied - didApply = true + groups.push(selectors.join('')) }) - // If the node wasn't modified, this variant is not compatible with - // `group-*` so discard the candidate. - if (!didApply) { + if (groups.length === 0) { return null } + + ruleNode.selector = `&:is(${groupSelector}:is(${groups.join(',')}) *)` + ruleNode.nodes = [] }) variants.suggest('group', () => {