Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new min and max variants #9558

Merged
merged 9 commits into from Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 }
}