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;
+ }
+ }
+ }
+ `)
+ })
+})