diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d2c780fd0b..1b25587a93a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 6e03c77c62de..6cd862cfd632 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -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` +
+ `, + }, + }, + async ({ exec, fs }) => { + await exec('npx @tailwindcss/upgrade') + + expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` + " + --- src/index.html --- +
+ + --- 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); + } + } + " + `) + }, + ) }) diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts index 84a062b461b8..8291812ee616 100644 --- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts @@ -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 diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index c4cfec81cb06..7a5aa750ce6a 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -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) { diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index a5f3e0470e78..64f8d0c210b3 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -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' diff --git a/packages/tailwindcss/src/compat/container-config.test.ts b/packages/tailwindcss/src/compat/container-config.test.ts new file mode 100644 index 000000000000..965e79a21865 --- /dev/null +++ b/packages/tailwindcss/src/compat/container-config.test.ts @@ -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; + } + } + " + `) +}) diff --git a/packages/tailwindcss/src/compat/container.ts b/packages/tailwindcss/src/compat/container.ts new file mode 100644 index 000000000000..99aea1e42378 --- /dev/null +++ b/packages/tailwindcss/src/compat/container.ts @@ -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 = 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 +} diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index f979b4a583d6..0b93a10f7ca3 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -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