diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f81b902e..edc653a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/featureFlags.js b/src/featureFlags.js index c34788ea1..3b77958d1 100644 --- a/src/featureFlags.js +++ b/src/featureFlags.js @@ -7,7 +7,7 @@ let defaults = { let featureFlags = { future: ['hoverOnlyWhenSupported'], - experimental: ['optimizeUniversalDefaults'], + experimental: ['optimizeUniversalDefaults', 'variantGrouping'], } export function flagEnabled(config, flag) { diff --git a/src/lib/defaultExtractor.js b/src/lib/defaultExtractor.js index 953f82d7e..cde750c48 100644 --- a/src/lib/defaultExtractor.js +++ b/src/lib/defaultExtractor.js @@ -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( diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 929d30156..ea9872575 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -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, }) }) diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js index b3aba6401..2684cdc7f 100644 --- a/src/util/formatVariantSelector.js +++ b/src/util/formatVariantSelector.js @@ -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) } diff --git a/tests/variant-grouping.test.js b/tests/variant-grouping.test.js new file mode 100644 index 000000000..6e24e2c33 --- /dev/null +++ b/tests/variant-grouping.test.js @@ -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`
` }], + 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`
` }], + 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`
` }], + 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`
` }], + 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`
`, + }, + ], + 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` +
+ `, + }, + ], + 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; + } + `) + }) +})