Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enforce the order of some variants #6018

Merged
merged 3 commits into from Nov 12, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
}
`)
})
})