diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c56a754e..61236b94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support TypeScript for `@plugin` and `@config` files ([#14317](https://github.com/tailwindlabs/tailwindcss/pull/14317)) +- Add `default` option to `@theme` to support overriding default theme values from plugins/JS config files ([#14327](https://github.com/tailwindlabs/tailwindcss/pull/14327)) ### Fixed @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure CSS `theme()` functions are evaluated in media query ranges with collapsed whitespace ((#14321)[https://github.com/tailwindlabs/tailwindcss/pull/14321]) - Fix support for Nuxt projects in the Vite plugin (requires Nuxt 3.13.1+) ([#14319](https://github.com/tailwindlabs/tailwindcss/pull/14319)) - Evaluate theme functions in plugins and JS config files ([#14326](https://github.com/tailwindlabs/tailwindcss/pull/14326)) +- Ensure theme values overridden with `reference` values don't generate stale CSS variables ([#14327](https://github.com/tailwindlabs/tailwindcss/pull/14327)) ## [4.0.0-alpha.21] - 2024-09-02 diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index e62b2b741..325c37118 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -10,7 +10,8 @@ export function applyConfigToTheme(designSystem: DesignSystem, configs: ConfigFi designSystem.theme.add(`--${name}`, value as any, { isInline: true, - isReference: false, + isReference: true, + isDefault: true, }) } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 9a52d5af4..d4fc57379 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1292,6 +1292,215 @@ describe('Parsing themes values from CSS', () => { }" `) }) + + test('`default` theme values can be overridden by regular theme values`', async () => { + expect( + await compileCss( + css` + @theme { + --color-potato: #ac855b; + } + @theme default { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #ac855b; + } + + .bg-potato { + background-color: var(--color-potato, #ac855b); + }" + `) + }) + + test('`default` and `inline` can be used together', async () => { + expect( + await compileCss( + css` + @theme default inline { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #efb46b; + } + + .bg-potato { + background-color: #efb46b; + }" + `) + }) + + test('`default` and `reference` can be used together', async () => { + expect( + await compileCss( + css` + @theme default reference { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: var(--color-potato, #efb46b); + }" + `) + }) + + test('`default`, `inline`, and `reference` can be used together', async () => { + expect( + await compileCss( + css` + @theme default reference inline { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: #efb46b; + }" + `) + }) + + test('`default` can be used in `media(…)`', async () => { + expect( + await compileCss( + css` + @media theme() { + @theme { + --color-potato: #ac855b; + } + } + @media theme(default) { + @theme { + --color-potato: #efb46b; + --color-tomato: tomato; + } + } + + @tailwind utilities; + `, + ['bg-potato', 'bg-tomato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #ac855b; + --color-tomato: tomato; + } + + .bg-potato { + background-color: var(--color-potato, #ac855b); + } + + .bg-tomato { + background-color: var(--color-tomato, tomato); + }" + `) + }) + + test('`default` theme values can be overridden by plugin theme values', async () => { + let { build } = await compile( + css` + @theme default { + --color-red: red; + } + @theme { + --color-orange: orange; + } + @plugin "my-plugin"; + @tailwind utilities; + `, + { + loadPlugin: async () => { + return plugin(({}) => {}, { + theme: { + extend: { + colors: { + red: 'tomato', + orange: '#f28500', + }, + }, + }, + }) + }, + }, + ) + + expect(optimizeCss(build(['text-red', 'text-orange'])).trim()).toMatchInlineSnapshot(` + ":root { + --color-orange: orange; + } + + .text-orange { + color: var(--color-orange, orange); + } + + .text-red { + color: tomato; + }" + `) + }) + + test('`default` theme values can be overridden by config theme values', async () => { + let { build } = await compile( + css` + @theme default { + --color-red: red; + } + @theme { + --color-orange: orange; + } + @config "./my-config.js"; + @tailwind utilities; + `, + { + loadConfig: async () => { + return { + theme: { + extend: { + colors: { + red: 'tomato', + orange: '#f28500', + }, + }, + }, + } + }, + }, + ) + + expect(optimizeCss(build(['text-red', 'text-orange'])).trim()).toMatchInlineSnapshot(` + ":root { + --color-orange: orange; + } + + .text-orange { + color: var(--color-orange, orange); + } + + .text-red { + color: tomato; + }" + `) + }) }) describe('plugins', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 893e83984..e31a82f22 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -28,16 +28,19 @@ function throwOnConfig(): never { function parseThemeOptions(selector: string) { let isReference = false let isInline = false + let isDefault = false for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) { if (option === 'reference') { isReference = true } else if (option === 'inline') { isInline = true + } else if (option === 'default') { + isDefault = true } } - return { isReference, isInline } + return { isReference, isInline, isDefault } } async function parseCss( @@ -253,8 +256,8 @@ async function parseCss( 'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.', ) } - if (child.selector === '@theme') { - child.selector = '@theme ' + themeParams + if (child.selector === '@theme' || child.selector.startsWith('@theme ')) { + child.selector += ' ' + themeParams return WalkAction.Skip } }) @@ -264,7 +267,7 @@ async function parseCss( if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return - let { isReference, isInline } = parseThemeOptions(node.selector) + let { isReference, isInline, isDefault } = parseThemeOptions(node.selector) // Record all custom properties in the `@theme` declaration walk(node.nodes, (child, { replaceWith }) => { @@ -278,7 +281,7 @@ async function parseCss( if (child.kind === 'comment') return if (child.kind === 'declaration' && child.property.startsWith('--')) { - theme.add(child.property, child.value ?? '', { isReference, isInline }) + theme.add(child.property, child.value ?? '', { isReference, isInline, isDefault }) return } @@ -302,6 +305,33 @@ async function parseCss( return WalkAction.Skip }) + let designSystem = buildDesignSystem(theme) + + let configs = await Promise.all( + configPaths.map(async (configPath) => ({ + path: configPath, + config: await loadConfig(configPath), + })), + ) + + let plugins = await Promise.all( + pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ + path: pluginPath, + plugin: await loadPlugin(pluginPath), + options: pluginOptions, + })), + ) + + let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs) + + for (let customVariant of customVariants) { + customVariant(designSystem) + } + + for (let customUtility of customUtilities) { + customUtility(designSystem) + } + // Output final set of theme variables at the position of the first `@theme` // rule. if (firstThemeRule) { @@ -340,33 +370,6 @@ async function parseCss( firstThemeRule.nodes = nodes } - let designSystem = buildDesignSystem(theme) - - let configs = await Promise.all( - configPaths.map(async (configPath) => ({ - path: configPath, - config: await loadConfig(configPath), - })), - ) - - let plugins = await Promise.all( - pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ - path: pluginPath, - plugin: await loadPlugin(pluginPath), - options: pluginOptions, - })), - ) - - let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs) - - for (let customVariant of customVariants) { - customVariant(designSystem) - } - - for (let customUtility of customUtilities) { - customUtility(designSystem) - } - // Replace `@apply` rules with the actual utility classes. if (css.includes('@apply')) { substituteAtApply(ast, designSystem) diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 178472b96..2a7bdb80e 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -2,10 +2,17 @@ import { escape } from './utils/escape' export class Theme { constructor( - private values = new Map(), + private values = new Map< + string, + { value: string; isReference: boolean; isInline: boolean; isDefault: boolean } + >(), ) {} - add(key: string, value: string, { isReference = false, isInline = false } = {}): void { + add( + key: string, + value: string, + { isReference = false, isInline = false, isDefault = false } = {}, + ): void { if (key.endsWith('-*')) { if (value !== 'initial') { throw new Error(`Invalid theme value \`${value}\` for namespace \`${key}\``) @@ -17,10 +24,15 @@ export class Theme { } } + if (isDefault) { + let existing = this.values.get(key) + if (existing && !existing.isDefault) return + } + if (value === 'initial') { this.values.delete(key) } else { - this.values.set(key, { value, isReference, isInline }) + this.values.set(key, { value, isReference, isInline, isDefault }) } } diff --git a/packages/tailwindcss/theme.css b/packages/tailwindcss/theme.css index 190e51f09..a6de6e84a 100644 --- a/packages/tailwindcss/theme.css +++ b/packages/tailwindcss/theme.css @@ -1,4 +1,4 @@ -@theme { +@theme default { /* Defaults */ --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);