diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3a0f0610f3..d2ea7f5e3536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Don't emit generated utilities with invalid uses of theme functions ([#9319](https://github.com/tailwindlabs/tailwindcss/pull/9319)) - Revert change that only listened for stdin close on TTYs ([#9331](https://github.com/tailwindlabs/tailwindcss/pull/9331)) - Ignore unset values (like `null` or `undefined`) when resolving the classList for intellisense ([#9385](https://github.com/tailwindlabs/tailwindcss/pull/9385)) +- Support `sort` function in `matchVariant` ([#9423](https://github.com/tailwindlabs/tailwindcss/pull/9423)) ## [3.1.8] - 2022-08-05 diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js index 116813a6d1ff..c8efcf8245c3 100644 --- a/src/lib/generateRules.js +++ b/src/lib/generateRules.js @@ -294,7 +294,11 @@ function applyVariant(variant, matches, context) { let withOffset = [ { ...meta, - sort: context.offsets.applyVariantOffset(meta.sort, variantSort), + sort: context.offsets.applyVariantOffset( + meta.sort, + variantSort, + Object.assign({ value: args }, context.variantOptions.get(variant)) + ), collectedFormats: (meta.collectedFormats ?? []).concat(collectedFormats), isArbitraryVariant: isArbitraryValue(variant), }, diff --git a/src/lib/offsets.js b/src/lib/offsets.js index 1aea30a698f9..f55c2a03f5a9 100644 --- a/src/lib/offsets.js +++ b/src/lib/offsets.js @@ -6,6 +6,13 @@ import bigSign from '../util/bigSign' * @typedef {'base' | 'defaults' | 'components' | 'utilities' | 'variants' | 'user'} Layer */ +/** + * @typedef {object} VariantOption + * @property {number} id An unique identifier to identify `matchVariant` + * @property {function | undefined} sort The sort function + * @property {string} value The value we want to compare + */ + /** * @typedef {object} RuleOffset * @property {Layer} layer The layer that this rule belongs to @@ -14,6 +21,7 @@ import bigSign from '../util/bigSign' * @property {bigint} variants Dynamic size. 1 bit per registered variant. 0n means no variants * @property {bigint} parallelIndex Rule index for the parallel variant. 0 if not applicable. * @property {bigint} index Index of the rule / utility in it's given *parent* layer. Monotonically increasing. + * @property {VariantOption[]} options Some information on how we can sort arbitrary variants */ export class Offsets { @@ -77,6 +85,7 @@ export class Offsets { variants: 0n, parallelIndex: 0n, index: this.offsets[layer]++, + options: [], } } @@ -112,14 +121,16 @@ export class Offsets { /** * @param {RuleOffset} rule * @param {RuleOffset} variant + * @param {VariantOption} options * @returns {RuleOffset} */ - applyVariantOffset(rule, variant) { + applyVariantOffset(rule, variant, options) { return { ...rule, layer: 'variants', parentLayer: rule.layer === 'variants' ? rule.parentLayer : rule.layer, variants: rule.variants | variant.variants, + options: options.sort ? [].concat(options, rule.options) : rule.options, // TODO: Technically this is wrong. We should be handling parallel index on a per variant basis. // We'll take the max of all the parallel indexes for now. @@ -151,7 +162,7 @@ export class Offsets { * @param {(name: string) => number} getLength */ recordVariants(variants, getLength) { - for (const variant of variants) { + for (let variant of variants) { this.recordVariant(variant, getLength(variant)) } } @@ -193,6 +204,16 @@ export class Offsets { return this.layerPositions[a.layer] - this.layerPositions[b.layer] } + // Sort based on the sorting function + for (let aOptions of a.options) { + for (let bOptions of b.options) { + if (aOptions.id !== bOptions.id) continue + if (!aOptions.sort || !bOptions.sort) continue + let result = aOptions.sort(aOptions.value, bOptions.value) + if (result !== 0) return result + } + } + // Sort variants in the order they were registered if (a.variants !== b.variants) { return a.variants - b.variants diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index d15e8db8c70c..ae88e3a636d7 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -496,19 +496,23 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs insertInto(variantList, variantName, options) variantMap.set(variantName, variantFunctions) + context.variantOptions.set(variantName, options) }, } if (flagEnabled(tailwindConfig, 'matchVariant')) { + let variantIdentifier = 0 api.matchVariant = function (variant, variantFn, options) { + let id = ++variantIdentifier // A unique identifier that "groups" these variables together. + for (let [key, value] of Object.entries(options?.values ?? {})) { - api.addVariant(`${variant}-${key}`, variantFn({ value })) + api.addVariant(`${variant}-${key}`, variantFn({ value }), { ...options, value, id }) } api.addVariant( variant, Object.assign(({ args }) => variantFn({ value: args }), { [MATCH_VARIANT]: true }), - options + { ...options, id } ) } } @@ -919,6 +923,7 @@ export function createContext(tailwindConfig, changedContent = [], root = postcs changedContent: changedContent, variantMap: new Map(), stylesheetCache: null, + variantOptions: new Map(), markInvalidUtilityCandidate: (candidate) => markInvalidUtilityCandidate(context, candidate), markInvalidUtilityNode: (node) => markInvalidUtilityNode(context, node), diff --git a/tests/match-variants.test.js b/tests/match-variants.test.js index b87c84b89236..fce47734eb87 100644 --- a/tests/match-variants.test.js +++ b/tests/match-variants.test.js @@ -234,3 +234,438 @@ test('matchVariant can return an array of format strings from the function', () `) }) }) + +it('should be possible to sort variants', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a) - parseInt(z) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 500px) { + .min-\[500px\]\:underline { + text-decoration-line: underline; + } + } + + @media (min-width: 700px) { + .min-\[700px\]\:italic { + font-style: italic; + } + } + `) + }) +}) + +it('should be possible to compare arbitrary variants and hardcoded variants', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + values: { + example: '600px', + }, + sort(a, z) { + return parseInt(a) - parseInt(z) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 500px) { + .min-\[500px\]\:italic { + font-style: italic; + } + } + + @media (min-width: 600px) { + .min-example\:italic { + font-style: italic; + } + } + + @media (min-width: 700px) { + .min-\[700px\]\:italic { + font-style: italic; + } + } + `) + }) +}) + +it('should be possible to sort stacked arbitrary variants correctly', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+ +
+ +
+ +
+ +
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a) - parseInt(z) + }, + }) + + matchVariant('max', ({ value }) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z) - parseInt(a) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 400px) { + .min-\[100px\]\:max-\[400px\]\:underline { + text-decoration-line: underline; + } + } + @media (max-width: 350px) { + .min-\[100px\]\:max-\[350px\]\:underline { + text-decoration-line: underline; + } + } + @media (max-width: 300px) { + .min-\[100px\]\:max-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + + @media (min-width: 150px) { + @media (max-width: 400px) { + .min-\[150px\]\:max-\[400px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +it('should maintain sort from other variants, if sort functions of arbitrary variants return 0', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a) - parseInt(z) + }, + }) + + matchVariant('max', ({ value }) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z) - parseInt(a) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 200px) { + .min-\[100px\]\:max-\[200px\]\:hover\:underline:hover { + text-decoration-line: underline; + } + .min-\[100px\]\:max-\[200px\]\:focus\:underline:focus { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +it('should sort arbitrary variants left to right (1)', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+
+
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a) - parseInt(z) + }, + }) + matchVariant('max', ({ value }) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z) - parseInt(a) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 400px) { + .min-\[100px\]\:max-\[400px\]\:underline { + text-decoration-line: underline; + } + } + + @media (max-width: 300px) { + .min-\[100px\]\:max-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + + @media (min-width: 200px) { + @media (max-width: 400px) { + .min-\[200px\]\:max-\[400px\]\:underline { + text-decoration-line: underline; + } + } + + @media (max-width: 300px) { + .min-\[200px\]\:max-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +it('should sort arbitrary variants left to right (2)', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+
+
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + sort(a, z) { + return parseInt(a) - parseInt(z) + }, + }) + matchVariant('max', ({ value }) => `@media (max-width: ${value})`, { + sort(a, z) { + return parseInt(z) - parseInt(a) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (max-width: 400px) { + @media (min-width: 100px) { + .max-\[400px\]\:min-\[100px\]\:underline { + text-decoration-line: underline; + } + } + @media (min-width: 200px) { + .max-\[400px\]\:min-\[200px\]\:underline { + text-decoration-line: underline; + } + } + } + + @media (max-width: 300px) { + @media (min-width: 100px) { + .max-\[300px\]\:min-\[100px\]\:underline { + text-decoration-line: underline; + } + } + @media (min-width: 200px) { + .max-\[300px\]\:min-\[200px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) +}) + +it('should guarantee that we are not passing values from other variants to the wrong function', () => { + let config = { + experimental: { matchVariant: true }, + content: [ + { + raw: html` +
+
+
+
+
+
+ `, + }, + ], + corePlugins: { preflight: false }, + plugins: [ + ({ matchVariant }) => { + matchVariant('min', ({ value }) => `@media (min-width: ${value})`, { + sort(a, z) { + let lookup = ['100px', '200px'] + if (lookup.indexOf(a) === -1 || lookup.indexOf(z) === -1) { + throw new Error('We are seeing values that should not be there!') + } + return lookup.indexOf(a) - lookup.indexOf(z) + }, + }) + matchVariant('max', ({ value }) => `@media (max-width: ${value})`, { + sort(a, z) { + let lookup = ['300px', '400px'] + if (lookup.indexOf(a) === -1 || lookup.indexOf(z) === -1) { + throw new Error('We are seeing values that should not be there!') + } + return lookup.indexOf(z) - lookup.indexOf(a) + }, + }) + }, + ], + } + + let input = css` + @tailwind utilities; + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + @media (min-width: 100px) { + @media (max-width: 400px) { + .min-\[100px\]\:max-\[400px\]\:underline { + text-decoration-line: underline; + } + } + + @media (max-width: 300px) { + .min-\[100px\]\:max-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + + @media (min-width: 200px) { + @media (max-width: 400px) { + .min-\[200px\]\:max-\[400px\]\:underline { + text-decoration-line: underline; + } + } + + @media (max-width: 300px) { + .min-\[200px\]\:max-\[300px\]\:underline { + text-decoration-line: underline; + } + } + } + `) + }) +})