Skip to content

Commit a3579bc

Browse files
authored
Enforce the order of pseudo elements (#6018)
* enforce the order of some variants * update changelog * use better algorithm
1 parent 4e21639 commit a3579bc

6 files changed

+172
-4
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
Nothing yet!
1111

12+
### Fixed
13+
14+
- Enforce the order of some variants (like `before` and `after`) ([#6018](https://github.com/tailwindlabs/tailwindcss/pull/6018))
15+
1216
## [3.0.0-alpha.2] - 2021-11-08
1317

1418
### Changed

src/util/formatVariantSelector.js

+81
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,92 @@ export function finalizeSelector(format, { selector, candidate, context }) {
7474
return p
7575
})
7676

77+
// This will make sure to move pseudo's to the correct spot (the end for
78+
// pseudo elements) because otherwise the selector will never work
79+
// anyway.
80+
//
81+
// E.g.:
82+
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
83+
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
84+
//
85+
// `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
86+
function collectPseudoElements(selector) {
87+
let nodes = []
88+
89+
for (let node of selector.nodes) {
90+
if (isPseudoElement(node)) {
91+
nodes.push(node)
92+
selector.removeChild(node)
93+
}
94+
95+
if (node?.nodes) {
96+
nodes.push(...collectPseudoElements(node))
97+
}
98+
}
99+
100+
return nodes
101+
}
102+
103+
let pseudoElements = collectPseudoElements(selector)
104+
if (pseudoElements.length > 0) {
105+
selector.nodes.push(pseudoElements.sort(sortSelector))
106+
}
107+
77108
return selector
78109
})
79110
}).processSync(selector)
80111
}
81112

113+
// Note: As a rule, double colons (::) should be used instead of a single colon
114+
// (:). This distinguishes pseudo-classes from pseudo-elements. However, since
115+
// this distinction was not present in older versions of the W3C spec, most
116+
// browsers support both syntaxes for the original pseudo-elements.
117+
let pseudoElementsBC = [':before', ':after', ':first-line', ':first-letter']
118+
119+
// These pseudo-elements _can_ be combined with other pseudo selectors AND the order does matter.
120+
let pseudoElementExceptions = ['::file-selector-button']
121+
122+
// This will make sure to move pseudo's to the correct spot (the end for
123+
// pseudo elements) because otherwise the selector will never work
124+
// anyway.
125+
//
126+
// E.g.:
127+
// - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
128+
// - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
129+
//
130+
// `::before:hover` doesn't work, which means that we can make it work
131+
// for you by flipping the order.
132+
function sortSelector(a, z) {
133+
// Both nodes are non-pseudo's so we can safely ignore them and keep
134+
// them in the same order.
135+
if (a.type !== 'pseudo' && z.type !== 'pseudo') {
136+
return 0
137+
}
138+
139+
// If one of them is a combinator, we need to keep it in the same order
140+
// because that means it will start a new "section" in the selector.
141+
if ((a.type === 'combinator') ^ (z.type === 'combinator')) {
142+
return 0
143+
}
144+
145+
// One of the items is a pseudo and the other one isn't. Let's move
146+
// the pseudo to the right.
147+
if ((a.type === 'pseudo') ^ (z.type === 'pseudo')) {
148+
return (a.type === 'pseudo') - (z.type === 'pseudo')
149+
}
150+
151+
// Both are pseudo's, move the pseudo elements (except for
152+
// ::file-selector-button) to the right.
153+
return isPseudoElement(a) - isPseudoElement(z)
154+
}
155+
156+
function isPseudoElement(node) {
157+
if (node.type !== 'pseudo') return false
158+
if (pseudoElementExceptions.includes(node.value)) return false
159+
160+
return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
161+
}
162+
82163
function resolveFunctionArgument(haystack, needle, arg) {
83164
let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
84165
if (startIdx === -1) return null

tests/format-variant-selector.test.js

+23
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,26 @@ describe('real examples', () => {
259259
})
260260
})
261261
})
262+
263+
describe('pseudo elements', () => {
264+
it.each`
265+
before | after
266+
${'&::before'} | ${'&::before'}
267+
${'&::before:hover'} | ${'&:hover::before'}
268+
${'&:before:hover'} | ${'&:hover:before'}
269+
${'&::file-selector-button:hover'} | ${'&::file-selector-button:hover'}
270+
${'&:hover::file-selector-button'} | ${'&:hover::file-selector-button'}
271+
${'.parent:hover &'} | ${'.parent:hover &'}
272+
${'.parent::before &'} | ${'.parent &::before'}
273+
${'.parent::before &:hover'} | ${'.parent &:hover::before'}
274+
${':where(&::before) :is(h1, h2, h3, h4)'} | ${':where(&) :is(h1, h2, h3, h4)::before'}
275+
${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} | ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'}
276+
`('should translate "$before" into "$after"', ({ before, after }) => {
277+
let result = finalizeSelector(formatVariantSelector('&', before), {
278+
selector: '.a',
279+
candidate: 'a',
280+
})
281+
282+
expect(result).toEqual(after.replace('&', '.a'))
283+
})
284+
})

tests/parallel-variants.test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ test('basic parallel variants', async () => {
2727
.test\:font-medium *::test {
2828
font-weight: 500;
2929
}
30-
.hover\:test\:font-black *::test:hover {
30+
.hover\:test\:font-black *:hover::test {
3131
font-weight: 900;
3232
}
3333
.test\:font-bold::test {
@@ -36,7 +36,7 @@ test('basic parallel variants', async () => {
3636
.test\:font-medium::test {
3737
font-weight: 500;
3838
}
39-
.hover\:test\:font-black::test:hover {
39+
.hover\:test\:font-black:hover::test {
4040
font-weight: 900;
4141
}
4242
`)

tests/resolve-defaults-at-rules.test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -252,12 +252,12 @@ test('with multi-class pseudo-element and pseudo-class variants', async () => {
252252
scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
253253
}
254254
/* --- */
255-
.group:hover .group-hover\:hover\:before\:scale-x-110::before:hover {
255+
.group:hover .group-hover\:hover\:before\:scale-x-110:hover::before {
256256
content: var(--tw-content);
257257
--tw-scale-x: 1.1;
258258
transform: var(--tw-transform);
259259
}
260-
.peer:focus ~ .peer-focus\:focus\:after\:rotate-3::after:focus {
260+
.peer:focus ~ .peer-focus\:focus\:after\:rotate-3:focus::after {
261261
content: var(--tw-content);
262262
--tw-rotate: 3deg;
263263
transform: var(--tw-transform);

tests/variants.test.js

+60
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,63 @@ test('custom addVariant with nested media & format shorthand', () => {
323323
`)
324324
})
325325
})
326+
327+
test('before and after variants are a bit special, and forced to the end', () => {
328+
let config = {
329+
content: [
330+
{
331+
raw: html`
332+
<div class="before:hover:text-center"></div>
333+
<div class="hover:before:text-center"></div>
334+
`,
335+
},
336+
],
337+
plugins: [],
338+
}
339+
340+
return run('@tailwind components;@tailwind utilities', config).then((result) => {
341+
return expect(result.css).toMatchFormattedCss(css`
342+
.before\:hover\:text-center:hover::before {
343+
content: var(--tw-content);
344+
text-align: center;
345+
}
346+
347+
.hover\:before\:text-center:hover::before {
348+
content: var(--tw-content);
349+
text-align: center;
350+
}
351+
`)
352+
})
353+
})
354+
355+
test('before and after variants are a bit special, and forced to the end (2)', () => {
356+
let config = {
357+
content: [
358+
{
359+
raw: html`
360+
<div class="before:prose-headings:text-center"></div>
361+
<div class="prose-headings:before:text-center"></div>
362+
`,
363+
},
364+
],
365+
plugins: [
366+
function ({ addVariant }) {
367+
addVariant('prose-headings', ':where(&) :is(h1, h2, h3, h4)')
368+
},
369+
],
370+
}
371+
372+
return run('@tailwind components;@tailwind utilities', config).then((result) => {
373+
return expect(result.css).toMatchFormattedCss(css`
374+
:where(.before\:prose-headings\:text-center) :is(h1, h2, h3, h4)::before {
375+
content: var(--tw-content);
376+
text-align: center;
377+
}
378+
379+
:where(.prose-headings\:before\:text-center) :is(h1, h2, h3, h4)::before {
380+
content: var(--tw-content);
381+
text-align: center;
382+
}
383+
`)
384+
})
385+
})

0 commit comments

Comments
 (0)