Skip to content

Commit

Permalink
Enforce the order of pseudo elements (#6018)
Browse files Browse the repository at this point in the history
* enforce the order of some variants

* update changelog

* use better algorithm
  • Loading branch information
RobinMalfait committed Nov 12, 2021
1 parent 4e21639 commit a3579bc
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 4 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Nothing yet!

### Fixed

- Enforce the order of some variants (like `before` and `after`) ([#6018](https://github.com/tailwindlabs/tailwindcss/pull/6018))

## [3.0.0-alpha.2] - 2021-11-08

### Changed
Expand Down
81 changes: 81 additions & 0 deletions src/util/formatVariantSelector.js
Expand Up @@ -74,11 +74,92 @@ export function finalizeSelector(format, { selector, candidate, context }) {
return p
})

// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
function collectPseudoElements(selector) {
let nodes = []

for (let node of selector.nodes) {
if (isPseudoElement(node)) {
nodes.push(node)
selector.removeChild(node)
}

if (node?.nodes) {
nodes.push(...collectPseudoElements(node))
}
}

return nodes
}

let pseudoElements = collectPseudoElements(selector)
if (pseudoElements.length > 0) {
selector.nodes.push(pseudoElements.sort(sortSelector))
}

return selector
})
}).processSync(selector)
}

// Note: As a rule, double colons (::) should be used instead of a single colon
// (:). This distinguishes pseudo-classes from pseudo-elements. However, since
// this distinction was not present in older versions of the W3C spec, most
// browsers support both syntaxes for the original pseudo-elements.
let pseudoElementsBC = [':before', ':after', ':first-line', ':first-letter']

// These pseudo-elements _can_ be combined with other pseudo selectors AND the order does matter.
let pseudoElementExceptions = ['::file-selector-button']

// This will make sure to move pseudo's to the correct spot (the end for
// pseudo elements) because otherwise the selector will never work
// anyway.
//
// E.g.:
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
//
// `::before:hover` doesn't work, which means that we can make it work
// for you by flipping the order.
function sortSelector(a, z) {
// Both nodes are non-pseudo's so we can safely ignore them and keep
// them in the same order.
if (a.type !== 'pseudo' && z.type !== 'pseudo') {
return 0
}

// If one of them is a combinator, we need to keep it in the same order
// because that means it will start a new "section" in the selector.
if ((a.type === 'combinator') ^ (z.type === 'combinator')) {
return 0
}

// One of the items is a pseudo and the other one isn't. Let's move
// the pseudo to the right.
if ((a.type === 'pseudo') ^ (z.type === 'pseudo')) {
return (a.type === 'pseudo') - (z.type === 'pseudo')
}

// Both are pseudo's, move the pseudo elements (except for
// ::file-selector-button) to the right.
return isPseudoElement(a) - isPseudoElement(z)
}

function isPseudoElement(node) {
if (node.type !== 'pseudo') return false
if (pseudoElementExceptions.includes(node.value)) return false

return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
}

function resolveFunctionArgument(haystack, needle, arg) {
let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
if (startIdx === -1) return null
Expand Down
23 changes: 23 additions & 0 deletions tests/format-variant-selector.test.js
Expand Up @@ -259,3 +259,26 @@ describe('real examples', () => {
})
})
})

describe('pseudo elements', () => {
it.each`
before | after
${'&::before'} | ${'&::before'}
${'&::before:hover'} | ${'&:hover::before'}
${'&:before:hover'} | ${'&:hover:before'}
${'&::file-selector-button:hover'} | ${'&::file-selector-button:hover'}
${'&:hover::file-selector-button'} | ${'&:hover::file-selector-button'}
${'.parent:hover &'} | ${'.parent:hover &'}
${'.parent::before &'} | ${'.parent &::before'}
${'.parent::before &:hover'} | ${'.parent &:hover::before'}
${':where(&::before) :is(h1, h2, h3, h4)'} | ${':where(&) :is(h1, h2, h3, h4)::before'}
${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} | ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'}
`('should translate "$before" into "$after"', ({ before, after }) => {
let result = finalizeSelector(formatVariantSelector('&', before), {
selector: '.a',
candidate: 'a',
})

expect(result).toEqual(after.replace('&', '.a'))
})
})
4 changes: 2 additions & 2 deletions tests/parallel-variants.test.js
Expand Up @@ -27,7 +27,7 @@ test('basic parallel variants', async () => {
.test\:font-medium *::test {
font-weight: 500;
}
.hover\:test\:font-black *::test:hover {
.hover\:test\:font-black *:hover::test {
font-weight: 900;
}
.test\:font-bold::test {
Expand All @@ -36,7 +36,7 @@ test('basic parallel variants', async () => {
.test\:font-medium::test {
font-weight: 500;
}
.hover\:test\:font-black::test:hover {
.hover\:test\:font-black:hover::test {
font-weight: 900;
}
`)
Expand Down
4 changes: 2 additions & 2 deletions tests/resolve-defaults-at-rules.test.js
Expand Up @@ -252,12 +252,12 @@ test('with multi-class pseudo-element and pseudo-class variants', async () => {
scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
/* --- */
.group:hover .group-hover\:hover\:before\:scale-x-110::before:hover {
.group:hover .group-hover\:hover\:before\:scale-x-110:hover::before {
content: var(--tw-content);
--tw-scale-x: 1.1;
transform: var(--tw-transform);
}
.peer:focus ~ .peer-focus\:focus\:after\:rotate-3::after:focus {
.peer:focus ~ .peer-focus\:focus\:after\:rotate-3:focus::after {
content: var(--tw-content);
--tw-rotate: 3deg;
transform: var(--tw-transform);
Expand Down
60 changes: 60 additions & 0 deletions tests/variants.test.js
Expand Up @@ -323,3 +323,63 @@ test('custom addVariant with nested media & format shorthand', () => {
`)
})
})

test('before and after variants are a bit special, and forced to the end', () => {
let config = {
content: [
{
raw: html`
<div class="before:hover:text-center"></div>
<div class="hover:before:text-center"></div>
`,
},
],
plugins: [],
}

return run('@tailwind components;@tailwind utilities', config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
.before\:hover\:text-center:hover::before {
content: var(--tw-content);
text-align: center;
}
.hover\:before\:text-center:hover::before {
content: var(--tw-content);
text-align: center;
}
`)
})
})

test('before and after variants are a bit special, and forced to the end (2)', () => {
let config = {
content: [
{
raw: html`
<div class="before:prose-headings:text-center"></div>
<div class="prose-headings:before:text-center"></div>
`,
},
],
plugins: [
function ({ addVariant }) {
addVariant('prose-headings', ':where(&) :is(h1, h2, h3, h4)')
},
],
}

return run('@tailwind components;@tailwind utilities', config).then((result) => {
return expect(result.css).toMatchFormattedCss(css`
:where(.before\:prose-headings\:text-center) :is(h1, h2, h3, h4)::before {
content: var(--tw-content);
text-align: center;
}
:where(.prose-headings\:before\:text-center) :is(h1, h2, h3, h4)::before {
content: var(--tw-content);
text-align: center;
}
`)
})
})

0 comments on commit a3579bc

Please sign in to comment.