From 5809c4d07c11808d9ff930fb41c09e37aed4176c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 18 Oct 2021 11:26:11 +0200 Subject: [PATCH] Improve `addVariant` API (#5809) * fix incorrect comment Probably messed this up in another PR, so just a bit of cleaning. * implement a formatVariantSelector function This will be used to eventually simplify the addVariant API. The idea is that it can take a list of strings that define a certain format. Then it squashes everything to a single format how you would expect it. E.g.: Input: - '&:hover' - '&:focus' - '.dark &' - ':merge(.group):hover &' - ':merge(.group):focus &' Output: - ':merge(.group):focus:hover .dark &:focus:hover' The API here is: - `&`, this means "The parent" or "The previous selector" (you can think of it like if you are using nested selectors) - `:merge(.group)`, this means insert a `.group` if it doesn't exist yet, but if it does exist already, then merge the new value with the old value. This allows us to merge group-focus, group-hover into a single `.group:focus:hover ...` * add new `format`, `withRule` and `wrap` API for addVariant * implement backwards compatibility This will ensure that the backwards compatibility for `modifySelectors` and direct mutations to the `container` will still work. We will try to capture the changes made to the `rule.selector`, we will also "backup" the existing selector. This allows us to diff the old and new selectors and determine what actually happened. Once we know this, we can restore the selector to the "old" selector and add the diffed string e.g.: `.foo &`, to the `collectedFormats` as if you called `format()` directly. This is a bunch of extra work, but it allows us to be backwards compatible. In the future we could also warn if you are using `modifySelectors`, but it is going to be a little bit tricky, because usually that's implemented by plugin authors and therefore you don't have direct control over this. Maybe we can figure out the plugin this is used in and change the warning somehow? * fix incorrect test This was clearly a bug, keyframes should not include escaped variants at all. The reason this is here in the first place is because the nodes in a keyframe are also "rule" nodes. * swap the order of pseudo states The current implementation had a strange side effect, that resulted in incorrect class definitions. When you are combining the `:hover` and `:focus` event, then there is no difference between `:hover:focus` and `:focus:hover`. However, when you use `:hover::file-selector-button` or `::file-selector-button:hover`, then there is a big difference. In the first place, you can hover over the full file input to apply changes to the `File selector button`. In the second scenario you have to hover over the `File selector button` itself to apply changes. You can think of it as function calls: - focus(hover(text-center)) What you would expect is something like this: `.focus\:hover\:text-center:hover:focus`, where `hover` is on the inside, and `focus` is on the outside. However in the current implementation this is implemented as `.focus\:hover\:text-cener:focus:hover` * add more variant tests for the new API * update parallel variants tests to make use of new API * implement core variants with new API * simplify/cleanup existing plugin utils We can get rid of this because we drastically simplified the new addVariant API. * add addVariant shorthand signature The current API looks like this: ```js addVariant('name', ({ format, wrap }) => { // Wrap in an atRule wrap(postcss.atRule({ name: 'media', params: '(prefers-reduced-motion: reduce)' })) // "Mutate" the selector, for example prepend `.dark` format('.dark &') }) ``` It is also pretty common to have this: ```js addVariant('name', ({ format }) => format('.dark &')) ``` So we simplified this to: ```js addVariant('name', '.dark &') ``` It is also pretty common to have this: ```js addVariant('name', ({ wrap }) => wrap(postcss.atRule({ name: 'media', params: '(prefers-reduced-motion: reduce)' }))) ``` So we simplified this to: ```js addVariant('name', '@media (prefers-reduced-motion: reduce)') ``` * improve fontVariantNumeric implementation We will use `@defaults`, so that only the resets are injected for the utilities we actually use. * fix typo * allow for nested addVariant shorthand This will allow to write something like: ```js addVariant('name', ` @supports (hover: hover) { @media (print) { &:hover } } `) // Or as a one-liner addVariant('name', '@supports (hover: hover) { @media (print) { &:hover } }') ``` * update changelog --- CHANGELOG.md | 1 + src/corePlugins.js | 396 +++++++---------------- src/lib/expandTailwindAtRules.js | 2 +- src/lib/generateRules.js | 91 +++++- src/lib/setupContextUtils.js | 57 +++- src/util/formatVariantSelector.js | 105 ++++++ src/util/pluginUtils.js | 136 +------- tests/apply.test.css | 33 +- tests/arbitrary-values.test.js | 2 +- tests/basic-usage.test.css | 12 +- tests/custom-plugins.test.js | 5 +- tests/format-variant-selector.test.js | 261 +++++++++++++++ tests/important-modifier-prefix.test.css | 2 +- tests/important-modifier.test.css | 2 +- tests/kitchen-sink.test.css | 49 +-- tests/layer-at-rules.test.js | 2 +- tests/match-components.test.js | 4 +- tests/parallel-variants.test.js | 23 +- tests/raw-content.test.css | 12 +- tests/resolve-defaults-at-rules.test.js | 6 +- tests/variants.test.css | 6 +- tests/variants.test.js | 147 ++++++++- 22 files changed, 831 insertions(+), 523 deletions(-) create mode 100644 src/util/formatVariantSelector.js create mode 100644 tests/format-variant-selector.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4156c040f..e2a7a2707 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 3cb91990f..aea6f3122 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 }) - - return `${variantSelector} *::selection` - }), - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`selection${config('separator')}${className}`, '::selection') - }) - }), - ]) - - addVariant( - 'file', - transformAllSelectors((selector) => { - return updateAllClasses(selector, (className, { withPseudo }) => { - return withPseudo(`file${config('separator')}${className}`, '::file-selector-button') - }) - }) - ) - - 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: '""' })) - } - }, + // TODO: Use `addVariant('after', '*::after')` instead, once `content` + // fix is implemented. + addVariant('after', ({ format, withRule }) => { + format('&::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', - ] + ].map((variant) => (Array.isArray(variant) ? variant : [variant, `:${variant}`])) - 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) - } - }) - ) + for (let [variantName, state] of pseudoVariants) { + addVariant(variantName, `&${state}`) } - 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(`group-${variantName}`, `:merge(.group)${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 - } - - 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"] &') + }) + + 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.', + ]) + + format('[dir="rtl"] &') + }) }, - 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( - 'motion-reduce', - transformLastClasses( - (className) => { - return `motion-reduce${config('separator')}${className}` - }, - { - wrap: () => - postcss.atRule({ - name: 'media', - params: '(prefers-reduced-motion: reduce)', - }), - } - ) - ) + 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 081086c35..d25a39f38 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 a6763c5e4..a6d013a32 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 53a9851ce..8935e2e0f 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 000000000..d24471cc2 --- /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 ef1cf2aae..6d8db4a21 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 1eef7c6e2..08bcbf5b8 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 cde584638..7b0b455bf 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 aeff4c30a..535560916 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 78ad7ce01..5fd8afd62 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 000000000..a32ae5025 --- /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 8bf95f968..17096025b 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 fffcd3175..11eef31f2 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 b4a2e3fd4..7fb1e7de0 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 2cb7c11b7..270fa1988 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 5390a502d..9641dadca 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 2f8004633..ea9cd9964 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 75ea33b87..79c3cb495 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 e88319502..1d93aa9b7 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 5a844fabd..9814996d5 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 b0edb5f76..f38c3919d 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; + } + } + } + `) + }) +})