Add codemod and interop for legacy container component configu (#14999)

This PR adds support for handling v3 [`container` customizations
](https://tailwindcss.com/docs/container#customizing). This is done by
adding a custom utility to extend the core `container` utility. A
concrete example can be taken from the added integration test.

### Input

```ts
/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.html'],
  theme: {
    container: {
      center: true,
      padding: {
        DEFAULT: '2rem',
        '2xl': '4rem',
      },
      screens: {
        md: '48rem', // Matches a default --breakpoint
        xl: '1280px',
        '2xl': '1536px',
      },
    },
  },
}
```

### Output

```css
@import "tailwindcss";

@utility container {
  margin-inline: auto;
  padding-inline: 2rem;

  @media (width >= theme(--breakpoint-sm)) {
    max-width: none;
  }

  @media (width >= 48rem) {
    max-width: 48rem;
  }

  @media (width >= 1280px) {
    max-width: 1280px;
  }

  @media (width >= 1536px) {
    max-width: 1536px;
    padding-inline: 4rem;
  }
}
````


## Test Plan

This PR adds extensive tests to the compat layer as part of unit tests.
Additionally it does at a test to the codemod setup that shows that the
right `@utility` code is generated. Furthermore I compared the
implementation against v3 on both the compat layer and the custom
`@utility`:


https://github.com/user-attachments/assets/44d6cbfb-4861-4225-9593-602b719f628f
This commit is contained in:
Philipp Spiess 2024-11-14 18:19:21 +01:00 committed by GitHub
parent 4079059420
commit 890f18de93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 834 additions and 3 deletions

View File

@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Support opacity values in increments of `0.25` by default ([#14980](https://github.com/tailwindlabs/tailwindcss/pull/14980))
- Support specifying the color interpolation method for gradients via modifier ([#14984](https://github.com/tailwindlabs/tailwindcss/pull/14984))
- Reintroduce `container` component as a utility ([#14993](https://github.com/tailwindlabs/tailwindcss/pull/14993))
- Reintroduce `container` component as a utility ([#14993](https://github.com/tailwindlabs/tailwindcss/pull/14993), [#14999](https://github.com/tailwindlabs/tailwindcss/pull/14999))
- _Upgrade (experimental)_: Migrate `container` component configuration to CSS ([#14999](https://github.com/tailwindlabs/tailwindcss/pull/14999))
### Fixed

View File

@ -1435,4 +1435,97 @@ describe('border compatibility', () => {
`)
},
)
test(
'migrates `container` component configurations',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
export default {
content: ['./src/**/*.html'],
theme: {
container: {
center: true,
padding: {
DEFAULT: '2rem',
'2xl': '4rem',
},
screens: {
md: '48rem', // Matches a default --breakpoint
xl: '1280px',
'2xl': '1536px',
},
},
},
} satisfies Config
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/index.html': html`
<div class="container"></div>
`,
},
},
async ({ exec, fs }) => {
await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
"
--- src/index.html ---
<div class="container"></div>
--- src/input.css ---
@import 'tailwindcss';
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 1280px) {
max-width: 1280px;
}
@media (width >= 1536px) {
max-width: 1536px;
padding-inline: 4rem;
}
}
/*
The default border color has changed to \`currentColor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
"
`)
},
)
})

View File

@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url'
import { type Config } from 'tailwindcss'
import defaultTheme from 'tailwindcss/defaultTheme'
import { loadModule } from '../../@tailwindcss-node/src/compile'
import { toCss, type AstNode } from '../../tailwindcss/src/ast'
import { atRule, toCss, type AstNode } from '../../tailwindcss/src/ast'
import {
keyPathToCssProperty,
themeableValues,
@ -13,6 +13,7 @@ import {
import { keyframesToRules } from '../../tailwindcss/src/compat/apply-keyframes-to-theme'
import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/config/resolve-config'
import type { ResolvedConfig, ThemeConfig } from '../../tailwindcss/src/compat/config/types'
import { buildCustomContainerUtilityRules } from '../../tailwindcss/src/compat/container'
import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode'
import type { DesignSystem } from '../../tailwindcss/src/design-system'
import { escape } from '../../tailwindcss/src/utils/escape'
@ -148,6 +149,14 @@ async function migrateTheme(
}
css += '}\n' // @theme
if ('container' in resolvedConfig.theme) {
let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem)
if (rules.length > 0) {
css += '\n' + toCss([atRule('@utility', 'container', rules)])
}
}
css += '}\n' // @tw-bucket
return css

View File

@ -6,6 +6,7 @@ import { applyKeyframesToTheme } from './apply-keyframes-to-theme'
import { createCompatConfig } from './config/create-compat-config'
import { resolveConfig } from './config/resolve-config'
import type { UserConfig } from './config/types'
import { registerContainerCompat } from './container'
import { darkModePlugin } from './dark-mode'
import { buildPluginApi, type CssPluginOptions, type Plugin } from './plugin-api'
import { registerScreensConfig } from './screens-config'
@ -239,6 +240,7 @@ function upgradeToFullPluginSupport({
registerThemeVariantOverrides(resolvedUserConfig, designSystem)
registerScreensConfig(resolvedUserConfig, designSystem)
registerContainerCompat(resolvedUserConfig, designSystem)
// If a prefix has already been set in CSS don't override it
if (!designSystem.theme.prefix && resolvedConfig.prefix) {

View File

@ -128,6 +128,9 @@ export function themeableValues(config: ResolvedConfig['theme']): [string[], unk
const IS_VALID_KEY = /^[a-zA-Z0-9-_%/\.]+$/
export function keyPathToCssProperty(path: string[]) {
// The legacy container component config should not be included in the Theme
if (path[0] === 'container') return null
path = structuredClone(path)
if (path[0] === 'animation') path[0] = 'animate'

View File

@ -0,0 +1,603 @@
import { expect, test } from 'vitest'
import { compile } from '..'
const css = String.raw
test('creates a custom utility to extend the built-in container', 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, {
loadModule: async () => ({
module: {
theme: {
container: {
center: true,
padding: '2rem',
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.container {
margin-inline: auto;
padding-inline: 2rem;
}
"
`)
})
test('allows padding to be defined at custom breakpoints', 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, {
loadModule: async () => ({
module: {
theme: {
container: {
padding: {
// The order here is messed up on purpose
'2xl': '3rem',
DEFAULT: '1rem',
lg: '2rem',
},
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.container {
padding-inline: 1rem;
@media (width >= 64rem) {
padding-inline: 2rem;
}
@media (width >= 96rem) {
padding-inline: 3rem;
}
}
"
`)
})
test('allows breakpoints to be overwritten', 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, {
loadModule: async () => ({
module: {
theme: {
container: {
screens: {
xl: '1280px',
'2xl': '1536px',
},
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.container {
@media (width >= 40rem) {
max-width: none;
}
@media (width >= 1280px) {
max-width: 1280px;
}
@media (width >= 1536px) {
max-width: 1536px;
}
}
"
`)
})
test('padding applies to custom `container` screens', 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, {
loadModule: async () => ({
module: {
theme: {
container: {
padding: {
sm: '2rem',
md: '3rem',
},
screens: {
md: '48rem',
},
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.container {
@media (width >= 40rem) {
max-width: none;
}
@media (width >= 48rem) {
max-width: 48rem;
padding-inline: 3rem;
}
}
"
`)
})
test("an empty `screen` config will undo all custom media screens and won't apply any breakpoint-specific padding", 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, {
loadModule: async () => ({
module: {
theme: {
container: {
padding: {
DEFAULT: '1rem',
sm: '2rem',
md: '3rem',
},
screens: {},
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.container {
padding-inline: 1rem;
@media (width >= 40rem) {
max-width: none;
}
}
"
`)
})
test('legacy container component does not interfere with new --container variables', async () => {
let input = css`
@theme default {
--container-3xs: 16rem;
--container-2xs: 18rem;
--container-xs: 20rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
--container-xl: 36rem;
--container-2xl: 42rem;
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem;
--container-7xl: 80rem;
--container-prose: 65ch;
}
@config "./config.js";
@tailwind utilities;
`
let compiler = await compile(input, {
loadModule: async () => ({
module: {
theme: {
container: {
center: true,
padding: '2rem',
},
},
},
base: '/root',
}),
})
expect(compiler.build(['max-w-sm'])).toMatchInlineSnapshot(`
":root {
--container-3xs: 16rem;
--container-2xs: 18rem;
--container-xs: 20rem;
--container-sm: 24rem;
--container-md: 28rem;
--container-lg: 32rem;
--container-xl: 36rem;
--container-2xl: 42rem;
--container-3xl: 48rem;
--container-4xl: 56rem;
--container-5xl: 64rem;
--container-6xl: 72rem;
--container-7xl: 80rem;
--container-prose: 65ch;
}
.max-w-sm {
max-width: var(--container-sm);
}
"
`)
})
test('combines custom padding and screen overwrites', 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, {
loadModule: async () => ({
module: {
theme: {
container: {
center: true,
padding: {
DEFAULT: '2rem',
'2xl': '4rem',
},
screens: {
md: '48rem', // Matches a default --breakpoint
xl: '1280px',
'2xl': '1536px',
},
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container', '!container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.\\!container {
width: 100% !important;
@media (width >= 40rem) {
max-width: 40rem !important;
}
@media (width >= 48rem) {
max-width: 48rem !important;
}
@media (width >= 64rem) {
max-width: 64rem !important;
}
@media (width >= 80rem) {
max-width: 80rem !important;
}
@media (width >= 96rem) {
max-width: 96rem !important;
}
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.\\!container {
margin-inline: auto !important;
padding-inline: 2rem !important;
@media (width >= 40rem) {
max-width: none !important;
}
@media (width >= 48rem) {
max-width: 48rem !important;
}
@media (width >= 1280px) {
max-width: 1280px !important;
}
@media (width >= 1536px) {
max-width: 1536px !important;
padding-inline: 4rem !important;
}
}
.container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= 40rem) {
max-width: none;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 1280px) {
max-width: 1280px;
}
@media (width >= 1536px) {
max-width: 1536px;
padding-inline: 4rem;
}
}
"
`)
})
test('filters out complex breakpoints', 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, {
loadModule: async () => ({
module: {
theme: {
container: {
center: true,
padding: {
DEFAULT: '2rem',
'2xl': '4rem',
},
screens: {
sm: '20px',
md: { min: '100px' },
lg: { max: '200px' },
xl: { min: '300px', max: '400px' },
'2xl': { raw: 'print' },
},
},
},
},
base: '/root',
}),
})
expect(compiler.build(['container'])).toMatchInlineSnapshot(`
":root {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= 40rem) {
max-width: none;
}
@media (width >= 20px) {
max-width: 20px;
}
@media (width >= 100px) {
max-width: 100px;
}
@media (width >= 300px) {
max-width: 300px;
}
}
"
`)
})

View File

@ -0,0 +1,120 @@
import { atRule, decl, type AstNode, type AtRule } from '../ast'
import type { DesignSystem } from '../design-system'
import { compareBreakpoints } from '../utils/compare-breakpoints'
import type { ResolvedConfig } from './config/types'
export function registerContainerCompat(userConfig: ResolvedConfig, designSystem: DesignSystem) {
let container = userConfig.theme.container || {}
if (typeof container !== 'object' || container === null) {
return
}
let rules = buildCustomContainerUtilityRules(container, designSystem)
if (rules.length === 0) {
return
}
designSystem.utilities.static('container', () => structuredClone(rules))
}
export function buildCustomContainerUtilityRules(
{
center,
padding,
screens,
}: {
center?: boolean
padding?: string | {}
screens?: {}
},
designSystem: DesignSystem,
): AstNode[] {
let rules = []
let breakpointOverwrites: null | Map<string, AtRule> = null
if (center) {
rules.push(decl('margin-inline', 'auto'))
}
if (
typeof padding === 'string' ||
(typeof padding === 'object' && padding !== null && 'DEFAULT' in padding)
) {
rules.push(
decl('padding-inline', typeof padding === 'string' ? padding : (padding.DEFAULT as string)),
)
}
if (typeof screens === 'object' && screens !== null) {
breakpointOverwrites = new Map()
// When setting a the `screens` in v3, you were overwriting the default
// screens config. To do this in v4, you have to manually unset all core
// screens.
let breakpoints = Array.from(designSystem.theme.namespace('--breakpoint').entries())
breakpoints.sort((a, z) => compareBreakpoints(a[1], z[1], 'asc'))
if (breakpoints.length > 0) {
let [key] = breakpoints[0]
// Unset all default breakpoints
rules.push(
atRule('@media', `(width >= theme(--breakpoint-${key}))`, [decl('max-width', 'none')]),
)
}
for (let [key, value] of Object.entries(screens)) {
if (typeof value === 'object') {
if ('min' in value) {
value = value.min
} else {
continue
}
}
// We're inlining the breakpoint values because the screens configured in
// the `container` option do not have to match the ones defined in the
// root `screen` setting.
breakpointOverwrites.set(
key,
atRule('@media', `(width >= ${value})`, [decl('max-width', value)]),
)
}
}
if (typeof padding === 'object' && padding !== null) {
let breakpoints = Object.entries(padding)
.filter(([key]) => key !== 'DEFAULT')
.map(([key, value]) => {
return [key, designSystem.theme.resolveValue(key, ['--breakpoint']), value]
})
.filter(Boolean) as [string, string, string][]
breakpoints.sort((a, z) => compareBreakpoints(a[1], z[1], 'asc'))
for (let [key, , value] of breakpoints) {
if (breakpointOverwrites && breakpointOverwrites.has(key)) {
let overwrite = breakpointOverwrites.get(key)!
overwrite.nodes.push(decl('padding-inline', value))
} else if (breakpointOverwrites) {
// The breakpoint does not exist in the overwritten breakpoints list, so
// we skip rendering it.
continue
} else {
rules.push(
atRule('@media', `(width >= theme(--breakpoint-${key}))`, [
decl('padding-inline', value),
]),
)
}
}
}
if (breakpointOverwrites) {
for (let [, rule] of breakpointOverwrites) {
rules.push(rule)
}
}
return rules
}

View File

@ -904,7 +904,7 @@ export function createUtilities(theme: Theme) {
let decls: AstNode[] = [decl('--tw-sort', '--tw-container-component'), decl('width', '100%')]
for (let breakpoint of breakpoints) {
decls.push(atRule('@media', `(min-width: ${breakpoint})`, [decl('max-width', breakpoint)]))
decls.push(atRule('@media', `(width >= ${breakpoint})`, [decl('max-width', breakpoint)]))
}
return decls