Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/featureFlags.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ let defaults = {

let featureFlags = {
future: ['hoverOnlyWhenSupported'],
experimental: ['optimizeUniversalDefaults'],
experimental: ['optimizeUniversalDefaults', 'variantGrouping'],
}

export function flagEnabled(config, flag) {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/defaultExtractor.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { flagEnabled } from '../featureFlags.js'
import * as regex from './regex'

export function defaultExtractor(context) {
Expand All @@ -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
Expand All @@ -43,7 +45,7 @@ function* buildRegExps(context) {
// Utilities
regex.pattern([
// Utility Name / Group Name
/-?(?:\w+)/,
variantGroupingEnabled ? /-?(?:[\w,()]+)/ : /-?(?:\w+)/,

// Normal/Arbitrary values
regex.optional(
Expand Down
18 changes: 16 additions & 2 deletions src/lib/generateRules.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
})
})
Expand Down
32 changes: 19 additions & 13 deletions src/util/formatVariantSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
198 changes: 198 additions & 0 deletions tests/variant-grouping.test.js
Original file line number Diff line number Diff line change
@@ -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;
}
`)
})
})