Skip to content

Commit

Permalink
Handle variants on complex selectors (#9262)
Browse files Browse the repository at this point in the history
* Handle variants on complex selector utilities

* Update changelog
  • Loading branch information
thecrypticace committed Sep 6, 2022
1 parent 09f38d2 commit db50bbb
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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

Expand Down
91 changes: 89 additions & 2 deletions src/util/formatVariantSelector.js
Expand Up @@ -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,
{
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions tests/variants.test.js
Expand Up @@ -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`<div
class="hover:foo hover:bar hover:baz group-hover:foo group-hover:bar group-hover:baz peer-checked:foo peer-checked:bar peer-checked:baz"
></div>`,
},
{
raw: html`<div
class="hover:foo1 hover:bar1 hover:baz1 group-hover:foo1 group-hover:bar1 group-hover:baz1 peer-checked:foo1 peer-checked:bar1 peer-checked:baz1"
></div>`,
},
],
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;
}
`)
})
})

0 comments on commit db50bbb

Please sign in to comment.