Skip to content

Commit 66f39a4

Browse files
Add new min and max variants (#9558)
* Rename test variants * Allow internally negating screens * Refactor * Add min/max screen variants * wip * Update changelog * Update tests * Sort list of variants properly Technically each test isn’t 100% sorted right in isolation because prettier decisions are basically project-wide. This is close enough though. * Update tests
1 parent 3011f46 commit 66f39a4

8 files changed

+963
-76
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- Prepare for container queries setup ([#9526](https://github.com/tailwindlabs/tailwindcss/pull/9526))
2929
- Add support for modifiers to `matchUtilities` ([#9541](https://github.com/tailwindlabs/tailwindcss/pull/9541))
3030
- Switch to positional argument + object for modifiers ([#9541](https://github.com/tailwindlabs/tailwindcss/pull/9541))
31+
- Add new `min` and `max` variants ([#9558](https://github.com/tailwindlabs/tailwindcss/pull/9558))
3132

3233
### Fixed
3334

src/corePlugins.js

+129-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import isPlainObject from './util/isPlainObject'
1212
import transformThemeValue from './util/transformThemeValue'
1313
import { version as tailwindVersion } from '../package.json'
1414
import log from './util/log'
15-
import { normalizeScreens } from './util/normalizeScreens'
15+
import {
16+
normalizeScreens,
17+
isScreenSortable,
18+
compareScreens,
19+
toScreen,
20+
} from './util/normalizeScreens'
1621
import { formatBoxShadowValue, parseBoxShadowValue } from './util/parseBoxShadowValue'
1722
import { removeAlphaVariables } from './util/removeAlphaVariables'
1823
import { flagEnabled } from './featureFlags'
@@ -220,12 +225,131 @@ export let variantPlugins = {
220225
addVariant('print', '@media print')
221226
},
222227

223-
screenVariants: ({ theme, addVariant }) => {
224-
for (let screen of normalizeScreens(theme('screens'))) {
225-
let query = buildMediaQuery(screen)
228+
screenVariants: ({ theme, addVariant, matchVariant }) => {
229+
let rawScreens = theme('screens') ?? {}
230+
let areSimpleScreens = Object.values(rawScreens).every((v) => typeof v === 'string')
231+
let screens = normalizeScreens(theme('screens'))
226232

227-
addVariant(screen.name, `@media ${query}`)
233+
/** @type {Set<string>} */
234+
let unitCache = new Set([])
235+
236+
/** @param {string} value */
237+
function units(value) {
238+
return value.match(/(\D+)$/)?.[1] ?? '(none)'
239+
}
240+
241+
/** @param {string} value */
242+
function recordUnits(value) {
243+
if (value !== undefined) {
244+
unitCache.add(units(value))
245+
}
246+
}
247+
248+
/** @param {string} value */
249+
function canUseUnits(value) {
250+
recordUnits(value)
251+
252+
// If the cache was empty it'll become 1 because we've just added the current unit
253+
// If the cache was not empty and the units are the same the size doesn't change
254+
// Otherwise, if the units are different from what is already known the size will always be > 1
255+
return unitCache.size === 1
256+
}
257+
258+
for (const screen of screens) {
259+
for (const value of screen.values) {
260+
recordUnits(value.min)
261+
recordUnits(value.max)
262+
}
263+
}
264+
265+
let screensUseConsistentUnits = unitCache.size <= 1
266+
267+
/**
268+
* @typedef {import('./util/normalizeScreens').Screen} Screen
269+
*/
270+
271+
/**
272+
* @param {'min' | 'max'} type
273+
* @returns {Record<string, Screen>}
274+
*/
275+
function buildScreenValues(type) {
276+
return Object.fromEntries(
277+
screens
278+
.filter((screen) => isScreenSortable(screen).result)
279+
.map((screen) => {
280+
let { min, max } = screen.values[0]
281+
282+
if (type === 'min' && min !== undefined) {
283+
return screen
284+
} else if (type === 'min' && max !== undefined) {
285+
return { ...screen, not: !screen.not }
286+
} else if (type === 'max' && max !== undefined) {
287+
return screen
288+
} else if (type === 'max' && min !== undefined) {
289+
return { ...screen, not: !screen.not }
290+
}
291+
})
292+
.map((screen) => [screen.name, screen])
293+
)
294+
}
295+
296+
/**
297+
* @param {'min' | 'max'} type
298+
* @returns {(a: { value: string | Screen }, z: { value: string | Screen }) => number}
299+
*/
300+
function buildSort(type) {
301+
return (a, z) => compareScreens(type, a.value, z.value)
302+
}
303+
304+
let maxSort = buildSort('max')
305+
let minSort = buildSort('min')
306+
307+
/** @param {'min'|'max'} type */
308+
function buildScreenVariant(type) {
309+
return (value) => {
310+
if (!areSimpleScreens) {
311+
log.warn('complex-screen-config', [
312+
'The min and max variants are not supported with a screen configuration containing objects.',
313+
])
314+
315+
return []
316+
} else if (!screensUseConsistentUnits) {
317+
log.warn('mixed-screen-units', [
318+
'The min and max variants are not supported with a screen configuration containing mixed units.',
319+
])
320+
321+
return []
322+
} else if (typeof value === 'string' && !canUseUnits(value)) {
323+
log.warn('minmax-have-mixed-units', [
324+
'The min and max variants are not supported with a screen configuration containing mixed units.',
325+
])
326+
327+
return []
328+
}
329+
330+
return [`@media ${buildMediaQuery(toScreen(value, type))}`]
331+
}
228332
}
333+
334+
matchVariant('max', buildScreenVariant('max'), {
335+
sort: maxSort,
336+
values: areSimpleScreens ? buildScreenValues('max') : {},
337+
})
338+
339+
// screens and min-* are sorted together when they can be
340+
let id = 'min-screens'
341+
for (let screen of screens) {
342+
addVariant(screen.name, `@media ${buildMediaQuery(screen)}`, {
343+
id,
344+
sort: areSimpleScreens && screensUseConsistentUnits ? minSort : undefined,
345+
value: screen,
346+
})
347+
}
348+
349+
matchVariant('min', buildScreenVariant('min'), {
350+
id,
351+
sort: minSort,
352+
})
229353
},
230354

231355
supportsVariants: ({ matchVariant, theme }) => {

src/lib/setupContextUtils.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,9 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
560560
context.variantOptions.set(variantName, options)
561561
},
562562
matchVariant(variant, variantFn, options) {
563-
let id = ++variantIdentifier // A unique identifier that "groups" these variables together.
563+
// A unique identifier that "groups" these variants together.
564+
// This is for internal use only which is why it is not present in the types
565+
let id = options?.id ?? ++variantIdentifier
564566
let isSpecial = variant === '@'
565567

566568
let modifiersEnabled = flagEnabled(tailwindConfig, 'generalizedModifiers')

src/util/buildMediaQuery.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ export default function buildMediaQuery(screens) {
22
screens = Array.isArray(screens) ? screens : [screens]
33

44
return screens
5-
.map((screen) =>
6-
screen.values.map((screen) => {
5+
.map((screen) => {
6+
let values = screen.values.map((screen) => {
77
if (screen.raw !== undefined) {
88
return screen.raw
99
}
@@ -15,6 +15,8 @@ export default function buildMediaQuery(screens) {
1515
.filter(Boolean)
1616
.join(' and ')
1717
})
18-
)
18+
19+
return screen.not ? `not all and ${values}` : values
20+
})
1921
.join(', ')
2022
}

src/util/normalizeScreens.js

+99-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
/**
2+
* @typedef {object} ScreenValue
3+
* @property {number|undefined} min
4+
* @property {number|undefined} max
5+
* @property {string|undefined} raw
6+
*/
7+
8+
/**
9+
* @typedef {object} Screen
10+
* @property {string} name
11+
* @property {boolean} not
12+
* @property {ScreenValue[]} values
13+
*/
14+
115
/**
216
* A function that normalizes the various forms that the screens object can be
317
* provided in.
@@ -10,6 +24,8 @@
1024
*
1125
* Output(s):
1226
* - [{ name: 'sm', values: [{ min: '100px', max: '200px' }] }] // List of objects, that contains multiple values
27+
*
28+
* @returns {Screen[]}
1329
*/
1430
export function normalizeScreens(screens, root = true) {
1531
if (Array.isArray(screens)) {
@@ -19,27 +35,106 @@ export function normalizeScreens(screens, root = true) {
1935
}
2036

2137
if (typeof screen === 'string') {
22-
return { name: screen.toString(), values: [{ min: screen, max: undefined }] }
38+
return { name: screen.toString(), not: false, values: [{ min: screen, max: undefined }] }
2339
}
2440

2541
let [name, options] = screen
2642
name = name.toString()
2743

2844
if (typeof options === 'string') {
29-
return { name, values: [{ min: options, max: undefined }] }
45+
return { name, not: false, values: [{ min: options, max: undefined }] }
3046
}
3147

3248
if (Array.isArray(options)) {
33-
return { name, values: options.map((option) => resolveValue(option)) }
49+
return { name, not: false, values: options.map((option) => resolveValue(option)) }
3450
}
3551

36-
return { name, values: [resolveValue(options)] }
52+
return { name, not: false, values: [resolveValue(options)] }
3753
})
3854
}
3955

4056
return normalizeScreens(Object.entries(screens ?? {}), false)
4157
}
4258

59+
/**
60+
* @param {Screen} screen
61+
* @returns {{result: false, reason: string} | {result: true, reason: null}}
62+
*/
63+
export function isScreenSortable(screen) {
64+
if (screen.values.length !== 1) {
65+
return { result: false, reason: 'multiple-values' }
66+
} else if (screen.values[0].raw !== undefined) {
67+
return { result: false, reason: 'raw-values' }
68+
} else if (screen.values[0].min !== undefined && screen.values[0].max !== undefined) {
69+
return { result: false, reason: 'min-and-max' }
70+
}
71+
72+
return { result: true, reason: null }
73+
}
74+
75+
/**
76+
* @param {'min' | 'max'} type
77+
* @param {Screen | 'string'} a
78+
* @param {Screen | 'string'} z
79+
* @returns {number}
80+
*/
81+
export function compareScreens(type, a, z) {
82+
let aScreen = toScreen(a, type)
83+
let zScreen = toScreen(z, type)
84+
85+
let aSorting = isScreenSortable(aScreen)
86+
let bSorting = isScreenSortable(zScreen)
87+
88+
// These cases should never happen and indicate a bug in Tailwind CSS itself
89+
if (aSorting.reason === 'multiple-values' || bSorting.reason === 'multiple-values') {
90+
throw new Error(
91+
'Attempted to sort a screen with multiple values. This should never happen. Please open a bug report.'
92+
)
93+
} else if (aSorting.reason === 'raw-values' || bSorting.reason === 'raw-values') {
94+
throw new Error(
95+
'Attempted to sort a screen with raw values. This should never happen. Please open a bug report.'
96+
)
97+
} else if (aSorting.reason === 'min-and-max' || bSorting.reason === 'min-and-max') {
98+
throw new Error(
99+
'Attempted to sort a screen with both min and max values. This should never happen. Please open a bug report.'
100+
)
101+
}
102+
103+
// Let the sorting begin
104+
let { min: aMin, max: aMax } = aScreen.values[0]
105+
let { min: zMin, max: zMax } = zScreen.values[0]
106+
107+
// Negating screens flip their behavior. Basically `not min-width` is `max-width`
108+
if (a.not) [aMin, aMax] = [aMax, aMin]
109+
if (z.not) [zMin, zMax] = [zMax, zMin]
110+
111+
aMin = aMin === undefined ? aMin : parseFloat(aMin)
112+
aMax = aMax === undefined ? aMax : parseFloat(aMax)
113+
zMin = zMin === undefined ? zMin : parseFloat(zMin)
114+
zMax = zMax === undefined ? zMax : parseFloat(zMax)
115+
116+
let [aValue, zValue] = type === 'min' ? [aMin, zMin] : [zMax, aMax]
117+
118+
return aValue - zValue
119+
}
120+
121+
/**
122+
*
123+
* @param {PartialScreen> | string} value
124+
* @param {'min' | 'max'} type
125+
* @returns {Screen}
126+
*/
127+
export function toScreen(value, type) {
128+
if (typeof value === 'object') {
129+
return value
130+
}
131+
132+
return {
133+
name: 'arbitrary-screen',
134+
values: [{ [type]: value }],
135+
}
136+
}
137+
43138
function resolveValue({ 'min-width': _minWidth, min = _minWidth, max, raw } = {}) {
44139
return { min, max, raw }
45140
}

0 commit comments

Comments
 (0)