Skip to content

Commit

Permalink
Add new min and max variants (#9558)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
thecrypticace committed Oct 14, 2022
1 parent 3011f46 commit 66f39a4
Show file tree
Hide file tree
Showing 8 changed files with 963 additions and 76 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prepare for container queries setup ([#9526](https://github.com/tailwindlabs/tailwindcss/pull/9526))
- Add support for modifiers to `matchUtilities` ([#9541](https://github.com/tailwindlabs/tailwindcss/pull/9541))
- Switch to positional argument + object for modifiers ([#9541](https://github.com/tailwindlabs/tailwindcss/pull/9541))
- Add new `min` and `max` variants ([#9558](https://github.com/tailwindlabs/tailwindcss/pull/9558))

### Fixed

Expand Down
134 changes: 129 additions & 5 deletions src/corePlugins.js
Expand Up @@ -12,7 +12,12 @@ import isPlainObject from './util/isPlainObject'
import transformThemeValue from './util/transformThemeValue'
import { version as tailwindVersion } from '../package.json'
import log from './util/log'
import { normalizeScreens } from './util/normalizeScreens'
import {
normalizeScreens,
isScreenSortable,
compareScreens,
toScreen,
} from './util/normalizeScreens'
import { formatBoxShadowValue, parseBoxShadowValue } from './util/parseBoxShadowValue'
import { removeAlphaVariables } from './util/removeAlphaVariables'
import { flagEnabled } from './featureFlags'
Expand Down Expand Up @@ -220,12 +225,131 @@ export let variantPlugins = {
addVariant('print', '@media print')
},

screenVariants: ({ theme, addVariant }) => {
for (let screen of normalizeScreens(theme('screens'))) {
let query = buildMediaQuery(screen)
screenVariants: ({ theme, addVariant, matchVariant }) => {
let rawScreens = theme('screens') ?? {}
let areSimpleScreens = Object.values(rawScreens).every((v) => typeof v === 'string')
let screens = normalizeScreens(theme('screens'))

addVariant(screen.name, `@media ${query}`)
/** @type {Set<string>} */
let unitCache = new Set([])

/** @param {string} value */
function units(value) {
return value.match(/(\D+)$/)?.[1] ?? '(none)'
}

/** @param {string} value */
function recordUnits(value) {
if (value !== undefined) {
unitCache.add(units(value))
}
}

/** @param {string} value */
function canUseUnits(value) {
recordUnits(value)

// If the cache was empty it'll become 1 because we've just added the current unit
// If the cache was not empty and the units are the same the size doesn't change
// Otherwise, if the units are different from what is already known the size will always be > 1
return unitCache.size === 1
}

for (const screen of screens) {
for (const value of screen.values) {
recordUnits(value.min)
recordUnits(value.max)
}
}

let screensUseConsistentUnits = unitCache.size <= 1

/**
* @typedef {import('./util/normalizeScreens').Screen} Screen
*/

/**
* @param {'min' | 'max'} type
* @returns {Record<string, Screen>}
*/
function buildScreenValues(type) {
return Object.fromEntries(
screens
.filter((screen) => isScreenSortable(screen).result)
.map((screen) => {
let { min, max } = screen.values[0]

if (type === 'min' && min !== undefined) {
return screen
} else if (type === 'min' && max !== undefined) {
return { ...screen, not: !screen.not }
} else if (type === 'max' && max !== undefined) {
return screen
} else if (type === 'max' && min !== undefined) {
return { ...screen, not: !screen.not }
}
})
.map((screen) => [screen.name, screen])
)
}

/**
* @param {'min' | 'max'} type
* @returns {(a: { value: string | Screen }, z: { value: string | Screen }) => number}
*/
function buildSort(type) {
return (a, z) => compareScreens(type, a.value, z.value)
}

let maxSort = buildSort('max')
let minSort = buildSort('min')

/** @param {'min'|'max'} type */
function buildScreenVariant(type) {
return (value) => {
if (!areSimpleScreens) {
log.warn('complex-screen-config', [
'The min and max variants are not supported with a screen configuration containing objects.',
])

return []
} else if (!screensUseConsistentUnits) {
log.warn('mixed-screen-units', [
'The min and max variants are not supported with a screen configuration containing mixed units.',
])

return []
} else if (typeof value === 'string' && !canUseUnits(value)) {
log.warn('minmax-have-mixed-units', [
'The min and max variants are not supported with a screen configuration containing mixed units.',
])

return []
}

return [`@media ${buildMediaQuery(toScreen(value, type))}`]
}
}

matchVariant('max', buildScreenVariant('max'), {
sort: maxSort,
values: areSimpleScreens ? buildScreenValues('max') : {},
})

// screens and min-* are sorted together when they can be
let id = 'min-screens'
for (let screen of screens) {
addVariant(screen.name, `@media ${buildMediaQuery(screen)}`, {
id,
sort: areSimpleScreens && screensUseConsistentUnits ? minSort : undefined,
value: screen,
})
}

matchVariant('min', buildScreenVariant('min'), {
id,
sort: minSort,
})
},

supportsVariants: ({ matchVariant, theme }) => {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/setupContextUtils.js
Expand Up @@ -560,7 +560,9 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
context.variantOptions.set(variantName, options)
},
matchVariant(variant, variantFn, options) {
let id = ++variantIdentifier // A unique identifier that "groups" these variables together.
// A unique identifier that "groups" these variants together.
// This is for internal use only which is why it is not present in the types
let id = options?.id ?? ++variantIdentifier
let isSpecial = variant === '@'

let modifiersEnabled = flagEnabled(tailwindConfig, 'generalizedModifiers')
Expand Down
8 changes: 5 additions & 3 deletions src/util/buildMediaQuery.js
Expand Up @@ -2,8 +2,8 @@ export default function buildMediaQuery(screens) {
screens = Array.isArray(screens) ? screens : [screens]

return screens
.map((screen) =>
screen.values.map((screen) => {
.map((screen) => {
let values = screen.values.map((screen) => {
if (screen.raw !== undefined) {
return screen.raw
}
Expand All @@ -15,6 +15,8 @@ export default function buildMediaQuery(screens) {
.filter(Boolean)
.join(' and ')
})
)

return screen.not ? `not all and ${values}` : values
})
.join(', ')
}
103 changes: 99 additions & 4 deletions src/util/normalizeScreens.js
@@ -1,3 +1,17 @@
/**
* @typedef {object} ScreenValue
* @property {number|undefined} min
* @property {number|undefined} max
* @property {string|undefined} raw
*/

/**
* @typedef {object} Screen
* @property {string} name
* @property {boolean} not
* @property {ScreenValue[]} values
*/

/**
* A function that normalizes the various forms that the screens object can be
* provided in.
Expand All @@ -10,6 +24,8 @@
*
* Output(s):
* - [{ name: 'sm', values: [{ min: '100px', max: '200px' }] }] // List of objects, that contains multiple values
*
* @returns {Screen[]}
*/
export function normalizeScreens(screens, root = true) {
if (Array.isArray(screens)) {
Expand All @@ -19,27 +35,106 @@ export function normalizeScreens(screens, root = true) {
}

if (typeof screen === 'string') {
return { name: screen.toString(), values: [{ min: screen, max: undefined }] }
return { name: screen.toString(), not: false, values: [{ min: screen, max: undefined }] }
}

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

if (typeof options === 'string') {
return { name, values: [{ min: options, max: undefined }] }
return { name, not: false, values: [{ min: options, max: undefined }] }
}

if (Array.isArray(options)) {
return { name, values: options.map((option) => resolveValue(option)) }
return { name, not: false, values: options.map((option) => resolveValue(option)) }
}

return { name, values: [resolveValue(options)] }
return { name, not: false, values: [resolveValue(options)] }
})
}

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

/**
* @param {Screen} screen
* @returns {{result: false, reason: string} | {result: true, reason: null}}
*/
export function isScreenSortable(screen) {
if (screen.values.length !== 1) {
return { result: false, reason: 'multiple-values' }
} else if (screen.values[0].raw !== undefined) {
return { result: false, reason: 'raw-values' }
} else if (screen.values[0].min !== undefined && screen.values[0].max !== undefined) {
return { result: false, reason: 'min-and-max' }
}

return { result: true, reason: null }
}

/**
* @param {'min' | 'max'} type
* @param {Screen | 'string'} a
* @param {Screen | 'string'} z
* @returns {number}
*/
export function compareScreens(type, a, z) {
let aScreen = toScreen(a, type)
let zScreen = toScreen(z, type)

let aSorting = isScreenSortable(aScreen)
let bSorting = isScreenSortable(zScreen)

// These cases should never happen and indicate a bug in Tailwind CSS itself
if (aSorting.reason === 'multiple-values' || bSorting.reason === 'multiple-values') {
throw new Error(
'Attempted to sort a screen with multiple values. This should never happen. Please open a bug report.'
)
} else if (aSorting.reason === 'raw-values' || bSorting.reason === 'raw-values') {
throw new Error(
'Attempted to sort a screen with raw values. This should never happen. Please open a bug report.'
)
} else if (aSorting.reason === 'min-and-max' || bSorting.reason === 'min-and-max') {
throw new Error(
'Attempted to sort a screen with both min and max values. This should never happen. Please open a bug report.'
)
}

// Let the sorting begin
let { min: aMin, max: aMax } = aScreen.values[0]
let { min: zMin, max: zMax } = zScreen.values[0]

// Negating screens flip their behavior. Basically `not min-width` is `max-width`
if (a.not) [aMin, aMax] = [aMax, aMin]
if (z.not) [zMin, zMax] = [zMax, zMin]

aMin = aMin === undefined ? aMin : parseFloat(aMin)
aMax = aMax === undefined ? aMax : parseFloat(aMax)
zMin = zMin === undefined ? zMin : parseFloat(zMin)
zMax = zMax === undefined ? zMax : parseFloat(zMax)

let [aValue, zValue] = type === 'min' ? [aMin, zMin] : [zMax, aMax]

return aValue - zValue
}

/**
*
* @param {PartialScreen> | string} value
* @param {'min' | 'max'} type
* @returns {Screen}
*/
export function toScreen(value, type) {
if (typeof value === 'object') {
return value
}

return {
name: 'arbitrary-screen',
values: [{ [type]: value }],
}
}

function resolveValue({ 'min-width': _minWidth, min = _minWidth, max, raw } = {}) {
return { min, max, raw }
}

0 comments on commit 66f39a4

Please sign in to comment.