Don't prefix classes in arbitrary variants (#10214)

* Add tests

* Refactor

refactor

* Allow `prefixSelector` to take an AST

* Consider multiple formats in `finalizeSelector`

The functions `finalizeSelector` and `formatVariantSelector` together were using a mix for AST and string-based parsing. This now does the full transformation using the selector AST. This also parses the format strings AST as early as possible and is set up to parse them only once for a given set of rules.

All of this will allow considering metadata per format string. For instance, we now know if the format string `.foo &` was produced by a normal variant or by an arbitrary variant. We use this information to control the prefixing behavior for individual format strings.

* Update changelog

* Cleanup code a bit
This commit is contained in:
Jordan Pittman 2023-01-03 09:40:47 -05:00 committed by GitHub
parent 2b885ef252
commit 7d8eb21de6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 483 additions and 245 deletions

View File

@ -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

View File

@ -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
match = applyFinalFormat(match, { context, candidate, original })
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]
}
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'
}

View File

@ -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

View File

@ -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])
/**
* @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 ?? ''
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]
// Parse the format selector into an AST
let parsedFormats = formats.map((format) => {
let ast = selectorParser().astSync(format.format)
current = current.replace(existingTarget, existingTarget + addition)
continue
}
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,
/**
* @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 = candidate
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
.pop(),
}
) {
let ast = selectorParser().astSync(selector)
// 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()
// 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)
// 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))
}
}
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)
if (pseudoElements.length > 0) {
selector.nodes.push(pseudoElements.sort(sortSelector))
selector.walkPseudos((p) => {
if (p.value === MERGE) {
p.replaceWith(p.nodes)
}
})
return ast.toString()
// Move pseudo elements to the end of the selector (if necessary)
selector.each((sel) => {
let pseudoElements = collectPseudoElements(sel)
if (pseudoElements.length > 0) {
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(),
})
}
})
// 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
}

View File

@ -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('-')
if (prefix === '') {
return selector
}
classSelector.value = shouldPlaceNegativeBeforePrefix
? `-${prefix}${baseClass.slice(1)}`
: `${prefix}${baseClass}`
})
}).processSync(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
}

View File

@ -542,6 +542,14 @@ test('classes in arbitrary variants should not be prefixed', () => {
<div>should not be red</div>
<div class="foo">should be red</div>
</div>
<div class="hover:[&_.foo]:tw-text-red-400">
<div>should not be red</div>
<div class="foo">should be red</div>
</div>
<div class="[&_.foo]:hover:tw-text-red-400">
<div>should not be red</div>
<div class="foo">should be red</div>
</div>
`,
},
],
@ -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));

View File

@ -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 &')
let selector = '.foo'
let candidate = 'peer-focus:group-focus:peer-hover:group-hover:foo'
result = formatVariantSelector(result, ':merge(.peer):hover ~ &')
expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):hover &')
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(.group):focus &')
expect(result).toEqual(':merge(.peer):hover ~ :merge(.group):focus:hover &')
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',
})