Skip to content

Commit 68ff4ba

Browse files
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 <[email protected]>
1 parent 816a0f2 commit 68ff4ba

File tree

6 files changed

+238
-17
lines changed

6 files changed

+238
-17
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848
- Add arbitrary variants ([#8299](https://github.com/tailwindlabs/tailwindcss/pull/8299))
4949
- Add `matchVariant` API ([#8310](https://github.com/tailwindlabs/tailwindcss/pull/8310))
5050
- Add `prefers-contrast` media query variants ([#8410](https://github.com/tailwindlabs/tailwindcss/pull/8410))
51+
- Experimental support for variant grouping ([#8405](https://github.com/tailwindlabs/tailwindcss/pull/8405))
5152

5253
## [3.0.24] - 2022-04-12
5354

src/featureFlags.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ let defaults = {
77

88
let featureFlags = {
99
future: ['hoverOnlyWhenSupported'],
10-
experimental: ['optimizeUniversalDefaults'],
10+
experimental: ['optimizeUniversalDefaults', 'variantGrouping'],
1111
}
1212

1313
export function flagEnabled(config, flag) {

src/lib/defaultExtractor.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { flagEnabled } from '../featureFlags.js'
12
import * as regex from './regex'
23

34
export function defaultExtractor(context) {
@@ -20,6 +21,7 @@ export function defaultExtractor(context) {
2021

2122
function* buildRegExps(context) {
2223
let separator = context.tailwindConfig.separator
24+
let variantGroupingEnabled = flagEnabled(context.tailwindConfig, 'variantGrouping')
2325

2426
yield regex.pattern([
2527
// Variants
@@ -43,7 +45,7 @@ function* buildRegExps(context) {
4345
// Utilities
4446
regex.pattern([
4547
// Utility Name / Group Name
46-
/-?(?:\w+)/,
48+
variantGroupingEnabled ? /-?(?:[\w,()]+)/ : /-?(?:\w+)/,
4749

4850
// Normal/Arbitrary values
4951
regex.optional(

src/lib/generateRules.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { normalize } from '../util/dataTypes'
1212
import { isValidVariantFormatString, parseVariant } from './setupContextUtils'
1313
import isValidArbitraryValue from '../util/isValidArbitraryValue'
1414
import { splitAtTopLevelOnly } from '../util/splitAtTopLevelOnly.js'
15+
import { flagEnabled } from '../featureFlags'
1516

1617
let classNameParser = selectorParser((selectors) => {
1718
return selectors.first.filter(({ type }) => type === 'class').pop().value
@@ -444,7 +445,7 @@ function* recordCandidates(matches, classCandidate) {
444445
}
445446
}
446447

447-
function* resolveMatches(candidate, context) {
448+
function* resolveMatches(candidate, context, original = candidate) {
448449
let separator = context.tailwindConfig.separator
449450
let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
450451
let important = false
@@ -454,6 +455,15 @@ function* resolveMatches(candidate, context) {
454455
classCandidate = classCandidate.slice(1)
455456
}
456457

458+
if (flagEnabled(context.tailwindConfig, 'variantGrouping')) {
459+
if (classCandidate.startsWith('(') && classCandidate.endsWith(')')) {
460+
let base = variants.slice().reverse().join(separator)
461+
for (let part of classCandidate.slice(1, -1).split(/\,(?![^(]*\))/g)) {
462+
yield* resolveMatches(base + separator + part, context, original)
463+
}
464+
}
465+
}
466+
457467
// TODO: Reintroduce this in ways that doesn't break on false positives
458468
// function sortAgainst(toSort, against) {
459469
// return toSort.slice().sort((a, z) => {
@@ -585,7 +595,11 @@ function* resolveMatches(candidate, context) {
585595

586596
rule.selector = finalizeSelector(finalFormat, {
587597
selector: rule.selector,
588-
candidate,
598+
candidate: original,
599+
base: candidate
600+
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
601+
.pop(),
602+
589603
context,
590604
})
591605
})

src/util/formatVariantSelector.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,27 @@ export function formatVariantSelector(current, ...others) {
2929
return current
3030
}
3131

32-
export function finalizeSelector(format, { selector, candidate, context }) {
32+
export function finalizeSelector(
33+
format,
34+
{
35+
selector,
36+
candidate,
37+
context,
38+
39+
// Split by the separator, but ignore the separator inside square brackets:
40+
//
41+
// E.g.: dark:lg:hover:[paint-order:markers]
42+
// ┬ ┬ ┬ ┬
43+
// │ │ │ ╰── We will not split here
44+
// ╰──┴─────┴─────────────── We will split here
45+
//
46+
base = candidate
47+
.split(new RegExp(`\\${context?.tailwindConfig?.separator ?? ':'}(?![^[]*\\])`))
48+
.pop(),
49+
}
50+
) {
3351
let ast = selectorParser().astSync(selector)
3452

35-
let separator = context?.tailwindConfig?.separator ?? ':'
36-
37-
// Split by the separator, but ignore the separator inside square brackets:
38-
//
39-
// E.g.: dark:lg:hover:[paint-order:markers]
40-
// ┬ ┬ ┬ ┬
41-
// │ │ │ ╰── We will not split here
42-
// ╰──┴─────┴─────────────── We will split here
43-
//
44-
let splitter = new RegExp(`\\${separator}(?![^[]*\\])`)
45-
let base = candidate.split(splitter).pop()
46-
4753
if (context?.tailwindConfig?.prefix) {
4854
format = prefixSelector(context.tailwindConfig.prefix, format)
4955
}

tests/variant-grouping.test.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { run, html, css } from './util/run'
2+
3+
// TODO: Remove this once we enable this by default
4+
it('should not generate nested selectors if the feature flag is not enabled', () => {
5+
let config = {
6+
content: [{ raw: html`<div class="md:(underline,italic)"></div>` }],
7+
corePlugins: { preflight: false },
8+
plugins: [],
9+
}
10+
11+
let input = css`
12+
@tailwind utilities;
13+
`
14+
15+
return run(input, config).then((result) => {
16+
expect(result.css).toMatchFormattedCss(css`
17+
.italic {
18+
font-style: italic;
19+
}
20+
21+
.underline {
22+
text-decoration-line: underline;
23+
}
24+
`)
25+
})
26+
})
27+
28+
it('should be possible to group variants', () => {
29+
let config = {
30+
experimental: 'all',
31+
content: [{ raw: html`<div class="md:(underline,italic)"></div>` }],
32+
corePlugins: { preflight: false },
33+
plugins: [],
34+
}
35+
36+
let input = css`
37+
@tailwind utilities;
38+
`
39+
40+
return run(input, config).then((result) => {
41+
expect(result.css).toMatchFormattedCss(css`
42+
@media (min-width: 768px) {
43+
.md\:\(underline\2c italic\) {
44+
font-style: italic;
45+
text-decoration-line: underline;
46+
}
47+
}
48+
`)
49+
})
50+
})
51+
52+
it('should be possible to group multiple variants', () => {
53+
let config = {
54+
experimental: 'all',
55+
content: [{ raw: html`<div class="md:dark:(underline,italic)"></div>` }],
56+
corePlugins: { preflight: false },
57+
plugins: [],
58+
}
59+
60+
let input = css`
61+
@tailwind utilities;
62+
`
63+
64+
return run(input, config).then((result) => {
65+
expect(result.css).toMatchFormattedCss(css`
66+
@media (min-width: 768px) {
67+
@media (prefers-color-scheme: dark) {
68+
.md\:dark\:\(underline\2c italic\) {
69+
font-style: italic;
70+
text-decoration-line: underline;
71+
}
72+
}
73+
}
74+
`)
75+
})
76+
})
77+
78+
it('should be possible to group nested grouped variants', () => {
79+
let config = {
80+
experimental: 'all',
81+
content: [{ raw: html`<div class="md:(underline,italic,hover:(uppercase,font-bold))"></div>` }],
82+
corePlugins: { preflight: false },
83+
plugins: [],
84+
}
85+
86+
let input = css`
87+
@tailwind utilities;
88+
`
89+
90+
return run(input, config).then((result) => {
91+
expect(result.css).toMatchFormattedCss(css`
92+
@media (min-width: 768px) {
93+
.md\:\(underline\2c italic\2c hover\:\(uppercase\2c font-bold\)\) {
94+
font-style: italic;
95+
text-decoration-line: underline;
96+
}
97+
98+
.md\:\(underline\2c italic\2c hover\:\(uppercase\2c font-bold\)\):hover {
99+
font-weight: 700;
100+
text-transform: uppercase;
101+
}
102+
}
103+
`)
104+
})
105+
})
106+
107+
it('should be possible to use nested multiple grouped variants', () => {
108+
let config = {
109+
experimental: 'all',
110+
content: [
111+
{
112+
raw: html`<div class="md:(text-black,dark:(text-white,hover:focus:text-gray-100))"></div>`,
113+
},
114+
],
115+
corePlugins: { preflight: false },
116+
plugins: [],
117+
}
118+
119+
let input = css`
120+
@tailwind utilities;
121+
`
122+
123+
return run(input, config).then((result) => {
124+
expect(result.css).toMatchFormattedCss(css`
125+
@media (min-width: 768px) {
126+
.md\:\(text-black\2c dark\:\(text-white\2c hover\:focus\:text-gray-100\)\) {
127+
--tw-text-opacity: 1;
128+
color: rgb(0 0 0 / var(--tw-text-opacity));
129+
}
130+
131+
@media (prefers-color-scheme: dark) {
132+
.md\:\(text-black\2c dark\:\(text-white\2c hover\:focus\:text-gray-100\)\) {
133+
--tw-text-opacity: 1;
134+
color: rgb(255 255 255 / var(--tw-text-opacity));
135+
}
136+
.md\:\(text-black\2c dark\:\(text-white\2c hover\:focus\:text-gray-100\)\):focus:hover {
137+
--tw-text-opacity: 1;
138+
color: rgb(243 244 246 / var(--tw-text-opacity));
139+
}
140+
}
141+
}
142+
`)
143+
})
144+
})
145+
146+
it('should group with variants defined in external plugins', () => {
147+
let config = {
148+
experimental: 'all',
149+
content: [
150+
{
151+
raw: html`
152+
<div class="ui-active:(bg-black,text-white) ui-selected:(bg-indigo-500,underline)"></div>
153+
`,
154+
},
155+
],
156+
corePlugins: { preflight: false },
157+
plugins: [
158+
({ addVariant }) => {
159+
addVariant('ui-active', ['&[data-ui-state~="active"]', '[data-ui-state~="active"] &'])
160+
addVariant('ui-selected', ['&[data-ui-state~="selected"]', '[data-ui-state~="selected"] &'])
161+
},
162+
],
163+
}
164+
165+
let input = css`
166+
@tailwind utilities;
167+
`
168+
169+
return run(input, config).then((result) => {
170+
expect(result.css).toMatchFormattedCss(css`
171+
.ui-active\:\(bg-black\2c text-white\)[data-ui-state~='active'] {
172+
--tw-bg-opacity: 1;
173+
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
174+
--tw-text-opacity: 1;
175+
color: rgb(255 255 255 / var(--tw-text-opacity));
176+
}
177+
178+
[data-ui-state~='active'] .ui-active\:\(bg-black\2c text-white\) {
179+
--tw-bg-opacity: 1;
180+
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
181+
--tw-text-opacity: 1;
182+
color: rgb(255 255 255 / var(--tw-text-opacity));
183+
}
184+
185+
.ui-selected\:\(bg-indigo-500\2c underline\)[data-ui-state~='selected'] {
186+
--tw-bg-opacity: 1;
187+
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
188+
text-decoration-line: underline;
189+
}
190+
191+
[data-ui-state~='selected'] .ui-selected\:\(bg-indigo-500\2c underline\) {
192+
--tw-bg-opacity: 1;
193+
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
194+
text-decoration-line: underline;
195+
}
196+
`)
197+
})
198+
})

0 commit comments

Comments
 (0)