From e000caa0bdaa9f67cc0ce7ea1ea43101af10ce32 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Fri, 2 Aug 2024 09:37:30 -0400 Subject: [PATCH] Add support for `inline` option when defining `@theme` values (#14095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for a new `inline` option when defining a `@theme` block that tells Tailwind to use raw theme values for utilities instead of referencing the corresponding generated CSS variable. ```css /* Input */ @theme inline { --color-red-500: #ef4444; /* ... */ } /* Example output */ :root { --color-red-500: #ef4444; } .text-red-500 { color: #ef4444; } ``` This can be composed with the existing `reference` option in case you want to define a `@theme` block as both `reference` (so the variables aren't generated) and `inline`: ```css /* Input */ @theme inline reference { --color-red-500: #ef4444; /* ... */ } /* Example output */ .text-red-500 { color: #ef4444; } ``` Since you can have multiple `@theme` blocks, you can even define some values normally and some as inline based on how you're using them. For example you might want to use `inline` for defining literal tokens like `--color-red-500`, but include the variable for tokens that you want to be able to theme like `--color-primary`: ```css /* Input */ @theme inline { --color-red-500: #ef4444; /* ... */ } @theme { --color-primary: var(--color-red-500); } /* Example output */ :root { --color-red-500: #ef4444; --color-primary: var(--color-red-500); } .text-red-500 { color: #ef4444; } .text-primary { color: var(--color-primary, var(--color-red-500)); } ``` ## Breaking changes Prior to this PR, you could `@import` a stylesheet that contained `@theme` blocks as reference by adding the `reference` keyword to your import: ```css @import "./my-theme.css" reference; ``` Now that `reference` isn't the only possible option when declaring your `@theme`, this syntax has changed to a new `theme(…)` function that accepts `reference` and `inline` as potential space-separated values: ```css @import "./my-theme.css"; @import "./my-theme.css" theme(reference); @import "./my-theme.css" theme(inline); @import "./my-theme.css" theme(reference inline); ``` If you are using the `@import … reference` option with an earlier alpha release, you'll need to update your code to `@import … theme(reference)` once this PR lands in a release. ## Motivation This PR is designed to solve an issue pointed out in #14091. Prior to this PR, generated utilities would always reference variables directly, with the raw value as a fallback: ```css /* Input */ @theme { --color-red-500: #ef4444; /* ... */ } /* Example output */ :root { --color-red-500: #ef4444; } .text-red-500 { color: var(--color-red-500, #ef4444); } ``` But this can create issues with variables resolving to an unexpected value when a theme value is referencing another variable defined on `:root`. For example, say you have a CSS file like this: ```css :root, .light { --text-fg: #000; } .dark { --text-fg: #fff; } @theme { --color-fg: var(--text-fg); } ``` Without `@theme inline`, we'd generate this output if you used the `text-fg` utility: ```css :root, .light { --text-fg: #000; } .dark { --text-fg: #fff; } :root { --color-fg: var(--text-fg); } .text-fg { color: var(--color-fg, var(--text-fg)); } ``` Now if you wrote this HTML, you're probably expecting your text to be the dark mode color: ```html

Hello world

``` But you'd actually get the light mode color because of this rule: ```css :root { --color-fg: var(--text-fg); } .text-fg { color: var(--color-fg, var(--text-fg)); } ``` The browser will try to resolve the `--color-fg` variable, which is defined on `:root`. When it tries to resolve the value, _it uses the value of `var(--text-fg)` as it would resolve at `:root`_, not what it would resolve to based on the element that has the `text-fg` class. So `var(--color-fg)` resolves to `#000` because `var(--text-fg)` resolved to `#000` at the point in the tree where the browser resolved the value of `var(--color-fg)`. By using `@theme inline`, the `.text-fg` class looks like this: ```css .text-fg { color: var(--text-fg); } ``` With this definition, the browser doesn't try to resolve `--color-fg` at all and instead resolves `--text-fg` directly which correctly resolves to `#fff` as expected. --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Co-authored-by: Robin Malfait --- CHANGELOG.md | 4 +- .../@tailwindcss-postcss/src/index.test.ts | 2 +- packages/tailwindcss/src/index.test.ts | 140 +++++++++++++++++- packages/tailwindcss/src/index.ts | 43 ++++-- packages/tailwindcss/src/theme.ts | 14 +- 5 files changed, 182 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f13d7210..0991de2e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- Add support for `inline` option when defining `@theme` values ([#14095](https://github.com/tailwindlabs/tailwindcss/pull/14095)) ## [4.0.0-alpha.18] - 2024-07-25 diff --git a/packages/@tailwindcss-postcss/src/index.test.ts b/packages/@tailwindcss-postcss/src/index.test.ts index 4d376db9a..7aeae5c6c 100644 --- a/packages/@tailwindcss-postcss/src/index.test.ts +++ b/packages/@tailwindcss-postcss/src/index.test.ts @@ -94,7 +94,7 @@ test('@apply can be used without emitting the theme in the CSS file', async () = // `@apply` is used. let result = await processor.process( css` - @import 'tailwindcss/theme.css' reference; + @import 'tailwindcss/theme.css' theme(reference); .foo { @apply text-red-500; } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index bb0f355a6..9e1a5a8dd 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1075,7 +1075,7 @@ describe('Parsing themes values from CSS', () => { @theme { --color-tomato: #e10c04; } - @media reference { + @media theme(reference) { @theme { --color-potato: #ac855b; } @@ -1106,11 +1106,11 @@ describe('Parsing themes values from CSS', () => { `) }) - test('`@media reference` can only contain `@theme` rules', () => { + test('`@media theme(…)` can only contain `@theme` rules', () => { expect(() => compileCss( css` - @media reference { + @media theme(reference) { .not-a-theme-rule { color: cursed; } @@ -1120,9 +1120,141 @@ describe('Parsing themes values from CSS', () => { ['bg-tomato', 'bg-potato', 'bg-avocado'], ), ).toThrowErrorMatchingInlineSnapshot( - `[Error: Files imported with \`@import "…" reference\` must only contain \`@theme\` blocks.]`, + `[Error: Files imported with \`@import "…" theme(…)\` must only contain \`@theme\` blocks.]`, ) }) + + test('theme values added as `inline` are not wrapped in `var(…)` when used as utility values', () => { + expect( + compileCss( + css` + @theme inline { + --color-tomato: #e10c04; + --color-potato: #ac855b; + --color-primary: var(--primary); + } + + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato', 'bg-primary'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-tomato: #e10c04; + --color-potato: #ac855b; + --color-primary: var(--primary); + } + + .bg-potato { + background-color: #ac855b; + } + + .bg-primary { + background-color: var(--primary); + } + + .bg-tomato { + background-color: #e10c04; + }" + `) + }) + + test('wrapping `@theme` with `@media theme(inline)` behaves like `@theme inline` to support `@import` statements', () => { + expect( + compileCss( + css` + @media theme(inline) { + @theme { + --color-tomato: #e10c04; + --color-potato: #ac855b; + --color-primary: var(--primary); + } + } + + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato', 'bg-primary'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-tomato: #e10c04; + --color-potato: #ac855b; + --color-primary: var(--primary); + } + + .bg-potato { + background-color: #ac855b; + } + + .bg-primary { + background-color: var(--primary); + } + + .bg-tomato { + background-color: #e10c04; + }" + `) + }) + + test('`inline` and `reference` can be used together', () => { + expect( + compileCss( + css` + @theme reference inline { + --color-tomato: #e10c04; + --color-potato: #ac855b; + --color-primary: var(--primary); + } + + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato', 'bg-primary'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: #ac855b; + } + + .bg-primary { + background-color: var(--primary); + } + + .bg-tomato { + background-color: #e10c04; + }" + `) + }) + + test('`inline` and `reference` can be used together in `media(…)`', () => { + expect( + compileCss( + css` + @media theme(reference inline) { + @theme { + --color-tomato: #e10c04; + --color-potato: #ac855b; + --color-primary: var(--primary); + } + } + + @tailwind utilities; + `, + ['bg-tomato', 'bg-potato', 'bg-primary'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: #ac855b; + } + + .bg-primary { + background-color: var(--primary); + } + + .bg-tomato { + background-color: #e10c04; + }" + `) + }) }) describe('plugins', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index f324dfad8..73b040d4e 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -33,6 +33,21 @@ function throwOnPlugin(): never { throw new Error('No `loadPlugin` function provided to `compile`') } +function parseThemeOptions(selector: string) { + let isReference = false + let isInline = false + + for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) { + if (option === 'reference') { + isReference = true + } else if (option === 'inline') { + isInline = true + } + } + + return { isReference, isInline } +} + export function compile( css: string, { loadPlugin = throwOnPlugin }: CompileOptions = {}, @@ -152,28 +167,32 @@ export function compile( } } - // Drop instances of `@media reference` + // Drop instances of `@media theme(…)` // - // We support `@import "tailwindcss/theme" reference` as a way to import an external theme file - // as a reference, which becomes `@media reference { … }` when the `@import` is processed. - if (node.selector === '@media reference') { + // We support `@import "tailwindcss/theme" theme(reference)` as a way to + // import an external theme file as a reference, which becomes `@media + // theme(reference) { … }` when the `@import` is processed. + if (node.selector.startsWith('@media theme(')) { + let themeParams = node.selector.slice(13, -1) + walk(node.nodes, (child) => { if (child.kind !== 'rule') { throw new Error( - 'Files imported with `@import "…" reference` must only contain `@theme` blocks.', + 'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.', ) } if (child.selector === '@theme') { - child.selector = '@theme reference' + child.selector = '@theme ' + themeParams return WalkAction.Skip } }) replaceWith(node.nodes) + return WalkAction.Skip } - if (node.selector !== '@theme' && node.selector !== '@theme reference') return + if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return - let isReference = node.selector === '@theme reference' + let { isReference, isInline } = parseThemeOptions(node.selector) // Record all custom properties in the `@theme` declaration walk(node.nodes, (child, { replaceWith }) => { @@ -187,7 +206,7 @@ export function compile( if (child.kind === 'comment') return if (child.kind === 'declaration' && child.property.startsWith('--')) { - theme.add(child.property, child.value, isReference) + theme.add(child.property, child.value, { isReference, isInline }) return } @@ -395,15 +414,15 @@ export function __unstable__loadDesignSystem(css: string) { walk(ast, (node) => { if (node.kind !== 'rule') return - if (node.selector !== '@theme' && node.selector !== '@theme reference') return - let isReference = node.selector === '@theme reference' + if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return + let { isReference, isInline } = parseThemeOptions(node.selector) // Record all custom properties in the `@theme` declaration walk([node], (node) => { if (node.kind !== 'declaration') return if (!node.property.startsWith('--')) return - theme.add(node.property, node.value, isReference) + theme.add(node.property, node.value, { isReference, isInline }) }) }) diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 8ac6a90cc..7d150c350 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -1,9 +1,11 @@ import { escape } from './utils/escape' export class Theme { - constructor(private values = new Map()) {} + constructor( + private values = new Map(), + ) {} - add(key: string, value: string, isReference = false): void { + add(key: string, value: string, { isReference = false, isInline = false } = {}): void { if (key.endsWith('-*')) { if (value !== 'initial') { throw new Error(`Invalid theme value \`${value}\` for namespace \`${key}\``) @@ -18,7 +20,7 @@ export class Theme { if (value === 'initial') { this.values.delete(key) } else { - this.values.set(key, { value, isReference }) + this.values.set(key, { value, isReference, isInline }) } } @@ -91,6 +93,12 @@ export class Theme { if (!themeKey) return null + let value = this.values.get(themeKey)! + + if (value.isInline) { + return value.value + } + return this.#var(themeKey) }