Add support for setting screens in JS config (#14415)

This PR adds support for the _simple_ case of the `screens` option
inside JS config paths. This allows JS configs to extend the responsive
theme by adding custom breakpoints. Here's an example from our v3 docs:

```js
{
  theme: {
    screens: {
      'sm': '640px',
      // => @media (min-width: 640px) { ... }

      'md': '768px',
      // => @media (min-width: 768px) { ... }

      'lg': '1024px',
      // => @media (min-width: 1024px) { ... }

      'xl': '1280px',
      // => @media (min-width: 1280px) { ... }

      '2xl': '1536px',
      // => @media (min-width: 1536px) { ... }
    }
  }
}
```

For simple breakpoints, this will extend the core breakpoints and will
work with the `min-*` and `max-*` utilities. However, we also support
complex ways of setting up custom screens like this:

```js
{
  theme: {
    extend: {
      screens: {
        sm: { max: '639px' },
        md: [
          { min: '668px', max: '767px' },
          { min: '868px' },
        ],
        lg: { min: '868px' },
        xl: { min: '1024px', max: '1279px' },
        tall: { raw: '(min-height: 800px)' },
      },
    },
  },
}
```

For these complex setups, we _only_ generate the shorthand variant (e.g.
`tall`) but those won't integrate within `min-*` and `max-*`. In v3,
adding any of these complex configurations would omit any `min-*` and
`max-*` variants.
This commit is contained in:
Philipp Spiess 2024-09-18 16:56:03 +02:00 • committed by GitHub
parent 6ca8cc6f02
commit 2ddb715abd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 806 additions and 27 deletions

View file

@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `aria`, `supports`, and `data` variants defined in JS config files ([#14407](https://github.com/tailwindlabs/tailwindcss/pull/14407))
- Add `@tailwindcss/upgrade` tooling ([#14434](https://github.com/tailwindlabs/tailwindcss/pull/14434))
### Added
- Support `screens` in JS config files ([#14415](https://github.com/tailwindlabs/tailwindcss/pull/14415))
### Fixed
- Support `borderRadius.*` as an alias for `--radius-*` when using dot notation inside the `theme()` function ([#14436](https://github.com/tailwindlabs/tailwindcss/pull/14436))

View file

@ -10,6 +10,7 @@ import { resolveConfig } from './config/resolve-config'
import type { UserConfig } from './config/types'
import { darkModePlugin } from './dark-mode'
import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api'
import { registerScreensConfig } from './screens-config'
import { registerThemeVariantOverrides } from './theme-variants'
export async function applyCompatibilityHooks({
@ -174,6 +175,7 @@ export async function applyCompatibilityHooks({
...userConfig,
{ config: { plugins: [darkModePlugin] } },
])
let resolvedUserConfig = resolveConfig(designSystem, userConfig)
let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig)
@ -184,9 +186,10 @@ export async function applyCompatibilityHooks({
// Merge the user-configured theme keys into the design system. The compat
// config would otherwise expand into namespaces like `background-color` which
// core utilities already read from.
applyConfigToTheme(designSystem, userConfig)
applyConfigToTheme(designSystem, resolvedUserConfig)
registerThemeVariantOverrides(resolvedConfig, designSystem)
registerThemeVariantOverrides(resolvedUserConfig, designSystem)
registerScreensConfig(resolvedUserConfig, designSystem)
// Replace `resolveThemeValue` with a version that is backwards compatible
// with dot-notation but also aware of any JS theme configurations registered

View file

@ -2,12 +2,13 @@ import { expect, test } from 'vitest'
import { buildDesignSystem } from '../design-system'
import { Theme } from '../theme'
import { applyConfigToTheme } from './apply-config-to-theme'
import { resolveConfig } from './config/resolve-config'
test('Config values can be merged into the theme', () => {
let theme = new Theme()
let design = buildDesignSystem(theme)
applyConfigToTheme(design, [
let resolvedUserConfig = resolveConfig(design, [
{
config: {
theme: {
@ -36,6 +37,7 @@ test('Config values can be merged into the theme', () => {
},
},
])
applyConfigToTheme(design, resolvedUserConfig)
expect(theme.resolve('primary', ['--color'])).toEqual('#c0ffee')
expect(theme.resolve('red-500', ['--color'])).toEqual('red')

View file

@ -1,6 +1,5 @@
import type { DesignSystem } from '../design-system'
import { ThemeOptions } from '../theme'
import { resolveConfig, type ConfigFile } from './config/resolve-config'
import type { ResolvedConfig } from './config/types'
function resolveThemeValue(value: unknown, subValue: string | null = null): string | null {
@ -20,10 +19,11 @@ function resolveThemeValue(value: unknown, subValue: string | null = null): stri
return null
}
export function applyConfigToTheme(designSystem: DesignSystem, configs: ConfigFile[]) {
let theme = resolveConfig(designSystem, configs).theme
export function applyConfigToTheme(designSystem: DesignSystem, { theme }: ResolvedConfig) {
for (let [path, value] of themeableValues(theme)) {
if (typeof value !== 'string' && typeof value !== 'number') {
continue
}
let name = keyPathToCssProperty(path)
designSystem.theme.add(
`--${name}`,
@ -111,9 +111,8 @@ function themeableValues(config: ResolvedConfig['theme']): [string[], unknown][]
}
function keyPathToCssProperty(path: string[]) {
if (path[0] === 'colors') {
path[0] = 'color'
}
if (path[0] === 'colors') path[0] = 'color'
if (path[0] === 'screens') path[0] = 'breakpoint'
return (
path

View file

@ -1078,3 +1078,65 @@ test('creates variants for `data`, `supports`, and `aria` theme options at the s
"
`)
})
test('merges css breakpoints with js config screens', async () => {
let input = css`
@theme default {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
@theme {
--breakpoint-md: 50rem;
}
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: '44rem',
},
},
},
}),
})
expect(compiler.build(['sm:flex', 'md:flex', 'lg:flex', 'min-sm:max-md:underline']))
.toMatchInlineSnapshot(`
":root {
--breakpoint-md: 50rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.sm\\:flex {
@media (width >= 44rem) {
display: flex;
}
}
.min-sm\\:max-md\\:underline {
@media (width >= 44rem) {
@media (width < 50rem) {
text-decoration-line: underline;
}
}
}
.md\\:flex {
@media (width >= 50rem) {
display: flex;
}
}
.lg\\:flex {
@media (width >= 64rem) {
display: flex;
}
}
"
`)
})

View file

@ -884,11 +884,11 @@ export default {
...barePercentages,
},
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
sm: '40rem',
md: '48rem',
lg: '64rem',
xl: '80rem',
'2xl': '96rem',
},
scrollMargin: ({ theme }) => theme('spacing'),
scrollPadding: ({ theme }) => theme('spacing'),

View file

@ -0,0 +1,592 @@
import { describe, expect, test } from 'vitest'
import { compile } from '..'
const css = String.raw
test('CSS `--breakpoint-*` merge with JS config `screens`', async () => {
let input = css`
@theme default {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
@theme {
--breakpoint-md: 50rem;
}
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: '44rem',
},
},
},
}),
})
expect(
compiler.build([
'sm:flex',
'md:flex',
'lg:flex',
'min-sm:max-md:underline',
'min-md:max-lg:underline',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 50rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.sm\\:flex {
@media (width >= 44rem) {
display: flex;
}
}
.min-sm\\:max-md\\:underline {
@media (width >= 44rem) {
@media (width < 50rem) {
text-decoration-line: underline;
}
}
}
.md\\:flex {
@media (width >= 50rem) {
display: flex;
}
}
.min-md\\:max-lg\\:underline {
@media (width >= 50rem) {
@media (width < 64rem) {
text-decoration-line: underline;
}
}
}
.lg\\:flex {
@media (width >= 64rem) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
test('JS config `screens` extend CSS `--breakpoint-*`', async () => {
let input = css`
@theme default {
--breakpoint-xs: 39rem;
--breakpoint-md: 49rem;
}
@theme {
--breakpoint-md: 50rem;
}
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
xs: '30rem',
sm: '40rem',
md: '48rem',
lg: '60rem',
},
},
},
}),
})
expect(
compiler.build([
// Order is messed up on purpose
'md:flex',
'sm:flex',
'lg:flex',
'xs:flex',
'min-md:max-lg:underline',
'min-sm:max-md:underline',
'min-xs:flex',
'min-xs:max-md:underline',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 50rem;
}
.min-xs\\:flex {
@media (width >= 30rem) {
display: flex;
}
}
.xs\\:flex {
@media (width >= 30rem) {
display: flex;
}
}
.min-xs\\:max-md\\:underline {
@media (width >= 30rem) {
@media (width < 50rem) {
text-decoration-line: underline;
}
}
}
.sm\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.min-sm\\:max-md\\:underline {
@media (width >= 40rem) {
@media (width < 50rem) {
text-decoration-line: underline;
}
}
}
.md\\:flex {
@media (width >= 50rem) {
display: flex;
}
}
.min-md\\:max-lg\\:underline {
@media (width >= 50rem) {
@media (width < 60rem) {
text-decoration-line: underline;
}
}
}
.lg\\:flex {
@media (width >= 60rem) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
test('JS config `screens` only setup, even if those match the default-theme export', async () => {
let input = css`
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
screens: {
sm: '40rem',
md: '48rem',
lg: '64rem',
},
},
}),
})
expect(
compiler.build([
// Order is messed up on purpose
'md:flex',
'sm:flex',
'lg:flex',
'min-md:max-lg:underline',
'min-sm:max-md:underline',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
".sm\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.min-sm\\:max-md\\:underline {
@media (width >= 40rem) {
@media (width < 48rem) {
text-decoration-line: underline;
}
}
}
.md\\:flex {
@media (width >= 48rem) {
display: flex;
}
}
.min-md\\:max-lg\\:underline {
@media (width >= 48rem) {
@media (width < 64rem) {
text-decoration-line: underline;
}
}
}
.lg\\:flex {
@media (width >= 64rem) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
test('JS config `screens` overwrite CSS `--breakpoint-*`', async () => {
let input = css`
@theme default {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
screens: {
mini: '40rem',
midi: '48rem',
maxi: '64rem',
},
},
}),
})
expect(
compiler.build([
'sm:flex',
'md:flex',
'mini:flex',
'midi:flex',
'maxi:flex',
'min-md:max-lg:underline',
'min-sm:max-md:underline',
'min-midi:max-maxi:underline',
'min-mini:max-midi:underline',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.mini\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.sm\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.min-mini\\:max-midi\\:underline {
@media (width >= 40rem) {
@media (width < 48rem) {
text-decoration-line: underline;
}
}
}
.min-sm\\:max-md\\:underline {
@media (width >= 40rem) {
@media (width < 48rem) {
text-decoration-line: underline;
}
}
}
.md\\:flex {
@media (width >= 48rem) {
display: flex;
}
}
.midi\\:flex {
@media (width >= 48rem) {
display: flex;
}
}
.min-md\\:max-lg\\:underline {
@media (width >= 48rem) {
@media (width < 64rem) {
text-decoration-line: underline;
}
}
}
.min-midi\\:max-maxi\\:underline {
@media (width >= 48rem) {
@media (width < 64rem) {
text-decoration-line: underline;
}
}
}
.maxi\\:flex {
@media (width >= 64rem) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
test('JS config with `theme: { extends }` should not include the `default-config` values', async () => {
let input = css`
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
mini: '40rem',
midi: '48rem',
maxi: '64rem',
},
},
},
}),
})
expect(
compiler.build(['sm:flex', 'md:flex', 'min-md:max-lg:underline', 'min-sm:max-md:underline']),
).toBe('')
expect(
compiler.build([
'mini:flex',
'midi:flex',
'maxi:flex',
'min-midi:max-maxi:underline',
'min-mini:max-midi:underline',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
".mini\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.min-mini\\:max-midi\\:underline {
@media (width >= 40rem) {
@media (width < 48rem) {
text-decoration-line: underline;
}
}
}
.midi\\:flex {
@media (width >= 48rem) {
display: flex;
}
}
.min-midi\\:max-maxi\\:underline {
@media (width >= 48rem) {
@media (width < 64rem) {
text-decoration-line: underline;
}
}
}
.maxi\\:flex {
@media (width >= 64rem) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
describe('complex screen configs', () => {
test('generates utilities', async () => {
let input = css`
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: { max: '639px' },
md: [
//
{ min: '668px', max: '767px' },
{ min: '868px' },
],
lg: { min: '868px' },
xl: { min: '1024px', max: '1279px' },
tall: { raw: '(min-height: 800px)' },
},
},
},
}),
})
expect(
compiler.build(['min-sm:flex', 'min-md:flex', 'min-lg:flex', 'min-xl:flex', 'min-tall:flex']),
).toBe('')
expect(
compiler.build([
'sm:flex',
'md:flex',
'lg:flex',
'xl:flex',
'tall:flex',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
".lg\\:flex {
@media (min-width: 868px) {
display: flex;
}
}
.sm\\:flex {
@media (max-width: 639px) {
display: flex;
}
}
.md\\:flex {
@media (min-width: 668px and max-width: 767px), (min-width: 868px) {
display: flex;
}
}
.xl\\:flex {
@media (min-width: 1024px and max-width: 1279px) {
display: flex;
}
}
.tall\\:flex {
@media (min-height: 800px) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
test("don't interfere with `min-*` and `max-*` variants of non-complex screen configs", async () => {
let input = css`
@theme default {
--breakpoint-sm: 39rem;
--breakpoint-md: 48rem;
}
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
screens: {
sm: '40rem',
portrait: { raw: 'screen and (orientation: portrait)' },
},
},
},
}),
})
expect(
compiler.build([
'sm:flex',
'md:flex',
'portrait:flex',
'min-sm:flex',
'min-md:flex',
'min-portrait:flex',
// Ensure other core variants appear at the end
'print:items-end',
]),
).toMatchInlineSnapshot(`
":root {
--breakpoint-md: 48rem;
}
.min-sm\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.sm\\:flex {
@media (width >= 40rem) {
display: flex;
}
}
.md\\:flex {
@media (width >= 48rem) {
display: flex;
}
}
.min-md\\:flex {
@media (width >= 48rem) {
display: flex;
}
}
.portrait\\:flex {
@media screen and (orientation: portrait) {
display: flex;
}
}
.print\\:items-end {
@media print {
align-items: flex-end;
}
}
"
`)
})
})

View file

@ -0,0 +1,105 @@
import { rule } from '../ast'
import type { DesignSystem } from '../design-system'
import type { ResolvedConfig } from './config/types'
export function registerScreensConfig(userConfig: ResolvedConfig, designSystem: DesignSystem) {
let screens = userConfig.theme.screens || {}
// We want to insert the breakpoints in the right order as best we can. In the
// core utility, all static breakpoint variants and the `min-*` functional
// variant are registered inside a group. Since all the variants within a
// group share the same order, we can use the always-defined `min-*` variant
// as the order.
let coreOrder = designSystem.variants.get('min')?.order ?? 0
let additionalVariants: ((order: number) => void)[] = []
// Register static breakpoint variants for everything that comes from the user
// theme config.
for (let [name, value] of Object.entries(screens)) {
let coreVariant = designSystem.variants.get(name)
// Ignore it if there's a CSS value that takes precedence over the JS config
// and the static utilities are already registered.
//
// This happens when a `@theme { }` block is used that overwrites all JS
// config options. We rely on the resolution order of the Theme for
// resolving this. If Theme has a different value, we know that this is not
// coming from the JS plugin and thus we don't need to handle it explicitly.
let cssValue = designSystem.theme.resolveValue(name, ['--breakpoint'])
if (coreVariant && cssValue && !designSystem.theme.hasDefault(`--breakpoint-${name}`)) {
continue
}
let query: string | undefined
let deferInsert = true
if (typeof value === 'string') {
query = `(width >= ${value})`
deferInsert = false
} else if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
query = value.map(ruleForComplexScreenValue).join(', ')
} else {
query = ruleForComplexScreenValue(value) ?? ''
if ('min' in value && !('max' in value)) {
deferInsert = false
}
}
} else {
continue
}
function insert(order: number) {
// `min-*` and `max-*` rules do not need to be reconfigured, as they are
// reading the latest values from the theme.
designSystem.variants.static(
name,
(ruleNode) => {
ruleNode.nodes = [rule(`@media ${query}`, ruleNode.nodes)]
},
{ order },
)
}
if (deferInsert) {
additionalVariants.push(insert)
} else {
insert(coreOrder)
}
}
// Reserve and insert slots for the additional variants
if (additionalVariants.length === 0) return
for (let [, variant] of designSystem.variants.variants) {
if (variant.order > coreOrder) variant.order += additionalVariants.length
}
designSystem.variants.compareFns = new Map(
Array.from(designSystem.variants.compareFns).map(([key, value]) => {
if (key > coreOrder) key += additionalVariants.length
return [key, value]
}),
)
for (let [index, callback] of additionalVariants.entries()) {
callback(coreOrder + index + 1)
}
}
function ruleForComplexScreenValue(value: object): string | null {
let query = null
if ('raw' in value && typeof value.raw === 'string') {
query = value.raw
} else {
let rules: string[] = []
if ('min' in value) rules.push(`min-width: ${value.min}`)
if ('max' in value) rules.push(`max-width: ${value.max}`)
if (rules.length !== 0) {
query = `(${rules.join(' and ')})`
}
}
return query
}

View file

@ -13,8 +13,8 @@ type VariantFn<T extends Variant['kind']> = (
type CompareFn = (a: Variant, z: Variant) => number
export class Variants {
private compareFns = new Map<number, CompareFn>()
private variants = new Map<
public compareFns = new Map<number, CompareFn>()
public variants = new Map<
string,
{
kind: Variant['kind']
@ -39,8 +39,12 @@ export class Variants {
*/
private lastOrder = 0
static(name: string, applyFn: VariantFn<'static'>, { compounds }: { compounds?: boolean } = {}) {
this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true })
static(
name: string,
applyFn: VariantFn<'static'>,
{ compounds, order }: { compounds?: boolean; order?: number } = {},
) {
this.set(name, { kind: 'static', applyFn, compounds: compounds ?? true, order })
}
fromAst(name: string, ast: AstNode[]) {
@ -54,17 +58,17 @@ export class Variants {
functional(
name: string,
applyFn: VariantFn<'functional'>,
{ compounds }: { compounds?: boolean } = {},
{ compounds, order }: { compounds?: boolean; order?: number } = {},
) {
this.set(name, { kind: 'functional', applyFn, compounds: compounds ?? true })
this.set(name, { kind: 'functional', applyFn, compounds: compounds ?? true, order })
}
compound(
name: string,
applyFn: VariantFn<'compound'>,
{ compounds }: { compounds?: boolean } = {},
{ compounds, order }: { compounds?: boolean; order?: number } = {},
) {
this.set(name, { kind: 'compound', applyFn, compounds: compounds ?? true })
this.set(name, { kind: 'compound', applyFn, compounds: compounds ?? true, order })
}
group(fn: () => void, compareFn?: CompareFn) {
@ -146,17 +150,25 @@ export class Variants {
private set<T extends Variant['kind']>(
name: string,
{ kind, applyFn, compounds }: { kind: T; applyFn: VariantFn<T>; compounds: boolean },
{
kind,
applyFn,
compounds,
order,
}: { kind: T; applyFn: VariantFn<T>; compounds: boolean; order?: number },
) {
let existing = this.variants.get(name)
if (existing) {
Object.assign(existing, { kind, applyFn, compounds })
} else {
this.lastOrder = this.nextOrder()
if (order === undefined) {
this.lastOrder = this.nextOrder()
order = this.lastOrder
}
this.variants.set(name, {
kind,
applyFn,
order: this.lastOrder,
order,
compounds,
})
}
@ -698,7 +710,7 @@ export function createVariants(theme: Theme): Variants {
let resolvedBreakpoints = new DefaultMap((variant: Variant) => {
switch (variant.kind) {
case 'static': {
return breakpoints.get(variant.root) ?? null
return theme.resolveValue(variant.root, ['--breakpoint']) ?? null
}
case 'functional': {