From 0db9f9aef60bc4f0de2875718839762ebff1ba28 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 8 Nov 2021 22:01:57 +0100 Subject: [PATCH 1/3] enforce the order of some variants --- src/lib/generateRules.js | 18 ++++++++ tests/resolve-defaults-at-rules.test.js | 4 +- tests/variants.test.js | 60 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index cdb5294769f6..c414dc636fb7 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -277,9 +277,27 @@ function splitWithSeparator(input, separator) { return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g')) } +// A list of variants that are forced to the end. This is useful for variants +// that have pseudo elements which can't really be combined with other variant +// if they are in the incorrect order. +// +// 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. +let forcedVariantOrder = ['before', 'after'] + function* resolveMatches(candidate, context) { let separator = context.tailwindConfig.separator let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() + + // Sort the variants if we used a forced variant. + // Note: this will not sort the others, it would only sort the forced variants. + if (variants.some((variant) => forcedVariantOrder.includes(variant))) { + variants.sort((a, z) => forcedVariantOrder.indexOf(a) - forcedVariantOrder.indexOf(z)) + } + let important = false if (classCandidate.startsWith('!')) { diff --git a/tests/resolve-defaults-at-rules.test.js b/tests/resolve-defaults-at-rules.test.js index ae8b3e90ecb9..997507790f02 100644 --- a/tests/resolve-defaults-at-rules.test.js +++ b/tests/resolve-defaults-at-rules.test.js @@ -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); diff --git a/tests/variants.test.js b/tests/variants.test.js index f2d80f42843f..b241a11c74ef 100644 --- a/tests/variants.test.js +++ b/tests/variants.test.js @@ -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` +
+
+ `, + }, + ], + 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` +
+
+ `, + }, + ], + 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; + } + `) + }) +}) From 03f6a71564456d02cdf64a3dd440fb035dc9f71c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 8 Nov 2021 22:26:21 +0100 Subject: [PATCH 2/3] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1504a01bcd55..532c71f598f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From fe41e980cfd1bd44bbd985cda9201dd08d3f9781 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 12 Nov 2021 15:58:55 +0100 Subject: [PATCH 3/3] use better algorithm --- src/lib/generateRules.js | 18 ------ src/util/formatVariantSelector.js | 81 +++++++++++++++++++++++++++ tests/format-variant-selector.test.js | 23 ++++++++ tests/parallel-variants.test.js | 4 +- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index c414dc636fb7..cdb5294769f6 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -277,27 +277,9 @@ function splitWithSeparator(input, separator) { return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g')) } -// A list of variants that are forced to the end. This is useful for variants -// that have pseudo elements which can't really be combined with other variant -// if they are in the incorrect order. -// -// 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. -let forcedVariantOrder = ['before', 'after'] - function* resolveMatches(candidate, context) { let separator = context.tailwindConfig.separator let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse() - - // Sort the variants if we used a forced variant. - // Note: this will not sort the others, it would only sort the forced variants. - if (variants.some((variant) => forcedVariantOrder.includes(variant))) { - variants.sort((a, z) => forcedVariantOrder.indexOf(a) - forcedVariantOrder.indexOf(z)) - } - let important = false if (classCandidate.startsWith('!')) { diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js index d24471cc2eb8..06d0f489b0d7 100644 --- a/src/util/formatVariantSelector.js +++ b/src/util/formatVariantSelector.js @@ -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 diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js index a32ae502501e..94e86ddfcab7 100644 --- a/tests/format-variant-selector.test.js +++ b/tests/format-variant-selector.test.js @@ -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')) + }) +}) diff --git a/tests/parallel-variants.test.js b/tests/parallel-variants.test.js index e92bee89bb4a..5d58ea347c74 100644 --- a/tests/parallel-variants.test.js +++ b/tests/parallel-variants.test.js @@ -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 { @@ -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; } `)