diff --git a/CHANGELOG.md b/CHANGELOG.md index abd79d9395f9..cab1a1c33f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update list of length units ([#10100](https://github.com/tailwindlabs/tailwindcss/pull/10100)) - Fix not matching arbitrary properties when closely followed by square brackets ([#10212](https://github.com/tailwindlabs/tailwindcss/pull/10212)) - Allow direct nesting in `root` or `@layer` nodes ([#10229](https://github.com/tailwindlabs/tailwindcss/pull/10229)) +- Don't prefix classes in arbitrary variants ([#10214](https://github.com/tailwindlabs/tailwindcss/pull/10214)) ### Changed diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 636b9eaac0d4..c33276464e59 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -201,6 +201,7 @@ function applyVariant(variant, matches, context) { } if (context.variantMap.has(variant)) { + let isArbitraryVariant = isArbitraryValue(variant) let variantFunctionTuples = context.variantMap.get(variant).slice() let result = [] @@ -262,7 +263,10 @@ function applyVariant(variant, matches, context) { clone.append(wrapper) }, format(selectorFormat) { - collectedFormats.push(selectorFormat) + collectedFormats.push({ + format: selectorFormat, + isArbitraryVariant, + }) }, args, }) @@ -288,7 +292,10 @@ function applyVariant(variant, matches, context) { } if (typeof ruleWithVariant === 'string') { - collectedFormats.push(ruleWithVariant) + collectedFormats.push({ + format: ruleWithVariant, + isArbitraryVariant, + }) } if (ruleWithVariant === null) { @@ -329,7 +336,10 @@ function applyVariant(variant, matches, context) { // modified (by plugin): .foo .foo\\:markdown > p // rebuiltBase (internal): .foo\\:markdown > p // format: .foo & - collectedFormats.push(modified.replace(rebuiltBase, '&')) + collectedFormats.push({ + format: modified.replace(rebuiltBase, '&'), + isArbitraryVariant, + }) rule.selector = before }) } @@ -349,7 +359,6 @@ function applyVariant(variant, matches, context) { Object.assign(args, context.variantOptions.get(variant)) ), collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), - isArbitraryVariant: isArbitraryValue(variant), }, clone.nodes[0], ] @@ -733,48 +742,15 @@ function* resolveMatches(candidate, context, original = candidate) { } for (let match of matches) { - let isValid = true - match[1].raws.tailwind = { ...match[1].raws.tailwind, candidate } // Apply final format selector - if (match[0].collectedFormats) { - let finalFormat = formatVariantSelector('&', ...match[0].collectedFormats) - let container = postcss.root({ nodes: [match[1].clone()] }) - container.walkRules((rule) => { - if (inKeyframes(rule)) return - - let selectorOptions = { - selector: rule.selector, - candidate: original, - base: candidate - .split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`)) - .pop(), - isArbitraryVariant: match[0].isArbitraryVariant, - - context, - } - - try { - rule.selector = finalizeSelector(finalFormat, selectorOptions) - } catch { - // The selector we produced is invalid - // This could be because: - // - A bug exists - // - A plugin introduced an invalid variant selector (ex: `addVariant('foo', '&;foo')`) - // - The user used an invalid arbitrary variant (ex: `[&;foo]:underline`) - // Either way the build will fail because of this - // We would rather that the build pass "silently" given that this could - // happen because of picking up invalid things when scanning content - // So we'll throw out the candidate instead - isValid = false - return false - } - }) - match[1] = container.nodes[0] - } + match = applyFinalFormat(match, { context, candidate, original }) - if (!isValid) { + // Skip rules with invalid selectors + // This will cause the candidate to be added to the "not class" + // cache skipping it entirely for future builds + if (match === null) { continue } @@ -783,6 +759,62 @@ function* resolveMatches(candidate, context, original = candidate) { } } +function applyFinalFormat(match, { context, candidate, original }) { + if (!match[0].collectedFormats) { + return match + } + + let isValid = true + let finalFormat + + try { + finalFormat = formatVariantSelector(match[0].collectedFormats, { + context, + candidate, + }) + } catch { + // The format selector we produced is invalid + // This could be because: + // - A bug exists + // - A plugin introduced an invalid variant selector (ex: `addVariant('foo', '&;foo')`) + // - The user used an invalid arbitrary variant (ex: `[&;foo]:underline`) + // Either way the build will fail because of this + // We would rather that the build pass "silently" given that this could + // happen because of picking up invalid things when scanning content + // So we'll throw out the candidate instead + + return null + } + + let container = postcss.root({ nodes: [match[1].clone()] }) + + container.walkRules((rule) => { + if (inKeyframes(rule)) { + return + } + + try { + rule.selector = finalizeSelector(rule.selector, finalFormat, { + candidate: original, + context, + }) + } catch { + // If this selector is invalid we also want to skip it + // But it's likely that being invalid here means there's a bug in a plugin rather than too loosely matching content + isValid = false + return false + } + }) + + if (!isValid) { + return null + } + + match[1] = container.nodes[0] + + return match +} + function inKeyframes(rule) { return rule.parent && rule.parent.type === 'atrule' && rule.parent.name === 'keyframes' } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 0912ffab7467..15d22a347f6d 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -1080,20 +1080,38 @@ function registerPlugins(plugins, context) { }) } - let result = formatStrings.map((formatString) => - finalizeSelector(formatVariantSelector('&', ...formatString), { - selector: `.${candidate}`, - candidate, - context, - isArbitraryVariant: !(value in (options.values ?? {})), - }) + let isArbitraryVariant = !(value in (options.values ?? {})) + + formatStrings = formatStrings.map((format) => + format.map((str) => ({ + format: str, + isArbitraryVariant, + })) + ) + + manualFormatStrings = manualFormatStrings.map((format) => ({ + format, + isArbitraryVariant, + })) + + let opts = { + candidate, + context, + } + + let result = formatStrings.map((formats) => + finalizeSelector(`.${candidate}`, formatVariantSelector(formats, opts), opts) .replace(`.${candidate}`, '&') .replace('{ & }', '') .trim() ) if (manualFormatStrings.length > 0) { - result.push(formatVariantSelector('&', ...manualFormatStrings)) + result.push( + formatVariantSelector(manualFormatStrings, opts) + .toString() + .replace(`.${candidate}`, '&') + ) } return result diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js index 79c94fae8385..38a8bfbc76fc 100644 --- a/src/util/formatVariantSelector.js +++ b/src/util/formatVariantSelector.js @@ -3,30 +3,57 @@ import unescape from 'postcss-selector-parser/dist/util/unesc' import escapeClassName from '../util/escapeClassName' import prefixSelector from '../util/prefixSelector' +/** @typedef {import('postcss-selector-parser').Root} Root */ +/** @typedef {import('postcss-selector-parser').Selector} Selector */ +/** @typedef {import('postcss-selector-parser').Pseudo} Pseudo */ +/** @typedef {import('postcss-selector-parser').Node} Node */ + +/** @typedef {{format: string, isArbitraryVariant: boolean}[]} RawFormats */ +/** @typedef {import('postcss-selector-parser').Root} ParsedFormats */ +/** @typedef {RawFormats | ParsedFormats} AcceptedFormats */ + let MERGE = ':merge' -let PARENT = '&' - -export let selectorFunctions = new Set([MERGE]) - -export function formatVariantSelector(current, ...others) { - for (let other of others) { - let incomingValue = resolveFunctionArgument(other, MERGE) - if (incomingValue !== null) { - let existingValue = resolveFunctionArgument(current, MERGE, incomingValue) - if (existingValue !== null) { - let existingTarget = `${MERGE}(${incomingValue})` - let splitIdx = other.indexOf(existingTarget) - let addition = other.slice(splitIdx + existingTarget.length).split(' ')[0] - - current = current.replace(existingTarget, existingTarget + addition) - continue - } + +/** + * @param {RawFormats} formats + * @param {{context: any, candidate: string, base: string | null}} options + * @returns {ParsedFormats | null} + */ +export function formatVariantSelector(formats, { context, candidate }) { + let prefix = context?.tailwindConfig.prefix ?? '' + + // Parse the format selector into an AST + let parsedFormats = formats.map((format) => { + let ast = selectorParser().astSync(format.format) + + return { + ...format, + ast: format.isArbitraryVariant ? ast : prefixSelector(prefix, ast), } + }) - current = other.replace(PARENT, current) + // We start with the candidate selector + let formatAst = selectorParser.root({ + nodes: [ + selectorParser.selector({ + nodes: [selectorParser.className({ value: escapeClassName(candidate) })], + }), + ], + }) + + // And iteratively merge each format selector into the candidate selector + for (let { ast } of parsedFormats) { + // 1. Handle :merge() special pseudo-class + ;[formatAst, ast] = handleMergePseudo(formatAst, ast) + + // 2. Merge the format selector into the current selector AST + ast.walkNesting((nesting) => nesting.replaceWith(...formatAst.nodes[0].nodes)) + + // 3. Keep going! + formatAst = ast } - return current + return formatAst } /** @@ -35,11 +62,11 @@ export function formatVariantSelector(current, ...others) { * 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[]} + * @param {Node} node + * @returns {Node[]} **/ function simpleSelectorForNode(node) { - /** @type {import('postcss-selector-parser').Node[]} */ + /** @type {Node[]} */ let nodes = [] // Walk backwards until we hit a combinator node (or the start) @@ -60,8 +87,8 @@ function simpleSelectorForNode(node) { * 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} + * @param {Selector} sel + * @returns {Selector} **/ function resortSelector(sel) { sel.sort((a, b) => { @@ -81,6 +108,18 @@ function resortSelector(sel) { return sel } +/** + * Remove extraneous selectors that do not include the base class/candidate + * + * Example: + * Given the utility `.a, .b { color: red}` + * Given the candidate `sm:b` + * + * The final selector should be `.sm\:b` and not `.a, .sm\:b` + * + * @param {Selector} ast + * @param {string} base + */ function eliminateIrrelevantSelectors(sel, base) { let hasClassesMatchingCandidate = false @@ -104,41 +143,26 @@ function eliminateIrrelevantSelectors(sel, base) { // TODO: Can we do this for :matches, :is, and :where? } -export function finalizeSelector( - format, - { - selector, - candidate, - context, - isArbitraryVariant, - - // Split by the separator, but ignore the separator inside square brackets: - // - // E.g.: dark:lg:hover:[paint-order:markers] - // ┬ ┬ ┬ ┬ - // │ │ │ ╰── We will not split here - // ╰──┴─────┴─────────────── We will split here - // - base = candidate - .split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`)) - .pop(), - } -) { - let ast = selectorParser().astSync(selector) - - // We explicitly DO NOT prefix classes in arbitrary variants - if (context?.tailwindConfig?.prefix && !isArbitraryVariant) { - format = prefixSelector(context.tailwindConfig.prefix, format) - } - - format = format.replace(PARENT, `.${escapeClassName(candidate)}`) - - let formatAst = selectorParser().astSync(format) +/** + * @param {string} current + * @param {AcceptedFormats} formats + * @param {{context: any, candidate: string, base: string | null}} options + * @returns {string} + */ +export function finalizeSelector(current, formats, { context, candidate, base }) { + let separator = context?.tailwindConfig?.separator ?? ':' + + // Split by the separator, but ignore the separator inside square brackets: + // + // E.g.: dark:lg:hover:[paint-order:markers] + // ┬ ┬ ┬ ┬ + // │ │ │ ╰── We will not split here + // ╰──┴─────┴─────────────── We will split here + // + base = base ?? candidate.split(new RegExp(`\\${separator}(?![^[]*\\])`)).pop() - // Remove extraneous selectors that do not include the base class/candidate being matched against - // For example if we have a utility defined `.a, .b { color: red}` - // And the formatted variant is sm:b then we want the final selector to be `.sm\:b` and not `.a, .sm\:b` - ast.each((sel) => eliminateIrrelevantSelectors(sel, base)) + // Parse the selector into an AST + let selector = selectorParser().astSync(current) // Normalize escaped classes, e.g.: // @@ -151,18 +175,31 @@ export function finalizeSelector( // base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\] // escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\] // - ast.walkClasses((node) => { + selector.walkClasses((node) => { if (node.raws && node.value.includes(base)) { node.raws.value = escapeClassName(unescape(node.raws.value)) } }) + // Remove extraneous selectors that do not include the base candidate + selector.each((sel) => eliminateIrrelevantSelectors(sel, base)) + + // If there are no formats that means there were no variants added to the candidate + // so we can just return the selector as-is + let formatAst = Array.isArray(formats) + ? formatVariantSelector(formats, { context, candidate }) + : formats + + if (formatAst === null) { + return selector.toString() + } + 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) => { + selector.walkClasses((node) => { if (node.value !== base) { return } @@ -200,47 +237,86 @@ export function finalizeSelector( simpleEnd.remove() }) - // 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)) - } + // Remove unnecessary pseudo selectors that we used as placeholders + selector.walkPseudos((p) => { + if (p.value === MERGE) { + p.replaceWith(p.nodes) } + }) - return nodes - } - - // Remove unnecessary pseudo selectors that we used as placeholders - ast.each((selector) => { - selector.walkPseudos((p) => { - if (selectorFunctions.has(p.value)) { - p.replaceWith(p.nodes) - } - }) - - let pseudoElements = collectPseudoElements(selector) + // Move pseudo elements to the end of the selector (if necessary) + selector.each((sel) => { + let pseudoElements = collectPseudoElements(sel) if (pseudoElements.length > 0) { - selector.nodes.push(pseudoElements.sort(sortSelector)) + sel.nodes.push(pseudoElements.sort(sortSelector)) + } + }) + + return selector.toString() +} + +/** + * + * @param {Selector} selector + * @param {Selector} format + */ +export function handleMergePseudo(selector, format) { + /** @type {{pseudo: Pseudo, value: string}[]} */ + let merges = [] + + // Find all :merge() pseudo-classes in `selector` + selector.walkPseudos((pseudo) => { + if (pseudo.value === MERGE) { + merges.push({ + pseudo, + value: pseudo.nodes[0].toString(), + }) } }) - return ast.toString() + // Find all :merge() "attachments" in `format` and attach them to the matching selector in `selector` + format.walkPseudos((pseudo) => { + if (pseudo.value !== MERGE) { + return + } + + let value = pseudo.nodes[0].toString() + + // Does `selector` contain a :merge() pseudo-class with the same value? + let existing = merges.find((merge) => merge.value === value) + + // Nope so there's nothing to do + if (!existing) { + return + } + + // Everything after `:merge()` up to the next combinator is what is attached to the merged selector + let attachments = [] + let next = pseudo.next() + while (next && next.type !== 'combinator') { + attachments.push(next) + next = next.next() + } + + let combinator = next + + existing.pseudo.parent.insertAfter( + existing.pseudo, + selectorParser.selector({ nodes: attachments.map((node) => node.clone()) }) + ) + + pseudo.remove() + attachments.forEach((node) => node.remove()) + + // What about this case: + // :merge(.group):focus > & + // :merge(.group):hover & + if (combinator && combinator.type === 'combinator') { + combinator.remove() + } + }) + + return [selector, format] } // Note: As a rule, double colons (::) should be used instead of a single colon @@ -263,6 +339,37 @@ let pseudoElementExceptions = [ '::-webkit-resizer', ] +/** + * 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. + * + * @param {Selector} selector + **/ +function collectPseudoElements(selector) { + /** @type {Node[]} */ + 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 +} + // 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. @@ -303,28 +410,3 @@ function isPseudoElement(node) { 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 - - // Start inside the `(` - startIdx += needle.length + 1 - - let target = '' - let count = 0 - - for (let char of haystack.slice(startIdx)) { - if (char !== '(' && char !== ')') { - target += char - } else if (char === '(') { - target += char - count++ - } else if (char === ')') { - if (--count < 0) break // unbalanced - target += char - } - } - - return target -} diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js index 34ce7d57c413..0e7bb445bdd9 100644 --- a/src/util/prefixSelector.js +++ b/src/util/prefixSelector.js @@ -1,14 +1,32 @@ import parser from 'postcss-selector-parser' +/** + * @template {string | import('postcss-selector-parser').Root} T + * + * Prefix all classes in the selector with the given prefix + * + * It can take either a string or a selector AST and will return the same type + * + * @param {string} prefix + * @param {T} selector + * @param {boolean} prependNegative + * @returns {T} + */ export default function (prefix, selector, prependNegative = false) { - return parser((selectors) => { - selectors.walkClasses((classSelector) => { - let baseClass = classSelector.value - let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-') - - classSelector.value = shouldPlaceNegativeBeforePrefix - ? `-${prefix}${baseClass.slice(1)}` - : `${prefix}${baseClass}` - }) - }).processSync(selector) + if (prefix === '') { + return selector + } + + let ast = typeof selector === 'string' ? parser().astSync(selector) : selector + + ast.walkClasses((classSelector) => { + let baseClass = classSelector.value + let shouldPlaceNegativeBeforePrefix = prependNegative && baseClass.startsWith('-') + + classSelector.value = shouldPlaceNegativeBeforePrefix + ? `-${prefix}${baseClass.slice(1)}` + : `${prefix}${baseClass}` + }) + + return typeof selector === 'string' ? ast.toString() : ast } diff --git a/tests/arbitrary-variants.test.js b/tests/arbitrary-variants.test.js index 80d854f03c93..5ef6520f7248 100644 --- a/tests/arbitrary-variants.test.js +++ b/tests/arbitrary-variants.test.js @@ -542,6 +542,14 @@ test('classes in arbitrary variants should not be prefixed', () => {
should not be red
should be red
+
+
should not be red
+
should be red
+
+
+
should not be red
+
should be red
+
`, }, ], @@ -558,7 +566,14 @@ test('classes in arbitrary variants should not be prefixed', () => { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); } - + .hover\:\[\&_\.foo\]\:tw-text-red-400 .foo:hover { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); + } + .\[\&_\.foo\]\:hover\:tw-text-red-400:hover .foo { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); + } .foo .\[\.foo_\&\]\:tw-text-red-400 { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity)); diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js index 94e86ddfcab7..91bff419dcd8 100644 --- a/tests/format-variant-selector.test.js +++ b/tests/format-variant-selector.test.js @@ -1,23 +1,24 @@ -import { formatVariantSelector, finalizeSelector } from '../src/util/formatVariantSelector' +import { finalizeSelector } from '../src/util/formatVariantSelector' it('should be possible to add a simple variant to a simple selector', () => { let selector = '.text-center' let candidate = 'hover:text-center' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( - '.hover\\:text-center:hover' - ) + expect(finalizeSelector(selector, formats, { candidate })).toEqual('.hover\\:text-center:hover') }) it('should be possible to add a multiple simple variants to a simple selector', () => { let selector = '.text-center' let candidate = 'focus:hover:text-center' - let variants = ['&:hover', '&:focus'] + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, + { format: '&:focus', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.focus\\:hover\\:text-center:hover:focus' ) }) @@ -26,9 +27,9 @@ it('should be possible to add a simple variant to a selector containing escaped let selector = '.bg-\\[rgba\\(0\\,0\\,0\\)\\]' let candidate = 'hover:bg-[rgba(0,0,0)]' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover' ) }) @@ -37,9 +38,9 @@ it('should be possible to add a simple variant to a selector containing escaped let selector = '.bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]' let candidate = 'hover:bg-[rgba(0,0,0)]' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover' ) }) @@ -48,9 +49,9 @@ it('should be possible to add a simple variant to a more complex selector', () = let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'hover:space-x-4' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])' ) }) @@ -59,9 +60,13 @@ it('should be possible to add multiple simple variants to a more complex selecto let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'disabled:focus:hover:space-x-4' - let variants = ['&:hover', '&:focus', '&:disabled'] + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, + { format: '&:focus', isArbitraryVariant: false }, + { format: '&:disabled', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.disabled\\:focus\\:hover\\:space-x-4:hover:focus:disabled > :not([hidden]) ~ :not([hidden])' ) }) @@ -70,9 +75,9 @@ it('should be possible to add a single merge variant to a simple selector', () = let selector = '.text-center' let candidate = 'group-hover:text-center' - let variants = [':merge(.group):hover &'] + let formats = [{ format: ':merge(.group):hover &', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .group-hover\\:text-center' ) }) @@ -81,9 +86,12 @@ it('should be possible to add multiple merge variants to a simple selector', () let selector = '.text-center' let candidate = 'group-focus:group-hover:text-center' - let variants = [':merge(.group):hover &', ':merge(.group):focus &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.group):focus &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:focus:hover .group-focus\\:group-hover\\:text-center' ) }) @@ -92,9 +100,9 @@ it('should be possible to add a single merge variant to a more complex selector' let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])' let candidate = 'group-hover:space-x-4' - let variants = [':merge(.group):hover &'] + let formats = [{ format: ':merge(.group):hover &', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])' ) }) @@ -103,9 +111,12 @@ it('should be possible to add multiple merge variants to a more complex selector let selector = '.space-x-4 ~ :not([hidden]) ~ :not([hidden])' let candidate = 'group-focus:group-hover:space-x-4' - let variants = [':merge(.group):hover &', ':merge(.group):focus &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.group):focus &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:focus:hover .group-focus\\:group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])' ) }) @@ -114,9 +125,12 @@ it('should be possible to add multiple unique merge variants to a simple selecto let selector = '.text-center' let candidate = 'peer-focus:group-hover:text-center' - let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.peer):focus ~ &' }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.peer:focus ~ .group:hover .peer-focus\\:group-hover\\:text-center' ) }) @@ -125,37 +139,41 @@ it('should be possible to add multiple unique merge variants to a simple selecto let selector = '.text-center' let candidate = 'group-hover:peer-focus:text-center' - let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &'] + let formats = [ + { format: ':merge(.peer):focus ~ &', isArbitraryVariant: false }, + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .peer:focus ~ .group-hover\\:peer-focus\\:text-center' ) }) it('should be possible to use multiple :merge() calls with different "arguments"', () => { - let result = '&' - result = formatVariantSelector(result, ':merge(.group):hover &') - expect(result).toEqual(':merge(.group):hover &') - - result = formatVariantSelector(result, ':merge(.peer):hover ~ &') - expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):hover &') - - result = formatVariantSelector(result, ':merge(.group):focus &') - expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):focus:hover &') + let selector = '.foo' + let candidate = 'peer-focus:group-focus:peer-hover:group-hover:foo' + + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: ':merge(.peer):hover ~ &', isArbitraryVariant: false }, + { format: ':merge(.group):focus &', isArbitraryVariant: false }, + { format: ':merge(.peer):focus ~ &', isArbitraryVariant: false }, + ] - result = formatVariantSelector(result, ':merge(.peer):focus ~ &') - expect(result).toEqual(':merge(.peer):focus:hover ~ :merge(.group):focus:hover &') + expect(finalizeSelector(selector, formats, { candidate })).toEqual( + '.peer:focus:hover ~ .group:focus:hover .peer-focus\\:group-focus\\:peer-hover\\:group-hover\\:foo' + ) }) it('group hover and prose headings combination', () => { let selector = '.text-center' let candidate = 'group-hover:prose-headings:text-center' - let variants = [ - ':where(&) :is(h1, h2, h3, h4)', // Prose Headings - ':merge(.group):hover &', // Group Hover + let formats = [ + { format: ':where(&) :is(h1, h2, h3, h4)', isArbitraryVariant: false }, // Prose Headings + { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group Hover ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover :where(.group-hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4)' ) }) @@ -163,12 +181,12 @@ it('group hover and prose headings combination', () => { it('group hover and prose headings combination flipped', () => { let selector = '.text-center' let candidate = 'prose-headings:group-hover:text-center' - let variants = [ - ':merge(.group):hover &', // Group Hover - ':where(&) :is(h1, h2, h3, h4)', // Prose Headings + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group Hover + { format: ':where(&) :is(h1, h2, h3, h4)', isArbitraryVariant: false }, // Prose Headings ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( ':where(.group:hover .prose-headings\\:group-hover\\:text-center) :is(h1, h2, h3, h4)' ) }) @@ -176,28 +194,74 @@ it('group hover and prose headings combination flipped', () => { it('should be possible to handle a complex utility', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'peer-disabled:peer-first-child:group-hover:group-focus:focus:hover:space-x-4' - let variants = [ - '&:hover', // Hover - '&:focus', // Focus - ':merge(.group):focus &', // Group focus - ':merge(.group):hover &', // Group hover - ':merge(.peer):first-child ~ &', // Peer first-child - ':merge(.peer):disabled ~ &', // Peer disabled + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, // Hover + { format: '&:focus', isArbitraryVariant: false }, // Focus + { format: ':merge(.group):focus &', isArbitraryVariant: false }, // Group focus + { format: ':merge(.group):hover &', isArbitraryVariant: false }, // Group hover + { format: ':merge(.peer):first-child ~ &', isArbitraryVariant: false }, // Peer first-child + { format: ':merge(.peer):disabled ~ &', isArbitraryVariant: false }, // Peer disabled ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.peer:disabled:first-child ~ .group:hover:focus .peer-disabled\\:peer-first-child\\:group-hover\\:group-focus\\:focus\\:hover\\:space-x-4:hover:focus > :not([hidden]) ~ :not([hidden])' ) }) +it('should match base utilities that are prefixed', () => { + let context = { tailwindConfig: { prefix: 'tw-' } } + let selector = '.tw-text-center' + let candidate = 'tw-text-center' + let formats = [] + + expect(finalizeSelector(selector, formats, { candidate, context })).toEqual('.tw-text-center') +}) + +it('should prefix classes from variants', () => { + let context = { tailwindConfig: { prefix: 'tw-' } } + let selector = '.tw-text-center' + let candidate = 'foo:tw-text-center' + let formats = [{ format: '.foo &', isArbitraryVariant: false }] + + expect(finalizeSelector(selector, formats, { candidate, context })).toEqual( + '.tw-foo .foo\\:tw-text-center' + ) +}) + +it('should not prefix classes from arbitrary variants', () => { + let context = { tailwindConfig: { prefix: 'tw-' } } + let selector = '.tw-text-center' + let candidate = '[.foo_&]:tw-text-center' + let formats = [{ format: '.foo &', isArbitraryVariant: true }] + + expect(finalizeSelector(selector, formats, { candidate, context })).toEqual( + '.foo .\\[\\.foo_\\&\\]\\:tw-text-center' + ) +}) + +it('Merged selectors with mixed combinators uses the first one', () => { + // This isn't explicitly specced behavior but it is how it works today + + let selector = '.text-center' + let candidate = 'text-center' + let formats = [ + { format: ':merge(.group):focus > &', isArbitraryVariant: true }, + { format: ':merge(.group):hover &', isArbitraryVariant: true }, + ] + + expect(finalizeSelector(selector, formats, { candidate })).toEqual( + '.group:hover:focus > .text-center' + ) +}) + describe('real examples', () => { it('example a', () => { let selector = '.placeholder-red-500::placeholder' let candidate = 'hover:placeholder-red-500' - let variants = ['&:hover'] + let formats = [{ format: '&:hover', isArbitraryVariant: false }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.hover\\:placeholder-red-500:hover::placeholder' ) }) @@ -206,9 +270,12 @@ describe('real examples', () => { let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' let candidate = 'group-hover:hover:space-x-4' - let variants = ['&:hover', ':merge(.group):hover &'] + let formats = [ + { format: '&:hover', isArbitraryVariant: false }, + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .group-hover\\:hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])' ) }) @@ -217,9 +284,12 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'dark:group-hover:text-center' - let variants = [':merge(.group):hover &', '.dark &'] + let formats = [ + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + { format: '.dark &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.dark .group:hover .dark\\:group-hover\\:text-center' ) }) @@ -228,9 +298,12 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'group-hover:dark:text-center' - let variants = ['.dark &', ':merge(.group):hover &'] + let formats = [ + { format: '.dark &' }, + { format: ':merge(.group):hover &', isArbitraryVariant: false }, + ] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( '.group:hover .dark .group-hover\\:dark\\:text-center' ) }) @@ -240,9 +313,9 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'hover:prose-headings:text-center' - let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover'] + let formats = [{ format: ':where(&) :is(h1, h2, h3, h4)' }, { format: '&:hover' }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( ':where(.hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4):hover' ) }) @@ -251,9 +324,9 @@ describe('real examples', () => { let selector = '.text-center' let candidate = 'prose-headings:hover:text-center' - let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)'] + let formats = [{ format: '&:hover' }, { format: ':where(&) :is(h1, h2, h3, h4)' }] - expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + expect(finalizeSelector(selector, formats, { candidate })).toEqual( ':where(.prose-headings\\:hover\\:text-center:hover) :is(h1, h2, h3, h4)' ) }) @@ -274,8 +347,7 @@ describe('pseudo elements', () => { ${':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', + let result = finalizeSelector('.a', [{ format: before, isArbitraryVariant: false }], { candidate: 'a', })