Experimental support for variant grouping (#8405)

* WIP

* use correct separator

* run all tests

* Fix regex

* add a few more tests

* name the experimental feature flag `variantGrouping`

* update changelog

* rename test file `variant-grouping`

Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait 2022-05-23 17:45:23 +02:00 committed by GitHub
parent 816a0f26c9
commit 68ff4ba500
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 238 additions and 17 deletions

View File

@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add arbitrary variants ([#8299](https://github.com/tailwindlabs/tailwindcss/pull/8299))
- Add `matchVariant` API ([#8310](https://github.com/tailwindlabs/tailwindcss/pull/8310))
- Add `prefers-contrast` media query variants ([#8410](https://github.com/tailwindlabs/tailwindcss/pull/8410))
- Experimental support for variant grouping ([#8405](https://github.com/tailwindlabs/tailwindcss/pull/8405))
## [3.0.24] - 2022-04-12

View File

@ -7,7 +7,7 @@ let defaults = {
let featureFlags = {
future: ['hoverOnlyWhenSupported'],
experimental: ['optimizeUniversalDefaults'],
experimental: ['optimizeUniversalDefaults', 'variantGrouping'],
}
export function flagEnabled(config, flag) {

View File

@ -1,3 +1,4 @@
import { flagEnabled } from '../featureFlags.js'
import * as regex from './regex'
export function defaultExtractor(context) {
@ -20,6 +21,7 @@ export function defaultExtractor(context) {
function* buildRegExps(context) {
let separator = context.tailwindConfig.separator
let variantGroupingEnabled = flagEnabled(context.tailwindConfig, 'variantGrouping')
yield regex.pattern([
// Variants
@ -43,7 +45,7 @@ function* buildRegExps(context) {
// Utilities
regex.pattern([
// Utility Name / Group Name
/-?(?:\w+)/,
variantGroupingEnabled ? /-?(?:[\w,()]+)/ : /-?(?:\w+)/,
// Normal/Arbitrary values
regex.optional(

View File

@ -12,6 +12,7 @@ import { normalize } from '../util/dataTypes'
import { isValidVariantFormatString, parseVariant } from './setupContextUtils'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
import { flagEnabled } from '../featureFlags'
let classNameParser = selectorParser((selectors) => {
return selectors.first.filter(({ type }) => type === 'class').pop().value
@ -444,7 +445,7 @@ function* recordCandidates(matches, classCandidate) {
}
}
function* resolveMatches(candidate, context) {
function* resolveMatches(candidate, context, original = candidate) {
let separator = context.tailwindConfig.separator
let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
let important = false
@ -454,6 +455,15 @@ function* resolveMatches(candidate, context) {
classCandidate = classCandidate.slice(1)
}
if (flagEnabled(context.tailwindConfig, 'variantGrouping')) {
if (classCandidate.startsWith('(') && classCandidate.endsWith(')')) {
let base = variants.slice().reverse().join(separator)
for (let part of classCandidate.slice(1, -1).split(/\,(?![^(]*\))/g)) {
yield* resolveMatches(base + separator + part, context, original)
}
}
}
// TODO: Reintroduce this in ways that doesn't break on false positives
// function sortAgainst(toSort, against) {
// return toSort.slice().sort((a, z) => {
@ -585,7 +595,11 @@ function* resolveMatches(candidate, context) {
rule.selector = finalizeSelector(finalFormat, {
selector: rule.selector,
candidate,
candidate: original,
base: candidate
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
.pop(),
context,
})
})

View File

@ -29,21 +29,27 @@ export function formatVariantSelector(current, ...others) {
return current
}
export function finalizeSelector(format, { selector, candidate, context }) {
export function finalizeSelector(
format,
{
selector,
candidate,
context,
// Split by the separator, but ignore the separator inside square brackets:
//
// E.g.: dark:lg:hover:[paint-order:markers]
// ┬ ┬ ┬ ┬
// │ │ │ ╰── We will not split here
// ╰──┴─────┴─────────────── We will split here
//
base = candidate
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
.pop(),
}
) {
let ast = selectorParser().astSync(selector)
let separator = context?.tailwindConfig?.separator ?? ':'
// Split by the separator, but ignore the separator inside square brackets:
//
// E.g.: dark:lg:hover:[paint-order:markers]
// ┬ ┬ ┬ ┬
// │ │ │ ╰── We will not split here
// ╰──┴─────┴─────────────── We will split here
//
let splitter = new RegExp(`\\${separator}(?![^[]*\\])`)
let base = candidate.split(splitter).pop()
if (context?.tailwindConfig?.prefix) {
format = prefixSelector(context.tailwindConfig.prefix, format)
}

View File

@ -0,0 +1,198 @@
import { run, html, css } from './util/run'
// TODO: Remove this once we enable this by default
it('should not generate nested selectors if the feature flag is not enabled', () => {
let config = {
content: [{ raw: html`<div class="md:(underline,italic)"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.italic {
font-style: italic;
}
.underline {
text-decoration-line: underline;
}
`)
})
})
it('should be possible to group variants', () => {
let config = {
experimental: 'all',
content: [{ raw: html`<div class="md:(underline,italic)"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
@media (min-width: 768px) {
.md\:\(underline\2c italic\) {
font-style: italic;
text-decoration-line: underline;
}
}
`)
})
})
it('should be possible to group multiple variants', () => {
let config = {
experimental: 'all',
content: [{ raw: html`<div class="md:dark:(underline,italic)"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
@media (min-width: 768px) {
@media (prefers-color-scheme: dark) {
.md\:dark\:\(underline\2c italic\) {
font-style: italic;
text-decoration-line: underline;
}
}
}
`)
})
})
it('should be possible to group nested grouped variants', () => {
let config = {
experimental: 'all',
content: [{ raw: html`<div class="md:(underline,italic,hover:(uppercase,font-bold))"></div>` }],
corePlugins: { preflight: false },
plugins: [],
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
@media (min-width: 768px) {
.md\:\(underline\2c italic\2c hover\:\(uppercase\2c font-bold\)\) {
font-style: italic;
text-decoration-line: underline;
}
.md\:\(underline\2c italic\2c hover\:\(uppercase\2c font-bold\)\):hover {
font-weight: 700;
text-transform: uppercase;
}
}
`)
})
})
it('should be possible to use nested multiple grouped variants', () => {
let config = {
experimental: 'all',
content: [
{
raw: html`<div class="md:(text-black,dark:(text-white,hover:focus:text-gray-100))"></div>`,
},
],
corePlugins: { preflight: false },
plugins: [],
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
@media (min-width: 768px) {
.md\:\(text-black\2c dark\:\(text-white\2c hover\:focus\:text-gray-100\)\) {
--tw-text-opacity: 1;
color: rgb(0 0 0 / var(--tw-text-opacity));
}
@media (prefers-color-scheme: dark) {
.md\:\(text-black\2c dark\:\(text-white\2c hover\:focus\:text-gray-100\)\) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.md\:\(text-black\2c dark\:\(text-white\2c hover\:focus\:text-gray-100\)\):focus:hover {
--tw-text-opacity: 1;
color: rgb(243 244 246 / var(--tw-text-opacity));
}
}
}
`)
})
})
it('should group with variants defined in external plugins', () => {
let config = {
experimental: 'all',
content: [
{
raw: html`
<div class="ui-active:(bg-black,text-white) ui-selected:(bg-indigo-500,underline)"></div>
`,
},
],
corePlugins: { preflight: false },
plugins: [
({ addVariant }) => {
addVariant('ui-active', ['&[data-ui-state~="active"]', '[data-ui-state~="active"] &'])
addVariant('ui-selected', ['&[data-ui-state~="selected"]', '[data-ui-state~="selected"] &'])
},
],
}
let input = css`
@tailwind utilities;
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.ui-active\:\(bg-black\2c text-white\)[data-ui-state~='active'] {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
[data-ui-state~='active'] .ui-active\:\(bg-black\2c text-white\) {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.ui-selected\:\(bg-indigo-500\2c underline\)[data-ui-state~='selected'] {
--tw-bg-opacity: 1;
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
text-decoration-line: underline;
}
[data-ui-state~='selected'] .ui-selected\:\(bg-indigo-500\2c underline\) {
--tw-bg-opacity: 1;
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
text-decoration-line: underline;
}
`)
})
})