diff --git a/CHANGELOG.md b/CHANGELOG.md index 4156c040fb19..e2a7a270721b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Don't use pointer cursor on disabled buttons by default ([#5772](https://github.com/tailwindlabs/tailwindcss/pull/5772)) +- Improve `addVariant` API ([#5809](https://github.com/tailwindlabs/tailwindcss/pull/5809)) ### Added diff --git a/src/corePlugins.js b/src/corePlugins.js index 3cb91990f53b..aea6f31229c3 100644 --- a/src/corePlugins.js +++ b/src/corePlugins.js @@ -3,129 +3,60 @@ import * as path from 'path' import postcss from 'postcss' import createUtilityPlugin from './util/createUtilityPlugin' import buildMediaQuery from './util/buildMediaQuery' -import prefixSelector from './util/prefixSelector' import parseAnimationValue from './util/parseAnimationValue' import flattenColorPalette from './util/flattenColorPalette' import withAlphaVariable, { withAlphaValue } from './util/withAlphaVariable' import toColorValue from './util/toColorValue' import isPlainObject from './util/isPlainObject' import transformThemeValue from './util/transformThemeValue' -import { - applyStateToMarker, - updateLastClasses, - updateAllClasses, - transformAllSelectors, - transformAllClasses, - transformLastClasses, -} from './util/pluginUtils' import { version as tailwindVersion } from '../package.json' import log from './util/log' export let variantPlugins = { - pseudoElementVariants: ({ config, addVariant }) => { - addVariant( - 'first-letter', - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`first-letter${config('separator')}${className}`, '::first-letter') - }) - }) - ) + pseudoElementVariants: ({ addVariant }) => { + addVariant('first-letter', '&::first-letter') + addVariant('first-line', '&::first-line') - addVariant( - 'first-line', - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`first-line${config('separator')}${className}`, '::first-line') - }) - }) - ) + addVariant('marker', ['& *::marker', '&::marker']) + addVariant('selection', ['& *::selection', '&::selection']) - addVariant('marker', [ - transformAllSelectors((selector) => { - let variantSelector = updateAllClasses(selector, (className) => { - return `marker${config('separator')}${className}` - }) + addVariant('file', '&::file-selector-button') - return `${variantSelector} *::marker` - }), - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`marker${config('separator')}${className}`, '::marker') - }) - }), - ]) + // TODO: Use `addVariant('before', '*::before')` instead, once `content` + // fix is implemented. + addVariant('before', ({ format, withRule }) => { + format('&::before') - addVariant('selection', [ - transformAllSelectors((selector) => { - let variantSelector = updateAllClasses(selector, (className) => { - return `selection${config('separator')}${className}` + withRule((rule) => { + let foundContent = false + rule.walkDecls('content', () => { + foundContent = true }) + if (!foundContent) { + rule.prepend(postcss.decl({ prop: 'content', value: '""' })) + } + }) + }) - return `${variantSelector} *::selection` - }), - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`selection${config('separator')}${className}`, '::selection') - }) - }), - ]) + // TODO: Use `addVariant('after', '*::after')` instead, once `content` + // fix is implemented. + addVariant('after', ({ format, withRule }) => { + format('&::after') - addVariant( - 'file', - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`file${config('separator')}${className}`, '::file-selector-button') + withRule((rule) => { + let foundContent = false + rule.walkDecls('content', () => { + foundContent = true }) - }) - ) - addVariant( - 'before', - transformAllSelectors( - (selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`before${config('separator')}${className}`, '::before') - }) - }, - { - withRule: (rule) => { - let foundContent = false - rule.walkDecls('content', () => { - foundContent = true - }) - if (!foundContent) { - rule.prepend(postcss.decl({ prop: 'content', value: '""' })) - } - }, + if (!foundContent) { + rule.prepend(postcss.decl({ prop: 'content', value: '""' })) } - ) - ) - - addVariant( - 'after', - transformAllSelectors( - (selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`after${config('separator')}${className}`, '::after') - }) - }, - { - withRule: (rule) => { - let foundContent = false - rule.walkDecls('content', () => { - foundContent = true - }) - if (!foundContent) { - rule.prepend(postcss.decl({ prop: 'content', value: '""' })) - } - }, - } - ) - ) + }) + }) }, - pseudoClassVariants: ({ config, addVariant }) => { + pseudoClassVariants: ({ addVariant }) => { let pseudoVariants = [ // Positional ['first', ':first-child'], @@ -165,137 +96,44 @@ export let variantPlugins = { 'focus-visible', 'active', 'disabled', - ] - - for (let variant of pseudoVariants) { - let [variantName, state] = Array.isArray(variant) ? variant : [variant, `:${variant}`] - - addVariant( - variantName, - transformAllClasses((className, { withAttr, withPseudo }) => { - if (state.startsWith(':')) { - return withPseudo(`${variantName}${config('separator')}${className}`, state) - } else if (state.startsWith('[')) { - return withAttr(`${variantName}${config('separator')}${className}`, state) - } - }) - ) - } + ].map((variant) => (Array.isArray(variant) ? variant : [variant, `:${variant}`])) - let groupMarker = prefixSelector(config('prefix'), '.group') - for (let variant of pseudoVariants) { - let [variantName, state] = Array.isArray(variant) ? variant : [variant, `:${variant}`] - let groupVariantName = `group-${variantName}` - - addVariant( - groupVariantName, - transformAllSelectors((selector) => { - let variantSelector = updateAllClasses(selector, (className) => { - if (`.${className}` === groupMarker) return className - return `${groupVariantName}${config('separator')}${className}` - }) - - if (variantSelector === selector) { - return null - } - - return applyStateToMarker( - variantSelector, - groupMarker, - state, - (marker, selector) => `${marker} ${selector}` - ) - }) - ) + for (let [variantName, state] of pseudoVariants) { + addVariant(variantName, `&${state}`) } - let peerMarker = prefixSelector(config('prefix'), '.peer') - for (let variant of pseudoVariants) { - let [variantName, state] = Array.isArray(variant) ? variant : [variant, `:${variant}`] - let peerVariantName = `peer-${variantName}` - - addVariant( - peerVariantName, - transformAllSelectors((selector) => { - let variantSelector = updateAllClasses(selector, (className) => { - if (`.${className}` === peerMarker) return className - return `${peerVariantName}${config('separator')}${className}` - }) - - if (variantSelector === selector) { - return null - } + for (let [variantName, state] of pseudoVariants) { + addVariant(`group-${variantName}`, `:merge(.group)${state} &`) + } - return applyStateToMarker(variantSelector, peerMarker, state, (marker, selector) => - selector.trim().startsWith('~') ? `${marker}${selector}` : `${marker} ~ ${selector}` - ) - }) - ) + for (let [variantName, state] of pseudoVariants) { + addVariant(`peer-${variantName}`, `:merge(.peer)${state} ~ &`) } }, - directionVariants: ({ config, addVariant }) => { - addVariant( - 'ltr', - transformAllSelectors((selector) => { - log.warn('rtl-experimental', [ - 'The RTL features in Tailwind CSS are currently in preview.', - 'Preview features are not covered by semver, and may be improved in breaking ways at any time.', - ]) - return `[dir="ltr"] ${updateAllClasses( - selector, - (className) => `ltr${config('separator')}${className}` - )}` - }) - ) + directionVariants: ({ addVariant }) => { + addVariant('ltr', ({ format }) => { + log.warn('rtl-experimental', [ + 'The RTL features in Tailwind CSS are currently in preview.', + 'Preview features are not covered by semver, and may be improved in breaking ways at any time.', + ]) - addVariant( - 'rtl', - transformAllSelectors((selector) => { - log.warn('rtl-experimental', [ - 'The RTL features in Tailwind CSS are currently in preview.', - 'Preview features are not covered by semver, and may be improved in breaking ways at any time.', - ]) - return `[dir="rtl"] ${updateAllClasses( - selector, - (className) => `rtl${config('separator')}${className}` - )}` - }) - ) - }, + format('[dir="ltr"] &') + }) - reducedMotionVariants: ({ config, addVariant }) => { - addVariant( - 'motion-safe', - transformLastClasses( - (className) => { - return `motion-safe${config('separator')}${className}` - }, - { - wrap: () => - postcss.atRule({ - name: 'media', - params: '(prefers-reduced-motion: no-preference)', - }), - } - ) - ) + addVariant('rtl', ({ format }) => { + log.warn('rtl-experimental', [ + 'The RTL features in Tailwind CSS are currently in preview.', + 'Preview features are not covered by semver, and may be improved in breaking ways at any time.', + ]) - addVariant( - 'motion-reduce', - transformLastClasses( - (className) => { - return `motion-reduce${config('separator')}${className}` - }, - { - wrap: () => - postcss.atRule({ - name: 'media', - params: '(prefers-reduced-motion: reduce)', - }), - } - ) - ) + format('[dir="rtl"] &') + }) + }, + + reducedMotionVariants: ({ addVariant }) => { + addVariant('motion-safe', '@media (prefers-reduced-motion: no-preference)') + addVariant('motion-reduce', '@media (prefers-reduced-motion: reduce)') }, darkVariants: ({ config, addVariant }) => { @@ -309,55 +147,18 @@ export let variantPlugins = { } if (mode === 'class') { - addVariant( - 'dark', - transformAllSelectors((selector) => { - let variantSelector = updateLastClasses(selector, (className) => { - return `dark${config('separator')}${className}` - }) - - if (variantSelector === selector) { - return null - } - - let darkSelector = prefixSelector(config('prefix'), `.dark`) - - return `${darkSelector} ${variantSelector}` - }) - ) + addVariant('dark', '.dark &') } else if (mode === 'media') { - addVariant( - 'dark', - transformLastClasses( - (className) => { - return `dark${config('separator')}${className}` - }, - { - wrap: () => - postcss.atRule({ - name: 'media', - params: '(prefers-color-scheme: dark)', - }), - } - ) - ) + addVariant('dark', '@media (prefers-color-scheme: dark)') } }, - screenVariants: ({ config, theme, addVariant }) => { + screenVariants: ({ theme, addVariant }) => { for (let screen in theme('screens')) { let size = theme('screens')[screen] let query = buildMediaQuery(size) - addVariant( - screen, - transformLastClasses( - (className) => { - return `${screen}${config('separator')}${className}` - }, - { wrap: () => postcss.atRule({ name: 'media', params: query }) } - ) - ) + addVariant(screen, `@media ${query}`) } }, } @@ -1745,25 +1546,56 @@ export let corePlugins = { fontVariantNumeric: ({ addUtilities }) => { addUtilities({ - '.ordinal, .slashed-zero, .lining-nums, .oldstyle-nums, .proportional-nums, .tabular-nums, .diagonal-fractions, .stacked-fractions': - { - '--tw-ordinal': 'var(--tw-empty,/*!*/ /*!*/)', - '--tw-slashed-zero': 'var(--tw-empty,/*!*/ /*!*/)', - '--tw-numeric-figure': 'var(--tw-empty,/*!*/ /*!*/)', - '--tw-numeric-spacing': 'var(--tw-empty,/*!*/ /*!*/)', - '--tw-numeric-fraction': 'var(--tw-empty,/*!*/ /*!*/)', - 'font-variant-numeric': - 'var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)', - }, + '@defaults font-variant-numeric': { + '--tw-ordinal': 'var(--tw-empty,/*!*/ /*!*/)', + '--tw-slashed-zero': 'var(--tw-empty,/*!*/ /*!*/)', + '--tw-numeric-figure': 'var(--tw-empty,/*!*/ /*!*/)', + '--tw-numeric-spacing': 'var(--tw-empty,/*!*/ /*!*/)', + '--tw-numeric-fraction': 'var(--tw-empty,/*!*/ /*!*/)', + '--tw-font-variant-numeric': + 'var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)', + }, '.normal-nums': { 'font-variant-numeric': 'normal' }, - '.ordinal': { '--tw-ordinal': 'ordinal' }, - '.slashed-zero': { '--tw-slashed-zero': 'slashed-zero' }, - '.lining-nums': { '--tw-numeric-figure': 'lining-nums' }, - '.oldstyle-nums': { '--tw-numeric-figure': 'oldstyle-nums' }, - '.proportional-nums': { '--tw-numeric-spacing': 'proportional-nums' }, - '.tabular-nums': { '--tw-numeric-spacing': 'tabular-nums' }, - '.diagonal-fractions': { '--tw-numeric-fraction': 'diagonal-fractions' }, - '.stacked-fractions': { '--tw-numeric-fraction': 'stacked-fractions' }, + '.ordinal': { + '@defaults font-variant-numeric': {}, + '--tw-ordinal': 'ordinal', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.slashed-zero': { + '@defaults font-variant-numeric': {}, + '--tw-slashed-zero': 'slashed-zero', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.lining-nums': { + '@defaults font-variant-numeric': {}, + '--tw-numeric-figure': 'lining-nums', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.oldstyle-nums': { + '@defaults font-variant-numeric': {}, + '--tw-numeric-figure': 'oldstyle-nums', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.proportional-nums': { + '@defaults font-variant-numeric': {}, + '--tw-numeric-spacing': 'proportional-nums', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.tabular-nums': { + '@defaults font-variant-numeric': {}, + '--tw-numeric-spacing': 'tabular-nums', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.diagonal-fractions': { + '@defaults font-variant-numeric': {}, + '--tw-numeric-fraction': 'diagonal-fractions', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, + '.stacked-fractions': { + '@defaults font-variant-numeric': {}, + '--tw-numeric-fraction': 'stacked-fractions', + 'font-variant-numeric': 'var(--tw-font-variant-numeric)', + }, }) }, diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js index 081086c35bf4..d25a39f38cd2 100644 --- a/src/lib/expandTailwindAtRules.js +++ b/src/lib/expandTailwindAtRules.js @@ -14,7 +14,7 @@ const PATTERNS = [ /([^<>"'`\s]*\['[^"'`\s]*'\])/.source, // `content-['hello']` but not `content-['hello']']` /([^<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]` /([^<>"'`\s]*\[[^"'`\s]+\][^<>"'`\s]*)/.source, // `fill-[#bada55]`, `fill-[#bada55]/50` - /([^<>"'`\s]*[^"'`\s:])/.source, // `px-1.5`, `uppercase` but not `uppercase:`].join('|') + /([^<>"'`\s]*[^"'`\s:])/.source, // `px-1.5`, `uppercase` but not `uppercase:` ].join('|') const BROAD_MATCH_GLOBAL_REGEXP = new RegExp(PATTERNS, 'g') const INNER_MATCH_GLOBAL_REGEXP = /[^<>"'`\s.(){}[\]#=%]*[^<>"'`\s.(){}[\]#=%:]/g diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index a6763c5e45e3..a6d013a32342 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -5,6 +5,7 @@ import isPlainObject from '../util/isPlainObject' import prefixSelector from '../util/prefixSelector' import { updateAllClasses } from '../util/pluginUtils' import log from '../util/log' +import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector' let classNameParser = selectorParser((selectors) => { return selectors.first.filter(({ type }) => type === 'class').pop().value @@ -112,7 +113,17 @@ function applyVariant(variant, matches, context) { for (let [variantSort, variantFunction] of variantFunctionTuples) { let clone = container.clone() + let collectedFormats = [] + + let originals = new Map() + + function prepareBackup() { + if (originals.size > 0) return // Already prepared, chicken out + clone.walkRules((rule) => originals.set(rule, rule.selector)) + } + function modifySelectors(modifierFunction) { + prepareBackup() clone.each((rule) => { if (rule.type !== 'rule') { return @@ -127,20 +138,80 @@ function applyVariant(variant, matches, context) { }) }) }) + return clone } let ruleWithVariant = variantFunction({ - container: clone, + get container() { + prepareBackup() + return clone + }, separator: context.tailwindConfig.separator, modifySelectors, + wrap(wrapper) { + let nodes = clone.nodes + clone.removeAll() + wrapper.append(nodes) + clone.append(wrapper) + }, + withRule(modify) { + clone.walkRules(modify) + }, + format(selectorFormat) { + collectedFormats.push(selectorFormat) + }, }) if (ruleWithVariant === null) { continue } - let withOffset = [{ ...meta, sort: variantSort | meta.sort }, clone.nodes[0]] + // We filled the `originals`, therefore we assume that somebody touched + // `container` or `modifySelectors`. Let's see if they did, so that we + // can restore the selectors, and collect the format strings. + if (originals.size > 0) { + clone.walkRules((rule) => { + if (!originals.has(rule)) return + let before = originals.get(rule) + if (before === rule.selector) return // No mutation happened + + let modified = rule.selector + + // Rebuild the base selector, this is what plugin authors would do + // as well. E.g.: `${variant}${separator}${className}`. + // However, plugin authors probably also prepend or append certain + // classes, pseudos, ids, ... + let rebuiltBase = selectorParser((selectors) => { + selectors.walkClasses((classNode) => { + classNode.value = `${variant}${context.tailwindConfig.separator}${classNode.value}` + }) + }).processSync(before) + + // Now that we know the original selector, the new selector, and + // the rebuild part in between, we can replace the part that plugin + // authors need to rebuild with `&`, and eventually store it in the + // collectedFormats. Similar to what `format('...')` would do. + // + // E.g.: + // variant: foo + // selector: .markdown > p + // modified (by plugin): .foo .foo\\:markdown > p + // rebuiltBase (internal): .foo\\:markdown > p + // format: .foo & + collectedFormats.push(modified.replace(rebuiltBase, '&')) + rule.selector = before + }) + } + + let withOffset = [ + { + ...meta, + sort: variantSort | meta.sort, + collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), + }, + clone.nodes[0], + ] result.push(withOffset) } } @@ -323,6 +394,22 @@ function* resolveMatches(candidate, context) { } for (let match of matches) { + // 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 + + rule.selector = finalizeSelector(finalFormat, { + selector: rule.selector, + candidate, + context, + }) + }) + match[1] = container.nodes[0] + } + yield match } } diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index 53a9851ce07a..8935e2e0f5b8 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -19,6 +19,35 @@ import { toPath } from '../util/toPath' import log from '../util/log' import negateValue from '../util/negateValue' +function parseVariantFormatString(input) { + if (input.includes('{')) { + if (!isBalanced(input)) throw new Error(`Your { and } are unbalanced.`) + + return input + .split(/{(.*)}/gim) + .flatMap((line) => parseVariantFormatString(line)) + .filter(Boolean) + } + + return [input.trim()] +} + +function isBalanced(input) { + let count = 0 + + for (let char of input) { + if (char === '{') { + count++ + } else if (char === '}') { + if (--count < 0) { + return false // unbalanced + } + } + } + + return count === 0 +} + function insertInto(list, value, { before = [] } = {}) { before = [].concat(before) @@ -186,7 +215,33 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs return { addVariant(variantName, variantFunctions, options = {}) { - variantFunctions = [].concat(variantFunctions) + variantFunctions = [].concat(variantFunctions).map((variantFunction) => { + if (typeof variantFunction !== 'string') { + return variantFunction + } + + variantFunction = variantFunction + .replace(/\n+/g, '') + .replace(/\s{1,}/g, ' ') + .trim() + + let fns = parseVariantFormatString(variantFunction) + .map((str) => { + if (!str.startsWith('@')) { + return ({ format }) => format(str) + } + + let [, name, params] = /@(.*?) (\(.*\))/g.exec(str) + return ({ wrap }) => wrap(postcss.atRule({ name, params })) + }) + .reverse() + + return (api) => { + for (let fn of fns) { + fn(api) + } + } + }) insertInto(variantList, variantName, options) variantMap.set(variantName, variantFunctions) diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js new file mode 100644 index 000000000000..d24471cc2eb8 --- /dev/null +++ b/src/util/formatVariantSelector.js @@ -0,0 +1,105 @@ +import selectorParser from 'postcss-selector-parser' +import unescape from 'postcss-selector-parser/dist/util/unesc' +import escapeClassName from '../util/escapeClassName' +import prefixSelector from '../util/prefixSelector' + +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 + } + } + + current = other.replace(PARENT, current) + } + + return current +} + +export function finalizeSelector(format, { selector, candidate, context }) { + let base = candidate.split(context?.tailwindConfig?.separator ?? ':').pop() + + if (context?.tailwindConfig?.prefix) { + format = prefixSelector(context.tailwindConfig.prefix, format) + } + + format = format.replace(PARENT, `.${escapeClassName(candidate)}`) + + // Normalize escaped classes, e.g.: + // + // The idea would be to replace the escaped `base` in the selector with the + // `format`. However, in css you can escape the same selector in a few + // different ways. This would result in different strings and therefore we + // can't replace it properly. + // + // base: bg-[rgb(255,0,0)] + // base in selector: bg-\\[rgb\\(255\\,0\\,0\\)\\] + // escaped base: bg-\\[rgb\\(255\\2c 0\\2c 0\\)\\] + // + selector = selectorParser((selectors) => { + return selectors.walkClasses((node) => { + if (node.raws && node.value.includes(base)) { + node.raws.value = escapeClassName(unescape(node.raws.value)) + } + + return node + }) + }).processSync(selector) + + // We can safely replace the escaped base now, since the `base` section is + // now in a normalized escaped value. + selector = selector.replace(`.${escapeClassName(base)}`, format) + + // Remove unnecessary pseudo selectors that we used as placeholders + return selectorParser((selectors) => { + return selectors.map((selector) => { + selector.walkPseudos((p) => { + if (selectorFunctions.has(p.value)) { + p.replaceWith(p.nodes) + } + + return p + }) + + return selector + }) + }).processSync(selector) +} + +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/pluginUtils.js b/src/util/pluginUtils.js index ef1cf2aaee0a..6d8db4a21e14 100644 --- a/src/util/pluginUtils.js +++ b/src/util/pluginUtils.js @@ -1,7 +1,6 @@ import selectorParser from 'postcss-selector-parser' import escapeCommas from './escapeCommas' import { withAlphaValue } from './withAlphaVariable' -import isKeyframeRule from './isKeyframeRule' import { normalize, length, @@ -19,34 +18,10 @@ import { } from './dataTypes' import negateValue from './negateValue' -export function applyStateToMarker(selector, marker, state, join) { - let markerIdx = selector.search(new RegExp(`${marker}[:[]`)) - - if (markerIdx === -1) { - return join(marker + state, selector) - } - - let markerSelector = selector.slice(markerIdx, selector.indexOf(' ', markerIdx)) - - return join( - marker + state + markerSelector.slice(markerIdx + marker.length), - selector.replace(markerSelector, '') - ) -} - export function updateAllClasses(selectors, updateClass) { let parser = selectorParser((selectors) => { selectors.walkClasses((sel) => { - let updatedClass = updateClass(sel.value, { - withAttr(className, attr) { - sel.parent.insertAfter(sel, selectorParser.attribute({ attribute: attr.slice(1, -1) })) - return className - }, - withPseudo(className, pseudo) { - sel.parent.insertAfter(sel, selectorParser.pseudo({ value: pseudo })) - return className - }, - }) + let updatedClass = updateClass(sel.value) sel.value = updatedClass if (sel.raws && sel.raws.value) { sel.raws.value = escapeCommas(sel.raws.value) @@ -59,115 +34,6 @@ export function updateAllClasses(selectors, updateClass) { return result } -export function updateLastClasses(selectors, updateClass) { - let parser = selectorParser((selectors) => { - selectors.each((sel) => { - let lastClass = sel.filter(({ type }) => type === 'class').pop() - - if (lastClass === undefined) { - return - } - - let updatedClass = updateClass(lastClass.value, { - withPseudo(className, pseudo) { - lastClass.parent.insertAfter(lastClass, selectorParser.pseudo({ value: `${pseudo}` })) - return className - }, - }) - lastClass.value = updatedClass - if (lastClass.raws && lastClass.raws.value) { - lastClass.raws.value = escapeCommas(lastClass.raws.value) - } - }) - }) - let result = parser.processSync(selectors) - - return result -} - -function splitByNotEscapedCommas(str) { - let chunks = [] - let currentChunk = '' - for (let i = 0; i < str.length; i++) { - if (str[i] === ',' && str[i - 1] !== '\\') { - chunks.push(currentChunk) - currentChunk = '' - } else { - currentChunk += str[i] - } - } - chunks.push(currentChunk) - return chunks -} - -export function transformAllSelectors(transformSelector, { wrap, withRule } = {}) { - return ({ container }) => { - container.walkRules((rule) => { - if (isKeyframeRule(rule)) { - return rule - } - let transformed = splitByNotEscapedCommas(rule.selector).map(transformSelector).join(',') - rule.selector = transformed - if (withRule) { - withRule(rule) - } - return rule - }) - - if (wrap) { - let wrapper = wrap() - let nodes = container.nodes - container.removeAll() - wrapper.append(nodes) - container.append(wrapper) - } - } -} - -export function transformAllClasses(transformClass, { wrap, withRule } = {}) { - return ({ container }) => { - container.walkRules((rule) => { - let selector = rule.selector - let variantSelector = updateAllClasses(selector, transformClass) - rule.selector = variantSelector - if (withRule) { - withRule(rule) - } - return rule - }) - - if (wrap) { - let wrapper = wrap() - let nodes = container.nodes - container.removeAll() - wrapper.append(nodes) - container.append(wrapper) - } - } -} - -export function transformLastClasses(transformClass, { wrap, withRule } = {}) { - return ({ container }) => { - container.walkRules((rule) => { - let selector = rule.selector - let variantSelector = updateLastClasses(selector, transformClass) - rule.selector = variantSelector - if (withRule) { - withRule(rule) - } - return rule - }) - - if (wrap) { - let wrapper = wrap() - let nodes = container.nodes - container.removeAll() - wrapper.append(nodes) - container.append(wrapper) - } - } -} - function resolveArbitraryValue(modifier, validate) { if (!isArbitraryValue(modifier)) { return undefined diff --git a/tests/apply.test.css b/tests/apply.test.css index 1eef7c6e26f3..08bcbf5b8ef0 100644 --- a/tests/apply.test.css +++ b/tests/apply.test.css @@ -126,22 +126,10 @@ } /* TODO: This works but the generated CSS is unnecessarily verbose. */ .complex-utilities { - --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); - --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) - var(--tw-numeric-spacing) var(--tw-numeric-fraction); - --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); - --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) - var(--tw-numeric-spacing) var(--tw-numeric-fraction); --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-font-variant-numeric); --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-font-variant-numeric); --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); @@ -152,14 +140,8 @@ var(--tw-shadow); } .complex-utilities:focus { - --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); - --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) - var(--tw-numeric-spacing) var(--tw-numeric-fraction); --tw-numeric-fraction: diagonal-fractions; + font-variant-numeric: var(--tw-font-variant-numeric); } .basic-nesting-parent { .basic-nesting-child { @@ -332,6 +314,15 @@ h2 { .important-modifier-variant:hover { border-radius: 0.375rem !important; } +.complex-utilities { + --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); + --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); + --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); + --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); + --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); + --tw-font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) + var(--tw-numeric-spacing) var(--tw-numeric-fraction); +} @keyframes spin { to { transform: rotate(360deg); diff --git a/tests/arbitrary-values.test.js b/tests/arbitrary-values.test.js index cde5846382d6..7b0b455bf5c8 100644 --- a/tests/arbitrary-values.test.js +++ b/tests/arbitrary-values.test.js @@ -198,7 +198,7 @@ it('should not convert escaped underscores with spaces', () => { }) }) -it('should warn and not generate if arbitrary values are ambigu', () => { +it('should warn and not generate if arbitrary values are ambiguous', () => { // If we don't protect against this, then `bg-[200px_100px]` would both // generate the background-size as well as the background-position utilities. let config = { diff --git a/tests/basic-usage.test.css b/tests/basic-usage.test.css index aeff4c30ab7e..535560916bba 100644 --- a/tests/basic-usage.test.css +++ b/tests/basic-usage.test.css @@ -730,29 +730,27 @@ font-style: normal; } .ordinal, -.slashed-zero, -.lining-nums, -.oldstyle-nums, -.proportional-nums, .tabular-nums, -.diagonal-fractions, -.stacked-fractions { +.diagonal-fractions { --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) + --tw-font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction); } .ordinal { --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-font-variant-numeric); } .tabular-nums { --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-font-variant-numeric); } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; + font-variant-numeric: var(--tw-font-variant-numeric); } .leading-relaxed { line-height: 1.625; diff --git a/tests/custom-plugins.test.js b/tests/custom-plugins.test.js index 78ad7ce01dd5..5fd8afd6236a 100644 --- a/tests/custom-plugins.test.js +++ b/tests/custom-plugins.test.js @@ -1521,7 +1521,7 @@ test('keyframes are not escaped', () => { } return run('@tailwind utilities', config).then((result) => { - expect(result.css).toMatchFormattedCss(` + expect(result.css).toMatchFormattedCss(css` @keyframes abc { 25.001% { color: black; @@ -1534,10 +1534,11 @@ test('keyframes are not escaped', () => { @media (min-width: 768px) { @keyframes def { - 25.md\\:001\\% { + 25.001% { color: black; } } + .md\\:foo-\\[def\\] { animation: def 1s infinite; } diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js new file mode 100644 index 000000000000..a32ae502501e --- /dev/null +++ b/tests/format-variant-selector.test.js @@ -0,0 +1,261 @@ +import { formatVariantSelector, 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'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, 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'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.focus\\:hover\\:text-center:hover:focus' + ) +}) + +it('should be possible to add a simple variant to a selector containing escaped parts', () => { + let selector = '.bg-\\[rgba\\(0\\,0\\,0\\)\\]' + let candidate = 'hover:bg-[rgba(0,0,0)]' + + let variants = ['&:hover'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover' + ) +}) + +it('should be possible to add a simple variant to a selector containing escaped parts (escape is slightly different)', () => { + let selector = '.bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]' + let candidate = 'hover:bg-[rgba(0,0,0)]' + + let variants = ['&:hover'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.hover\\:bg-\\[rgba\\(0\\2c 0\\2c 0\\)\\]:hover' + ) +}) + +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'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])' + ) +}) + +it('should be possible to add multiple simple variants to a more complex selector', () => { + let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' + let candidate = 'disabled:focus:hover:space-x-4' + + let variants = ['&:hover', '&:focus', '&:disabled'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.disabled\\:focus\\:hover\\:space-x-4:hover:focus:disabled > :not([hidden]) ~ :not([hidden])' + ) +}) + +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 &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:hover .group-hover\\:text-center' + ) +}) + +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 &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:focus:hover .group-focus\\:group-hover\\:text-center' + ) +}) + +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 &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:hover .group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])' + ) +}) + +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 &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:focus:hover .group-focus\\:group-hover\\:space-x-4 ~ :not([hidden]) ~ :not([hidden])' + ) +}) + +it('should be possible to add multiple unique merge variants to a simple selector', () => { + let selector = '.text-center' + let candidate = 'peer-focus:group-hover:text-center' + + let variants = [':merge(.group):hover &', ':merge(.peer):focus ~ &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.peer:focus ~ .group:hover .peer-focus\\:group-hover\\:text-center' + ) +}) + +it('should be possible to add multiple unique merge variants to a simple selector', () => { + let selector = '.text-center' + let candidate = 'group-hover:peer-focus:text-center' + + let variants = [':merge(.peer):focus ~ &', ':merge(.group):hover &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, 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 &') + + result = formatVariantSelector(result, ':merge(.peer):focus ~ &') + expect(result).toEqual(':merge(.peer):focus:hover ~ :merge(.group):focus:hover &') +}) + +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 + ] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:hover :where(.group-hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4)' + ) +}) + +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 + ] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + ':where(.group:hover .prose-headings\\:group-hover\\:text-center) :is(h1, h2, h3, h4)' + ) +}) + +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 + ] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, 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])' + ) +}) + +describe('real examples', () => { + it('example a', () => { + let selector = '.placeholder-red-500::placeholder' + let candidate = 'hover:placeholder-red-500' + + let variants = ['&:hover'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.hover\\:placeholder-red-500:hover::placeholder' + ) + }) + + it('example b', () => { + let selector = '.space-x-4 > :not([hidden]) ~ :not([hidden])' + let candidate = 'group-hover:hover:space-x-4' + + let variants = ['&:hover', ':merge(.group):hover &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:hover .group-hover\\:hover\\:space-x-4:hover > :not([hidden]) ~ :not([hidden])' + ) + }) + + it('should work for group-hover and class dark mode combinations', () => { + let selector = '.text-center' + let candidate = 'dark:group-hover:text-center' + + let variants = [':merge(.group):hover &', '.dark &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.dark .group:hover .dark\\:group-hover\\:text-center' + ) + }) + + it('should work for group-hover and class dark mode combinations (reversed)', () => { + let selector = '.text-center' + let candidate = 'group-hover:dark:text-center' + + let variants = ['.dark &', ':merge(.group):hover &'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + '.group:hover .dark .group-hover\\:dark\\:text-center' + ) + }) + + describe('prose-headings', () => { + it('should be possible to use hover:prose-headings:text-center', () => { + let selector = '.text-center' + let candidate = 'hover:prose-headings:text-center' + + let variants = [':where(&) :is(h1, h2, h3, h4)', '&:hover'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + ':where(.hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4):hover' + ) + }) + + it('should be possible to use prose-headings:hover:text-center', () => { + let selector = '.text-center' + let candidate = 'prose-headings:hover:text-center' + + let variants = ['&:hover', ':where(&) :is(h1, h2, h3, h4)'] + + expect(finalizeSelector(formatVariantSelector(...variants), { selector, candidate })).toEqual( + ':where(.prose-headings\\:hover\\:text-center:hover) :is(h1, h2, h3, h4)' + ) + }) + }) +}) diff --git a/tests/important-modifier-prefix.test.css b/tests/important-modifier-prefix.test.css index 8bf95f968c1e..17096025b112 100644 --- a/tests/important-modifier-prefix.test.css +++ b/tests/important-modifier-prefix.test.css @@ -38,7 +38,7 @@ } } @media (min-width: 1280px) { - .xl\:focus\:disabled\:\!tw-float-right:focus:disabled { + .xl\:focus\:disabled\:\!tw-float-right:disabled:focus { float: right !important; } } diff --git a/tests/important-modifier.test.css b/tests/important-modifier.test.css index fffcd3175978..11eef31f2f8f 100644 --- a/tests/important-modifier.test.css +++ b/tests/important-modifier.test.css @@ -38,7 +38,7 @@ } } @media (min-width: 1280px) { - .xl\:focus\:disabled\:\!float-right:focus:disabled { + .xl\:focus\:disabled\:\!float-right:disabled:focus { float: right !important; } } diff --git a/tests/kitchen-sink.test.css b/tests/kitchen-sink.test.css index b4a2e3fd447e..7fb1e7de0b89 100644 --- a/tests/kitchen-sink.test.css +++ b/tests/kitchen-sink.test.css @@ -23,7 +23,7 @@ .apply-test:hover { font-weight: 700; } -.apply-test:focus:hover { +.apply-test:hover:focus { font-weight: 700; } @media (min-width: 640px) { @@ -31,7 +31,7 @@ --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity)); } - .apply-test:focus:nth-child(even) { + .apply-test:nth-child(even):focus { --tw-bg-opacity: 1; background-color: rgb(251 207 232 / var(--tw-bg-opacity)); } @@ -198,22 +198,10 @@ div { } } .test-apply-font-variant { - --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); - --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) - var(--tw-numeric-spacing) var(--tw-numeric-fraction); - --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); - --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) - var(--tw-numeric-spacing) var(--tw-numeric-fraction); --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-font-variant-numeric); --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-font-variant-numeric); } .custom-component { background: #123456; @@ -267,6 +255,16 @@ div { .font-medium { font-weight: 500; } +.test-apply-font-variant, +.sm\:tabular-nums { + --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); + --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); + --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); + --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); + --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); + --tw-font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) + var(--tw-numeric-spacing) var(--tw-numeric-fraction); +} .shadow-sm { --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), @@ -352,7 +350,7 @@ div { --tw-ring-opacity: 1; --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); } -.focus\:hover\:font-light:focus:hover { +.focus\:hover\:font-light:hover:focus { font-weight: 300; } .disabled\:font-bold:disabled { @@ -427,24 +425,9 @@ div { .sm\:text-center { text-align: center; } - .sm\:ordinal, - .sm\:slashed-zero, - .sm\:lining-nums, - .sm\:oldstyle-nums, - .sm\:proportional-nums, - .sm\:tabular-nums, - .sm\:diagonal-fractions, - .sm\:stacked-fractions { - --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); - --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); - --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) - var(--tw-numeric-spacing) var(--tw-numeric-fraction); - } .sm\:tabular-nums { --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-font-variant-numeric); } .sm\:custom-util { background: #abcdef; diff --git a/tests/layer-at-rules.test.js b/tests/layer-at-rules.test.js index 2cb7c11b7bdb..270fa1988b4f 100644 --- a/tests/layer-at-rules.test.js +++ b/tests/layer-at-rules.test.js @@ -43,7 +43,7 @@ test('custom user-land utilities', () => { .hover\\:align-banana:hover { text-align: banana; } - .focus\\:hover\\:align-chocolate:focus:hover { + .focus\\:hover\\:align-chocolate:hover:focus { text-align: chocolate; } `) diff --git a/tests/match-components.test.js b/tests/match-components.test.js index 5390a502d049..9641dadca9d3 100644 --- a/tests/match-components.test.js +++ b/tests/match-components.test.js @@ -79,12 +79,12 @@ it('should be possible to matchComponents', () => { color: #f0f; } - .hover\\:card-\\[\\#f0f\\]:hover .hover\\:card-header:hover { + .hover\\:card-\\[\\#f0f\\]:hover .card-header { border-top-width: 3px; border-top-color: #f0f; } - .hover\\:card-\\[\\#f0f\\]:hover .hover\\:card-footer:hover { + .hover\\:card-\\[\\#f0f\\]:hover .card-footer { border-bottom-width: 3px; border-bottom-color: #f0f; } diff --git a/tests/parallel-variants.test.js b/tests/parallel-variants.test.js index 2f80046336c9..ea9cd99647f2 100644 --- a/tests/parallel-variants.test.js +++ b/tests/parallel-variants.test.js @@ -1,5 +1,3 @@ -import { transformAllSelectors, updateAllClasses } from '../src/util/pluginUtils.js' - import { run, html, css } from './util/run' test('basic parallel variants', async () => { @@ -12,21 +10,8 @@ test('basic parallel variants', async () => { }, ], plugins: [ - function test({ addVariant, config }) { - addVariant('test', [ - transformAllSelectors((selector) => { - let variantSelector = updateAllClasses(selector, (className) => { - return `test${config('separator')}${className}` - }) - - return `${variantSelector} *::test` - }), - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`test${config('separator')}${className}`, '::test') - }) - }), - ]) + function test({ addVariant }) { + addVariant('test', ['& *::test', '&::test']) }, ], } @@ -42,7 +27,7 @@ test('basic parallel variants', async () => { .test\\:font-medium *::test { font-weight: 500; } - .hover\\:test\\:font-black:hover *::test { + .hover\\:test\\:font-black *::test:hover { font-weight: 900; } .test\\:font-bold::test { @@ -51,7 +36,7 @@ test('basic parallel variants', async () => { .test\\:font-medium::test { font-weight: 500; } - .hover\\:test\\:font-black:hover::test { + .hover\\:test\\:font-black::test:hover { font-weight: 900; } `) diff --git a/tests/raw-content.test.css b/tests/raw-content.test.css index 75ea33b87906..79c3cb495749 100644 --- a/tests/raw-content.test.css +++ b/tests/raw-content.test.css @@ -513,29 +513,27 @@ font-style: normal; } .ordinal, -.slashed-zero, -.lining-nums, -.oldstyle-nums, -.proportional-nums, .tabular-nums, -.diagonal-fractions, -.stacked-fractions { +.diagonal-fractions { --tw-ordinal: var(--tw-empty, /*!*/ /*!*/); --tw-slashed-zero: var(--tw-empty, /*!*/ /*!*/); --tw-numeric-figure: var(--tw-empty, /*!*/ /*!*/); --tw-numeric-spacing: var(--tw-empty, /*!*/ /*!*/); --tw-numeric-fraction: var(--tw-empty, /*!*/ /*!*/); - font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) + --tw-font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction); } .ordinal { --tw-ordinal: ordinal; + font-variant-numeric: var(--tw-font-variant-numeric); } .tabular-nums { --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-font-variant-numeric); } .diagonal-fractions { --tw-numeric-fraction: diagonal-fractions; + font-variant-numeric: var(--tw-font-variant-numeric); } .leading-relaxed { line-height: 1.625; diff --git a/tests/resolve-defaults-at-rules.test.js b/tests/resolve-defaults-at-rules.test.js index e8831950245b..1d93aa9b7a8d 100644 --- a/tests/resolve-defaults-at-rules.test.js +++ b/tests/resolve-defaults-at-rules.test.js @@ -84,7 +84,7 @@ test('with pseudo-class variants', async () => { --tw-rotate: 3deg; transform: var(--tw-transform); } - .hover\\:focus\\:skew-y-6:hover:focus { + .hover\\:focus\\:skew-y-6:focus:hover { --tw-skew-y: 6deg; transform: var(--tw-transform); } @@ -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:hover::before { + .group:hover .group-hover\\:hover\\:before\\:scale-x-110::before:hover { content: ''; --tw-scale-x: 1.1; transform: var(--tw-transform); } - .peer:focus ~ .peer-focus\\:focus\\:after\\:rotate-3:focus::after { + .peer:focus ~ .peer-focus\\:focus\\:after\\:rotate-3::after:focus { content: ''; --tw-rotate: 3deg; transform: var(--tw-transform); diff --git a/tests/variants.test.css b/tests/variants.test.css index 5a844fabdd49..9814996d56e0 100644 --- a/tests/variants.test.css +++ b/tests/variants.test.css @@ -313,11 +313,11 @@ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } -.file\:hover\:bg-blue-600::file-selector-button:hover { +.file\:hover\:bg-blue-600:hover::file-selector-button { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity)); } -.open\:hover\:bg-red-200[open]:hover { +.open\:hover\:bg-red-200:hover[open] { --tw-bg-opacity: 1; background-color: rgb(254 202 202 / var(--tw-bg-opacity)); } @@ -326,7 +326,7 @@ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } -.focus\:hover\:shadow-md:focus:hover { +.focus\:hover\:shadow-md:hover:focus { --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -1px rgb(0 0 0 / 0.06); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); diff --git a/tests/variants.test.js b/tests/variants.test.js index b0edb5f762e0..f38c3919d879 100644 --- a/tests/variants.test.js +++ b/tests/variants.test.js @@ -1,7 +1,7 @@ import fs from 'fs' import path from 'path' -import { run, css } from './util/run' +import { run, css, html } from './util/run' test('variants', () => { let config = { @@ -24,6 +24,124 @@ test('variants', () => { }) }) +test('order matters and produces different behaviour', () => { + let config = { + content: [ + { + raw: html` +
+
+ `, + }, + ], + } + + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .hover\\:file\\:bg-pink-600::file-selector-button:hover { + --tw-bg-opacity: 1; + background-color: rgb(219 39 119 / var(--tw-bg-opacity)); + } + + .file\\:hover\\:bg-pink-600:hover::file-selector-button { + --tw-bg-opacity: 1; + background-color: rgb(219 39 119 / var(--tw-bg-opacity)); + } + `) + }) +}) + +describe('custom advanced variants', () => { + test('prose-headings usage on its own', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + plugins: [ + function ({ addVariant }) { + addVariant('prose-headings', ({ format }) => { + return format(':where(&) :is(h1, h2, h3, h4)') + }) + }, + ], + } + + return run('@tailwind components;@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + :where(.prose-headings\\:text-center) :is(h1, h2, h3, h4) { + text-align: center; + } + `) + }) + }) + + test('prose-headings with another "simple" variant', () => { + let config = { + content: [ + { + raw: html` +
+
+ `, + }, + ], + plugins: [ + function ({ addVariant }) { + addVariant('prose-headings', ({ format }) => { + return format(':where(&) :is(h1, h2, h3, h4)') + }) + }, + ], + } + + return run('@tailwind components;@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + :where(.hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4):hover { + text-align: center; + } + + :where(.prose-headings\\:hover\\:text-center:hover) :is(h1, h2, h3, h4) { + text-align: center; + } + `) + }) + }) + + test('prose-headings with another "complex" variant', () => { + let config = { + content: [ + { + raw: html` +
+
+ `, + }, + ], + plugins: [ + function ({ addVariant }) { + addVariant('prose-headings', ({ format }) => { + return format(':where(&) :is(h1, h2, h3, h4)') + }) + }, + ], + } + + return run('@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + .group:hover :where(.group-hover\\:prose-headings\\:text-center) :is(h1, h2, h3, h4) { + text-align: center; + } + + :where(.group:hover .prose-headings\\:group-hover\\:text-center) :is(h1, h2, h3, h4) { + text-align: center; + } + `) + }) + }) +}) + test('stacked peer variants', async () => { let config = { content: [{ raw: 'peer-disabled:peer-focus:peer-hover:border-blue-500' }], @@ -126,3 +244,30 @@ it('should properly handle keyframes with multiple variants', async () => { } `) }) + +test('custom addVariant with nested media & format shorthand', () => { + let config = { + content: [ + { + raw: html`
`, + }, + ], + plugins: [ + function ({ addVariant }) { + addVariant('magic', '@supports (hover: hover) { @media (print) { &:disabled } }') + }, + ], + } + + return run('@tailwind components;@tailwind utilities', config).then((result) => { + return expect(result.css).toMatchFormattedCss(css` + @supports (hover: hover) { + @media (print) { + .magic\\:text-center:disabled { + text-align: center; + } + } + } + `) + }) +})