diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f3f1b4c55e..b7a97d63d85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _Upgrade (experimental)_: Rename `drop-shadow` to `drop-shadow-sm` and `drop-shadow-sm` to `drop-shadow-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875)) - _Upgrade (experimental)_: Rename `rounded` to `rounded-sm` and `rounded-sm` to `rounded-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875)) - _Upgrade (experimental)_: Rename `blur` to `blur-sm` and `blur-sm` to `blur-xs` ([#14875](https://github.com/tailwindlabs/tailwindcss/pull/14875)) +- _Upgrade (experimental)_: Migrate `theme()` usage and JS config files to use the new `--spacing` multiplier where possible ([#14905](https://github.com/tailwindlabs/tailwindcss/pull/14905)) ### Fixed diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index ccc6e22f96cb..5db64a52bb1e 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -1415,4 +1415,260 @@ describe('border compatibility', () => { `) }, ) + + test( + 'migrates extended spacing keys', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + import { type Config } from 'tailwindcss' + + export default { + content: ['./src/**/*.html'], + theme: { + extend: { + spacing: { + 2: '0.5rem', + 4.5: '1.125rem', + 5.5: '1.375em', // Units are different from --spacing scale + 13: '3.25rem', + 100: '100px', + miami: '1337px', + }, + }, + }, + } satisfies Config + `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + + .container { + width: theme(spacing.2); + width: theme(spacing[4.5]); + width: theme(spacing[5.5]); + width: theme(spacing[13]); + width: theme(spacing[100]); + width: theme(spacing.miami); + } + `, + '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'; + + @theme { + --spacing-100: 100px; + --spacing-5_5: 1.375em; + --spacing-miami: 1337px; + } + + /* + 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); + } + } + + /* + Form elements have a 1px border by default 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 \`border-0\` to + any form elements that shouldn't have a border. + */ + @layer base { + input:where(:not([type='button'], [type='reset'], [type='submit'])), + select, + textarea { + border-width: 0; + } + } + + .container { + width: calc(var(--spacing) * 2); + width: calc(var(--spacing) * 4.5); + width: var(--spacing-5_5); + width: calc(var(--spacing) * 13); + width: var(--spacing-100); + width: var(--spacing-miami); + } + " + `) + }, + ) + + test( + 'retains overwriting spacing scale', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + import { type Config } from 'tailwindcss' + + export default { + content: ['./src/**/*.html'], + theme: { + spacing: { + 2: '0.5rem', + 4.5: '1.125rem', + 5.5: '1.375em', + 13: '3.25rem', + 100: '100px', + miami: '1337px', + }, + }, + } satisfies Config + `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + + .container { + width: theme(spacing.2); + width: theme(spacing[4.5]); + width: theme(spacing[5.5]); + width: theme(spacing[13]); + width: theme(spacing[100]); + width: theme(spacing.miami); + } + `, + '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'; + + @theme { + --spacing-*: initial; + --spacing-2: 0.5rem; + --spacing-13: 3.25rem; + --spacing-100: 100px; + --spacing-4_5: 1.125rem; + --spacing-5_5: 1.375em; + --spacing-miami: 1337px; + } + + /* + 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); + } + } + + /* + Form elements have a 1px border by default 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 \`border-0\` to + any form elements that shouldn't have a border. + */ + @layer base { + input:where(:not([type='button'], [type='reset'], [type='submit'])), + select, + textarea { + border-width: 0; + } + } + + .container { + width: var(--spacing-2); + width: var(--spacing-4_5); + width: var(--spacing-5_5); + width: var(--spacing-13); + width: var(--spacing-100); + width: var(--spacing-miami); + } + " + `) + }, + ) }) diff --git a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts index be33b726bb4e..63fc094db8fb 100644 --- a/packages/@tailwindcss-upgrade/src/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/migrate-js-config.ts @@ -12,10 +12,11 @@ import { } from '../../tailwindcss/src/compat/apply-config-to-theme' import { keyframesToRules } from '../../tailwindcss/src/compat/apply-keyframes-to-theme' import { resolveConfig, type ConfigFile } from '../../tailwindcss/src/compat/config/resolve-config' -import type { ThemeConfig } from '../../tailwindcss/src/compat/config/types' +import type { ResolvedConfig, ThemeConfig } from '../../tailwindcss/src/compat/config/types' import { darkModePlugin } from '../../tailwindcss/src/compat/dark-mode' import type { DesignSystem } from '../../tailwindcss/src/design-system' import { escape } from '../../tailwindcss/src/utils/escape' +import { isValidSpacingMultiplier } from '../../tailwindcss/src/utils/infer-data-type' import { findStaticPlugins, type StaticPluginOptions } from './utils/extract-static-plugins' import { info } from './utils/renderer' @@ -101,6 +102,8 @@ async function migrateTheme( Array.from(replacedThemeKeys.entries()).map(([key]) => [key, false]), ) + removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys) + let prevSectionKey = '' let css = '\n@tw-bucket theme {\n' css += `\n@theme {\n` @@ -317,3 +320,42 @@ function patternSourceFiles(source: { base: string; pattern: string }): string[] scanner.scan() return scanner.files } + +function removeUnnecessarySpacingKeys( + designSystem: DesignSystem, + resolvedConfig: ResolvedConfig, + replacedThemeKeys: Set, +) { + // We want to keep the spacing scale as-is if the user is overwriting + if (replacedThemeKeys.has('spacing')) return + + // Ensure we have a spacing multiplier + let spacingScale = designSystem.theme.get(['--spacing']) + if (!spacingScale) return + + let [spacingMultiplier, spacingUnit] = splitNumberAndUnit(spacingScale) + if (!spacingMultiplier || !spacingUnit) return + + if (spacingScale && !replacedThemeKeys.has('spacing')) { + for (let [key, value] of Object.entries(resolvedConfig.theme.spacing ?? {})) { + let [multiplier, unit] = splitNumberAndUnit(value as string) + if (multiplier === null) continue + + if (!isValidSpacingMultiplier(key)) continue + if (unit !== spacingUnit) continue + + if (parseFloat(multiplier) === Number(key) * parseFloat(spacingMultiplier)) { + delete resolvedConfig.theme.spacing[key] + designSystem.theme.clearNamespace(escape(`--spacing-${key.replaceAll('.', '_')}`), 0) + } + } + } +} + +function splitNumberAndUnit(value: string): [string, string] | [null, null] { + let match = value.match(/^([0-9.]+)(.*)$/) + if (!match) { + return [null, null] + } + return [match[1], match[2]] +} diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts index 3dcbaeecac87..0e6850672586 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.test.ts @@ -9,14 +9,18 @@ test.each([ ['[color:red]', '[color:red]'], // Handle special cases around `.1` in the `theme(…)` - ['[--value:theme(spacing.1)]', '[--value:var(--spacing-1)]'], + ['[--value:theme(spacing.1)]', '[--value:calc(var(--spacing)*1)]'], ['[--value:theme(fontSize.xs.1.lineHeight)]', '[--value:var(--text-xs--line-height)]'], + ['[--value:theme(spacing[1.25])]', '[--value:calc(var(--spacing)*1.25)]'], + + // Should not convert invalid spacing values to calc + ['[--value:theme(spacing[1.1])]', '[--value:theme(spacing[1.1])]'], // Convert to `var(…)` if we can resolve the path ['[color:theme(colors.red.500)]', '[color:var(--color-red-500)]'], // Arbitrary property ['[color:theme(colors.red.500)]/50', '[color:var(--color-red-500)]/50'], // Arbitrary property + modifier ['bg-[theme(colors.red.500)]', 'bg-[var(--color-red-500)]'], // Arbitrary value - ['bg-[size:theme(spacing.4)]', 'bg-[size:var(--spacing-4)]'], // Arbitrary value + data type hint + ['bg-[size:theme(spacing.4)]', 'bg-[size:calc(var(--spacing)*4)]'], // Arbitrary value + data type hint // Convert to `var(…)` if we can resolve the path, but keep fallback values ['bg-[theme(colors.red.500,red)]', 'bg-[var(--color-red-500,red)]'], @@ -79,15 +83,20 @@ test.each([ // Variants, we can't use `var(…)` especially inside of `@media(…)`. We can // still upgrade the `theme(…)` to the modern syntax. - ['max-[theme(spacing.4)]:flex', 'max-[theme(--spacing-4)]:flex'], + ['max-[theme(screens.lg)]:flex', 'max-[theme(--breakpoint-lg)]:flex'], + // There are no variables for `--spacing` multiples, so we can't convert this + ['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'], // This test in itself doesn't make much sense. But we need to make sure // that this doesn't end up as the modifier in the candidate itself. - ['max-[theme(spacing.4/50)]:flex', 'max-[theme(--spacing-4/50)]:flex'], + ['max-[theme(spacing.4/50)]:flex', 'max-[theme(spacing.4/50)]:flex'], // `theme(…)` calls in another CSS function is replaced correctly. // Additionally we remove unnecessary whitespace. - ['grid-cols-[min(50%_,_theme(spacing.80))_auto]', 'grid-cols-[min(50%,var(--spacing-80))_auto]'], + [ + 'grid-cols-[min(50%_,_theme(spacing.80))_auto]', + 'grid-cols-[min(50%,calc(var(--spacing)*80))_auto]', + ], // `theme(…)` calls valid in v3, but not in v4 should still be converted. ['[--foo:theme(transitionDuration.500)]', '[--foo:theme(transitionDuration.500)]'], @@ -110,51 +119,66 @@ test.each([ '[--foo:theme(colors.red.500/50/50)_var(--color-blue-200)]/50', ], ])('%s => %s', async (candidate, result) => { + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + `, + { + base: __dirname, + }, + ) + + expect(themeToVar(designSystem, {}, candidate)).toEqual(result) +}) + +test('extended space scale converts to var or calc', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @import 'tailwindcss'; + @theme { + --spacing-2: 2px; + --spacing-miami: 0.875rem; + } + `, + { + base: __dirname, + }, + ) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual( + '[--value:calc(var(--spacing)*1)]', + ) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual( + '[--value:var(--spacing-2)]', + ) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.miami)]')).toEqual( + '[--value:var(--spacing-miami)]', + ) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.nyc)]')).toEqual( + '[--value:theme(spacing.nyc)]', + ) +}) + +test('custom space scale converts to var', async () => { let designSystem = await __unstable__loadDesignSystem( css` @import 'tailwindcss'; @theme { - --spacing-px: 1px; - --spacing-0: 0px; - --spacing-0_5: 0.125rem; + --spacing-*: initial; --spacing-1: 0.25rem; - --spacing-1_5: 0.375rem; --spacing-2: 0.5rem; - --spacing-2_5: 0.625rem; - --spacing-3: 0.75rem; - --spacing-3_5: 0.875rem; - --spacing-4: 1rem; - --spacing-5: 1.25rem; - --spacing-6: 1.5rem; - --spacing-7: 1.75rem; - --spacing-8: 2rem; - --spacing-9: 2.25rem; - --spacing-10: 2.5rem; - --spacing-11: 2.75rem; - --spacing-12: 3rem; - --spacing-14: 3.5rem; - --spacing-16: 4rem; - --spacing-20: 5rem; - --spacing-24: 6rem; - --spacing-28: 7rem; - --spacing-32: 8rem; - --spacing-36: 9rem; - --spacing-40: 10rem; - --spacing-44: 11rem; - --spacing-48: 12rem; - --spacing-52: 13rem; - --spacing-56: 14rem; - --spacing-60: 15rem; - --spacing-64: 16rem; - --spacing-72: 18rem; - --spacing-80: 20rem; - --spacing-96: 24rem; } `, { base: __dirname, }, ) - - expect(themeToVar(designSystem, {}, candidate)).toEqual(result) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.1)]')).toEqual( + '[--value:var(--spacing-1)]', + ) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.2)]')).toEqual( + '[--value:var(--spacing-2)]', + ) + expect(themeToVar(designSystem, {}, '[--value:theme(spacing.3)]')).toEqual( + '[--value:theme(spacing.3)]', + ) }) diff --git a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts index 4934108e85d2..21ddf7dcd1c7 100644 --- a/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts +++ b/packages/@tailwindcss-upgrade/src/template/codemods/theme-to-var.ts @@ -7,6 +7,7 @@ import { } from '../../../../tailwindcss/src/candidate' import { keyPathToCssProperty } from '../../../../tailwindcss/src/compat/apply-config-to-theme' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { isValidSpacingMultiplier } from '../../../../tailwindcss/src/utils/infer-data-type' import { segment } from '../../../../tailwindcss/src/utils/segment' import { toKeyPath } from '../../../../tailwindcss/src/utils/to-key-path' import * as ValueParser from '../../../../tailwindcss/src/value-parser' @@ -199,9 +200,17 @@ export function createConverter(designSystem: DesignSystem, { prettyPrint = fals function toVar(path: string, fallback?: string) { let variable = pathToVariableName(path) - if (!variable) return null + if (variable) return fallback ? `var(${variable}, ${fallback})` : `var(${variable})` + + let keyPath = toKeyPath(path) + if (keyPath[0] === 'spacing' && designSystem.theme.get(['--spacing'])) { + let multiplier = keyPath[1] + if (!isValidSpacingMultiplier(multiplier)) return null + + return 'calc(var(--spacing) * ' + multiplier + ')' + } - return fallback ? `var(${variable}, ${fallback})` : `var(${variable})` + return null } function toTheme(path: string, fallback?: string) { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 321c7a6b8113..f57c4cccec9d 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -2,7 +2,7 @@ import { atRoot, atRule, decl, styleRule, type AstNode } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' import type { Theme, ThemeKey } from './theme' import { DefaultMap } from './utils/default-map' -import { inferDataType, isPositiveInteger } from './utils/infer-data-type' +import { inferDataType, isPositiveInteger, isValidSpacingMultiplier } from './utils/infer-data-type' import { replaceShadowColors } from './utils/replace-shadow-colors' import { segment } from './utils/segment' @@ -397,9 +397,7 @@ export function createUtilities(theme: Theme) { handleBareValue: ({ value }) => { let multiplier = theme.resolve(null, ['--spacing']) if (!multiplier) return null - - let num = Number(value) - if (num < 0 || num % 0.25 !== 0 || String(num) !== value) return null + if (!isValidSpacingMultiplier(value)) return null return `calc(${multiplier} * ${value})` }, diff --git a/packages/tailwindcss/src/utils/infer-data-type.ts b/packages/tailwindcss/src/utils/infer-data-type.ts index 53abed449c5d..6aa34fbe1d7e 100644 --- a/packages/tailwindcss/src/utils/infer-data-type.ts +++ b/packages/tailwindcss/src/utils/infer-data-type.ts @@ -328,3 +328,11 @@ export function isPositiveInteger(value: any) { let num = Number(value) return Number.isInteger(num) && num >= 0 && String(num) === String(value) } + +/** + * Returns true if the value is either a positive whole number or a multiple of 0.25. + */ +export function isValidSpacingMultiplier(value: any) { + let num = Number(value) + return num >= 0 && num % 0.25 === 0 && String(num) === String(value) +}