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
This commit is contained in:
Robin Malfait 2021-10-18 11:26:11 +02:00 committed by GitHub
parent aeaa2a376b
commit 5809c4d07c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 831 additions and 523 deletions

View File

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

View File

@ -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)',
},
})
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)'
)
})
})
})

View File

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

View File

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

View File

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

View File

@ -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;
}
`)

View File

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

View File

@ -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;
}
`)

View File

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

View File

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

View File

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

View File

@ -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`
<div class="hover:file:bg-pink-600"></div>
<div class="file:hover:bg-pink-600"></div>
`,
},
],
}
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` <div class="prose-headings:text-center"></div> `,
},
],
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`
<div class="hover:prose-headings:text-center"></div>
<div class="prose-headings:hover:text-center"></div>
`,
},
],
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`
<div class="group-hover:prose-headings:text-center"></div>
<div class="prose-headings:group-hover:text-center"></div>
`,
},
],
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` <div class="magic:text-center"></div> `,
},
],
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;
}
}
}
`)
})
})