Arbitrary variants (#8299)

* register arbitrary variants

With the new `addVariant` API, we have a beautiful way of creating new
variants.

You can use it as:
```js
addVariant('children', '& > *')
```

Now you can use the `children:` variant. The API uses a `&` as a
reference for the candidate, which means that:
```html
children:pl-4
```

Will result in:
```css
.children\:pl-4 > * { .. }
```

Notice that the `&` was replaced by `.children\:pl-4`.

We can leverage this API to implement arbitrary variants, this means
that you can write those `&>*` (Notice that we don't have spaces) inside
a variant directly. An example of this can be:
```html
<ul class="[&>*]:underline">
  <li>A</li>
  <li>B</li>
  <li>C</li>
</ul>
```
Which generates the following css:
```css
.\[\&\>\*\]\:underline > * {
  text-decoration-line: underline;
}
```

Now all the children of the `ul` will have an `underline`. The selector
itself is a bit crazy since it contains the candidate which is the
selector itself, it is just escaped.

* add tests for arbitrary variants

This still requires some work to the `defaultExtractor` to make sure it
all works with existing code.

* update changelog

* Fix candidate detection for arbitrary variants

* Refactor

* Add support for at rules

* Add test for attribute selectors

* Fix test

* Add attribute selector support

* Split top-level comma parsing into a generalized splitting routine

We can now split on any character at the top level with any nesting. We don’t balance brackets directly here but this is probably “enough”

* Split variants by separator at the top-level only

This means that the separator has to be ouside of balanced brackets

* Fix extraction when using custom variant separators

* Support custom separators when top-level splitting variants

* Add a second multi-character separator test

* Split tests for at-rule and at-rule with selector changes

* Add nested at-rule tests

* Fix space-less at-rule parsing in addVariant

* Add test for using with `@apply`

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Robin Malfait 2022-05-08 18:24:59 +02:00 committed by GitHub
parent cea3ccf14d
commit be51739337
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 524 additions and 87 deletions

View File

@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `backdrop` variant ([#7924](https://github.com/tailwindlabs/tailwindcss/pull/7924))
- Add `grid-flow-dense` utility ([#8193](https://github.com/tailwindlabs/tailwindcss/pull/8193))
- Add `mix-blend-plus-lighter` utility ([#8288](https://github.com/tailwindlabs/tailwindcss/pull/8288))
- Add arbitrary variants ([#8299](https://github.com/tailwindlabs/tailwindcss/pull/8299))
## [3.0.24] - 2022-04-12

View File

@ -1,25 +1,34 @@
import * as regex from './regex'
let patterns = Array.from(buildRegExps())
export function defaultExtractor(context) {
let patterns = Array.from(buildRegExps(context))
/**
* @param {string} content
*/
export function defaultExtractor(content) {
/** @type {(string|string)[]} */
let results = []
/**
* @param {string} content
*/
return (content) => {
/** @type {(string|string)[]} */
let results = []
for (let pattern of patterns) {
results.push(...(content.match(pattern) ?? []))
for (let pattern of patterns) {
results.push(...(content.match(pattern) ?? []))
}
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
}
return results.filter((v) => v !== undefined).map(clipAtBalancedParens)
}
function* buildRegExps() {
function* buildRegExps(context) {
let separator = context.tailwindConfig.separator
yield regex.pattern([
// Variants
/((?=([^\s"'\\\[]+:))\2)?/,
'((?=((',
regex.any(
[regex.pattern([/\[[^\s"'\\]+\]/, separator]), regex.pattern([/[^\s"'\[\\]+/, separator])],
true
),
')+))\\2)?',
// Important (optional)
/!?/,

View File

@ -24,7 +24,7 @@ function getExtractor(context, fileExtension) {
extractors[fileExtension] ||
extractors.DEFAULT ||
builtInExtractors[fileExtension] ||
builtInExtractors.DEFAULT
builtInExtractors.DEFAULT(context)
)
}

View File

@ -9,7 +9,9 @@ import * as sharedState from './sharedState'
import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
import { asClass } from '../util/nameClass'
import { normalize } from '../util/dataTypes'
import { parseVariant } from './setupContextUtils'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
let classNameParser = selectorParser((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
@ -125,6 +127,17 @@ function applyVariant(variant, matches, context) {
return matches
}
// Register arbitrary variants
if (isArbitraryValue(variant) && !context.variantMap.has(variant)) {
let selector = normalize(variant.slice(1, -1))
let fn = parseVariant(selector)
let sort = Array.from(context.variantOrder.values()).pop() << 1n
context.variantMap.set(variant, [[sort, fn]])
context.variantOrder.set(variant, sort)
}
if (context.variantMap.has(variant)) {
let variantFunctionTuples = context.variantMap.get(variant)
let result = []
@ -407,7 +420,7 @@ function splitWithSeparator(input, separator) {
return [sharedState.NOT_ON_DEMAND]
}
return input.split(new RegExp(`\\${separator}(?![^[]*\\])`, 'g'))
return Array.from(splitAtTopLevelOnly(input, separator))
}
function* recordCandidates(matches, classCandidate) {

View File

@ -170,6 +170,30 @@ function withIdentifiers(styles) {
})
}
export function parseVariant(variant) {
variant = variant
.replace(/\n+/g, '')
.replace(/\s{1,}/g, ' ')
.trim()
let fns = parseVariantFormatString(variant)
.map((str) => {
if (!str.startsWith('@')) {
return ({ format }) => format(str)
}
let [, name, params] = /@(.*?)( .+|[({].*)/g.exec(str)
return ({ wrap }) => wrap(postcss.atRule({ name, params: params.trim() }))
})
.reverse()
return (api) => {
for (let fn of fns) {
fn(api)
}
}
}
function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
function getConfigValue(path, defaultValue) {
return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
@ -201,27 +225,7 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
}
}
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)
}
}
return parseVariant(variantFunction)
})
insertInto(variantList, variantName, options)

View File

@ -1,58 +1,11 @@
import { splitAtTopLevelOnly } from './splitAtTopLevelOnly'
let KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
let SPACE = /\ +(?![^(]*\))/g // Similar to the one above, but with spaces instead.
let LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
let SPECIALS = /[(),]/g
/**
* This splits a string on top-level commas.
*
* Regex doesn't support recursion (at least not the JS-flavored version).
* So we have to use a tiny state machine to keep track of paren vs comma
* placement. Before we'd only exclude commas from the inner-most nested
* set of parens rather than any commas that were not contained in parens
* at all which is the intended behavior here.
*
* Expected behavior:
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
*
* x x x Split because top-level
* Ignored b/c inside >= 1 levels of parens
*
* @param {string} input
*/
function* splitByTopLevelCommas(input) {
SPECIALS.lastIndex = -1
let depth = 0
let lastIndex = 0
let found = false
// Find all parens & commas
// And only split on commas if they're top-level
for (let match of input.matchAll(SPECIALS)) {
if (match[0] === '(') depth++
if (match[0] === ')') depth--
if (match[0] === ',' && depth === 0) {
found = true
yield input.substring(lastIndex, match.index)
lastIndex = match.index + match[0].length
}
}
// Provide the last segment of the string if available
// Otherwise the whole string since no commas were found
// This mirrors the behavior of string.split()
if (found) {
yield input.substring(lastIndex)
} else {
yield input
}
}
export function parseBoxShadowValue(input) {
let shadows = Array.from(splitByTopLevelCommas(input))
let shadows = Array.from(splitAtTopLevelOnly(input, ','))
return shadows.map((shadow) => {
let value = shadow.trim()
let result = { raw: value }

View File

@ -0,0 +1,71 @@
import * as regex from '../lib/regex'
/**
* This splits a string on a top-level character.
*
* Regex doesn't support recursion (at least not the JS-flavored version).
* So we have to use a tiny state machine to keep track of paren placement.
*
* Expected behavior using commas:
* var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)
*
* x x x Split because top-level
* Ignored b/c inside >= 1 levels of parens
*
* @param {string} input
* @param {string} separator
*/
export function* splitAtTopLevelOnly(input, separator) {
let SPECIALS = new RegExp(`[(){}\\[\\]${regex.escape(separator)}]`, 'g')
let depth = 0
let lastIndex = 0
let found = false
let separatorIndex = 0
let separatorStart = 0
let separatorLength = separator.length
// Find all paren-like things & character
// And only split on commas if they're top-level
for (let match of input.matchAll(SPECIALS)) {
let matchesSeparator = match[0] === separator[separatorIndex]
let atEndOfSeparator = separatorIndex === separatorLength - 1
let matchesFullSeparator = matchesSeparator && atEndOfSeparator
if (match[0] === '(') depth++
if (match[0] === ')') depth--
if (match[0] === '[') depth++
if (match[0] === ']') depth--
if (match[0] === '{') depth++
if (match[0] === '}') depth--
if (matchesSeparator && depth === 0) {
if (separatorStart === 0) {
separatorStart = match.index
}
separatorIndex++
}
if (matchesFullSeparator && depth === 0) {
found = true
yield input.substring(lastIndex, separatorStart)
lastIndex = separatorStart + separatorLength
}
if (separatorIndex === separatorLength) {
separatorIndex = 0
separatorStart = 0
}
}
// Provide the last segment of the string if available
// Otherwise the whole string since no `char`s were found
// This mirrors the behavior of string.split()
if (found) {
yield input.substring(lastIndex)
} else {
yield input
}
}

View File

@ -0,0 +1,379 @@
import { run, html, css, defaults } from './util/run'
test('basic arbitrary variants', () => {
let config = {
content: [{ raw: html`<div class="[&>*]:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\>\*\]\:underline > * {
text-decoration-line: underline;
}
`)
})
})
test('spaces in selector (using _)', () => {
let config = {
content: [
{
raw: html`<div class="[.a.b_&]:underline"></div>`,
},
],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.a.b .\[\.a\.b_\&\]\:underline {
text-decoration-line: underline;
}
`)
})
})
test('arbitrary variants with modifiers', () => {
let config = {
content: [{ raw: html`<div class="dark:lg:hover:[&>*]:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
@media (prefers-color-scheme: dark) {
@media (min-width: 1024px) {
.dark\:lg\:hover\:\[\&\>\*\]\:underline > *:hover {
text-decoration-line: underline;
}
}
}
`)
})
})
test('arbitrary variants are sorted after other variants', () => {
let config = {
content: [{ raw: html`<div class="[&>*]:underline underline lg:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.underline {
text-decoration-line: underline;
}
@media (min-width: 1024px) {
.lg\:underline {
text-decoration-line: underline;
}
}
.\[\&\>\*\]\:underline > * {
text-decoration-line: underline;
}
`)
})
})
test('using the important modifier', () => {
let config = {
content: [{ raw: html`<div class="[&>*]:!underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\>\*\]\:\!underline > * {
text-decoration-line: underline !important;
}
`)
})
})
test('at-rules', () => {
let config = {
content: [{ raw: html`<div class="[@supports(what:ever)]:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
@supports (what: ever) {
.\[\@supports\(what\:ever\)\]\:underline {
text-decoration-line: underline;
}
}
`)
})
})
test('nested at-rules', () => {
let config = {
content: [
{
raw: html`<div class="[@media_screen{@media(hover:hover)}]:underline"></div>`,
},
],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
@media screen {
@media (hover: hover) {
.\[\@media_screen\{\@media\(hover\:hover\)\}\]\:underline {
text-decoration-line: underline;
}
}
}
`)
})
})
test('at-rules with selector modifications', () => {
let config = {
content: [{ raw: html`<div class="[@media(hover:hover){&:hover}]:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
@media (hover: hover) {
.\[\@media\(hover\:hover\)\{\&\:hover\}\]\:underline:hover {
text-decoration-line: underline;
}
}
`)
})
})
test('nested at-rules with selector modifications', () => {
let config = {
content: [
{
raw: html`<div class="[@media_screen{@media(hover:hover){&:hover}}]:underline"></div>`,
},
],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
@media screen {
@media (hover: hover) {
.\[\@media_screen\{\@media\(hover\:hover\)\{\&\:hover\}\}\]\:underline:hover {
text-decoration-line: underline;
}
}
}
`)
})
})
test('attribute selectors', () => {
let config = {
content: [{ raw: html`<div class="[&[data-open]]:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\[data-open\]\]\:underline[data-open] {
text-decoration-line: underline;
}
`)
})
})
test('multiple attribute selectors', () => {
let config = {
content: [{ raw: html`<div class="[&[data-foo][data-bar]:not([data-baz])]:underline"></div>` }],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\[data-foo\]\[data-bar\]\:not\(\[data-baz\]\)\]\:underline[data-foo][data-bar]:not([data-baz]) {
text-decoration-line: underline;
}
`)
})
})
test('multiple attribute selectors with custom separator (1)', () => {
let config = {
separator: '__',
content: [
{ raw: html`<div class="[&[data-foo][data-bar]:not([data-baz])]__underline"></div>` },
],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\[data-foo\]\[data-bar\]\:not\(\[data-baz\]\)\]__underline[data-foo][data-bar]:not([data-baz]) {
text-decoration-line: underline;
}
`)
})
})
test('multiple attribute selectors with custom separator (2)', () => {
let config = {
separator: '_@',
content: [
{ raw: html`<div class="[&[data-foo][data-bar]:not([data-baz])]_@underline"></div>` },
],
corePlugins: { preflight: false },
}
let input = css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
.\[\&\[data-foo\]\[data-bar\]\:not\(\[data-baz\]\)\]_\@underline[data-foo][data-bar]:not([data-baz]) {
text-decoration-line: underline;
}
`)
})
})
test('with @apply', () => {
let config = {
content: [
{
raw: html`<div class="foo"></div>`,
},
],
corePlugins: { preflight: false },
}
let input = `
@tailwind base;
@tailwind components;
@tailwind utilities;
.foo {
@apply [@media_screen{@media(hover:hover){&:hover}}]:underline;
}
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
${defaults}
@media screen {
@media (hover: hover) {
.foo:hover {
text-decoration-line: underline;
}
}
}
`)
})
})

View File

@ -1,5 +1,5 @@
import { html } from './util/run'
import { defaultExtractor } from '../src/lib/defaultExtractor'
import { defaultExtractor as createDefaultExtractor } from '../src/lib/defaultExtractor'
const jsExamples = `
document.body.classList.add(["pl-1.5"].join(" "));
@ -163,6 +163,13 @@ const excludes = [
`test`,
]
let defaultExtractor
beforeEach(() => {
let context = { tailwindConfig: { separator: ':' } }
defaultExtractor = createDefaultExtractor(context)
})
test('The default extractor works as expected', async () => {
const extractions = defaultExtractor([jsExamples, jsxExamples, htmlExamples].join('\n').trim())