diff --git a/CHANGELOG.md b/CHANGELOG.md index fd358b6073f3..7d549f81f9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Re-use existing entries in the rule cache ([#9208](https://github.com/tailwindlabs/tailwindcss/pull/9208)) - Don't output duplicate utilities ([#9208](https://github.com/tailwindlabs/tailwindcss/pull/9208)) - Fix `fontFamily` config TypeScript types ([#9214](https://github.com/tailwindlabs/tailwindcss/pull/9214)) +- Handle variants on complex selector utilities ([#9262](https://github.com/tailwindlabs/tailwindcss/pull/9262)) ## [3.1.8] - 2022-08-05 diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js index 3c2e1f9db733..47d9ade7a4fc 100644 --- a/src/util/formatVariantSelector.js +++ b/src/util/formatVariantSelector.js @@ -29,6 +29,58 @@ export function formatVariantSelector(current, ...others) { return current } +/** + * Given any node in a selector this gets the "simple" selector it's a part of + * A simple selector is just a list of nodes without any combinators + * Technically :is(), :not(), :has(), etc… can have combinators but those are nested + * inside the relevant node and won't be picked up so they're fine to ignore + * + * @param {import('postcss-selector-parser').Node} node + * @returns {import('postcss-selector-parser').Node[]} + **/ +function simpleSelectorForNode(node) { + /** @type {import('postcss-selector-parser').Node[]} */ + let nodes = [] + + // Walk backwards until we hit a combinator node (or the start) + while (node.prev() && node.prev().type !== 'combinator') { + node = node.prev() + } + + // Now record all non-combinator nodes until we hit one (or the end) + while (node && node.type !== 'combinator') { + nodes.push(node) + node = node.next() + } + + return nodes +} + +/** + * Resorts the nodes in a selector to ensure they're in the correct order + * Tags go before classes, and pseudo classes go after classes + * + * @param {import('postcss-selector-parser').Selector} sel + * @returns {import('postcss-selector-parser').Selector} + **/ +function resortSelector(sel) { + sel.sort((a, b) => { + if (a.type === 'tag' && b.type === 'class') { + return -1 + } else if (a.type === 'class' && b.type === 'tag') { + return 1 + } else if (a.type === 'class' && b.type === 'pseudo' && b.value !== ':merge') { + return -1 + } else if (a.type === 'pseudo' && a.value !== ':merge' && b.type === 'class') { + return 1 + } + + return sel.index(a) - sel.index(b) + }) + + return sel +} + export function finalizeSelector( format, { @@ -88,12 +140,47 @@ export function finalizeSelector( } }) + let simpleStart = selectorParser.comment({ value: '/*__simple__*/' }) + let simpleEnd = selectorParser.comment({ value: '/*__simple__*/' }) + // We can safely replace the escaped base now, since the `base` section is // now in a normalized escaped value. ast.walkClasses((node) => { - if (node.value === base) { - node.replaceWith(...formatAst.nodes) + if (node.value !== base) { + return + } + + let parent = node.parent + let formatNodes = formatAst.nodes[0].nodes + + // Perf optimization: if the parent is a single class we can just replace it and be done + if (parent.nodes.length === 1) { + node.replaceWith(...formatNodes) + return } + + let simpleSelector = simpleSelectorForNode(node) + parent.insertBefore(simpleSelector[0], simpleStart) + parent.insertAfter(simpleSelector[simpleSelector.length - 1], simpleEnd) + + for (let child of formatNodes) { + parent.insertBefore(simpleSelector[0], child) + } + + node.remove() + + // Re-sort the simple selector to ensure it's in the correct order + simpleSelector = simpleSelectorForNode(simpleStart) + let firstNode = parent.index(simpleStart) + + parent.nodes.splice( + firstNode, + simpleSelector.length, + ...resortSelector(selectorParser.selector({ nodes: simpleSelector })).nodes + ) + + simpleStart.remove() + simpleEnd.remove() }) // This will make sure to move pseudo's to the correct spot (the end for diff --git a/tests/variants.test.js b/tests/variants.test.js index 06fc18456304..5d9131786920 100644 --- a/tests/variants.test.js +++ b/tests/variants.test.js @@ -855,3 +855,92 @@ test('hoverOnlyWhenSupported adds hover and pointer media features by default', `) }) }) + +test('multi-class utilities handle selector-mutating variants correctly', () => { + let config = { + content: [ + { + raw: html`
`, + }, + { + raw: html`
`, + }, + ], + corePlugins: { preflight: false }, + } + + let input = css` + @tailwind utilities; + @layer utilities { + .foo.bar.baz { + color: red; + } + .foo1 .bar1 .baz1 { + color: red; + } + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + .hover\:foo.bar.baz:hover { + color: red; + } + .hover\:bar.foo.baz:hover { + color: red; + } + .hover\:baz.foo.bar:hover { + color: red; + } + .hover\:foo1:hover .bar1 .baz1 { + color: red; + } + .foo1 .hover\:bar1:hover .baz1 { + color: red; + } + .foo1 .bar1 .hover\:baz1:hover { + color: red; + } + .group:hover .group-hover\:foo.bar.baz { + color: red; + } + .group:hover .group-hover\:bar.foo.baz { + color: red; + } + .group:hover .group-hover\:baz.foo.bar { + color: red; + } + .group:hover .group-hover\:foo1 .bar1 .baz1 { + color: red; + } + .foo1 .group:hover .group-hover\:bar1 .baz1 { + color: red; + } + .foo1 .bar1 .group:hover .group-hover\:baz1 { + color: red; + } + .peer:checked ~ .peer-checked\:foo.bar.baz { + color: red; + } + .peer:checked ~ .peer-checked\:bar.foo.baz { + color: red; + } + .peer:checked ~ .peer-checked\:baz.foo.bar { + color: red; + } + .peer:checked ~ .peer-checked\:foo1 .bar1 .baz1 { + color: red; + } + .foo1 .peer:checked ~ .peer-checked\:bar1 .baz1 { + color: red; + } + .foo1 .bar1 .peer:checked ~ .peer-checked\:baz1 { + color: red; + } + `) + }) +})