Allow configs to override default CSS theme values in theme() function provided to plugins and configs (#14359)

Previously, given the following CSS and configuration:

```css
/* app.css */

@theme default {
  --font-size-base: 1.25rem;
  --font-size-base--line-height: 1.5rem;
}
@tailwind utilities;
@config "./config.js";
```

```js
// config.js

export default {
  theme: {
    fontSize: {
      // …
      base: ['1rem', { lineHeight: '1.75rem' }],
    },

    // …
  },
};
```

When a config or a plugin asked for the value of `theme(fontSize.base)`
like so:

```js
// config.js
export default {
  theme: {
    // …

    typography: ({ theme }) => ({
      css: {
        '[class~="lead"]': {
          fontSize: theme('fontSize.base')[0],
          ...theme('fontSize.base')[1],
        },
      }
    }),
  },
};
```

We would instead pull the values from the CSS theme even through they're
marked with `@theme default`. This would cause the incorrect font size
and line height to be used resulting in something like this (in the case
of the typography plugin with custom styles):

  ```css
  .prose [class~="lead"] {
    font-size: 1.25rem;
    line-height: 1.5rem;
  }
  ```

After this change we'll now pull the values from the appropriate place
(the config in this case) and the correct font size and line height will
be used:

  ```css
  .prose [class~="lead"] {
    font-size: 1rem;
    line-height: 1.75rem;
  }
  ```

This will work even when some values are overridden in the CSS theme:

  ```css
  /* app.css */
  @theme default {
    --font-size-base: 1.25rem;
    --font-size-base--line-height: 1.5rem;
  }
  @theme {
    --font-size-base: 2rem;
  }
  @tailwind utilities;
  @config "./config.js";
  ```

which would result in the following CSS:

  ```css
  .prose [class~="lead"] {
    font-size: 2rem;
    line-height: 1.75rem;
  }
  ```

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Jordan Pittman 2024-09-06 16:05:26 -04:00 • committed by GitHub
parent ee35a1d047
commit b1e22e121e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 409 additions and 34 deletions

View file

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure there is always CLI feedback on save even when no new classes were found ([#14351](https://github.com/tailwindlabs/tailwindcss/pull/14351))
- Properly resolve `theme('someKey.DEFAULT')` when all `--some-key-*` keys have a suffix ([#14354](https://github.com/tailwindlabs/tailwindcss/pull/14354))
- Make sure tuple theme values in JS configs take precedence over `@theme default` values ([#14359](https://github.com/tailwindlabs/tailwindcss/pull/14359))
## [4.0.0-alpha.23] - 2024-09-05

View file

@ -1,6 +1,7 @@
import { describe, test } from 'vitest'
import { compile } from '..'
import plugin from '../plugin'
import { flattenColorPalette } from './flatten-color-palette'
const css = String.raw
@ -230,6 +231,272 @@ test('Variants in CSS overwrite variants from plugins', async ({ expect }) => {
`)
})
describe('theme callbacks', () => {
test('tuple values from the config overwrite `@theme default` tuple-ish values from the CSS theme', async ({
expect,
}) => {
let input = css`
@theme default {
--font-size-base: 0rem;
--font-size-base--line-height: 1rem;
--font-size-md: 0rem;
--font-size-md--line-height: 1rem;
--font-size-xl: 0rem;
--font-size-xl--line-height: 1rem;
}
@theme {
--font-size-base: 100rem;
--font-size-md--line-height: 101rem;
}
@tailwind utilities;
@config "./config.js";
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
fontSize: {
base: ['200rem', { lineHeight: '201rem' }],
md: ['200rem', { lineHeight: '201rem' }],
xl: ['200rem', { lineHeight: '201rem' }],
},
// Direct access
lineHeight: ({ theme }) => ({
base: theme('fontSize.base[1].lineHeight'),
md: theme('fontSize.md[1].lineHeight'),
xl: theme('fontSize.xl[1].lineHeight'),
}),
// Tuple access
typography: ({ theme }) => ({
'[class~=lead-base]': {
fontSize: theme('fontSize.base')[0],
...theme('fontSize.base')[1],
},
'[class~=lead-md]': {
fontSize: theme('fontSize.md')[0],
...theme('fontSize.md')[1],
},
'[class~=lead-xl]': {
fontSize: theme('fontSize.xl')[0],
...theme('fontSize.xl')[1],
},
}),
},
},
plugins: [
plugin(function ({ addUtilities, theme }) {
addUtilities({
'.prose': {
...theme('typography'),
},
})
}),
],
}),
})
expect(compiler.build(['leading-base', 'leading-md', 'leading-xl', 'prose']))
.toMatchInlineSnapshot(`
":root {
--font-size-base: 100rem;
--font-size-md--line-height: 101rem;
}
.prose {
[class~=lead-base] {
font-size: 100rem;
line-height: 201rem;
}
[class~=lead-md] {
font-size: 200rem;
line-height: 101rem;
}
[class~=lead-xl] {
font-size: 200rem;
line-height: 201rem;
}
}
.leading-base {
line-height: 201rem;
}
.leading-md {
line-height: 101rem;
}
.leading-xl {
line-height: 201rem;
}
"
`)
})
})
describe('theme overrides order', () => {
test('user theme > js config > default theme', async ({ expect }) => {
let input = css`
@theme default {
--color-red: red;
}
@theme {
--color-blue: blue;
}
@tailwind utilities;
@config "./config.js";
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
colors: {
red: 'very-red',
blue: 'very-blue',
},
},
},
}),
})
expect(compiler.build(['bg-red', 'bg-blue'])).toMatchInlineSnapshot(`
":root {
--color-blue: blue;
}
.bg-blue {
background-color: var(--color-blue, blue);
}
.bg-red {
background-color: very-red;
}
"
`)
})
test('user theme > js config > default theme (with nested object)', async ({ expect }) => {
let input = css`
@theme default {
--color-slate-100: #000100;
--color-slate-200: #000200;
--color-slate-300: #000300;
}
@theme {
--color-slate-400: #100400;
--color-slate-500: #100500;
}
@tailwind utilities;
@config "./config.js";
@plugin "./plugin.js";
`
let compiler = await compile(input, {
loadConfig: async () => ({
theme: {
extend: {
colors: {
slate: {
200: '#200200',
400: '#200400',
600: '#200600',
},
},
},
},
}),
loadPlugin: async () => {
return plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'hover-bg': (value) => {
return {
'&:hover': {
backgroundColor: value,
},
}
},
},
{ values: flattenColorPalette(theme('colors')) },
)
})
},
})
expect(
compiler.build([
'bg-slate-100',
'bg-slate-200',
'bg-slate-300',
'bg-slate-400',
'bg-slate-500',
'bg-slate-600',
'hover-bg-slate-100',
'hover-bg-slate-200',
'hover-bg-slate-300',
'hover-bg-slate-400',
'hover-bg-slate-500',
'hover-bg-slate-600',
]),
).toMatchInlineSnapshot(`
":root {
--color-slate-100: #000100;
--color-slate-300: #000300;
--color-slate-400: #100400;
--color-slate-500: #100500;
}
.bg-slate-100 {
background-color: var(--color-slate-100, #000100);
}
.bg-slate-200 {
background-color: #200200;
}
.bg-slate-300 {
background-color: var(--color-slate-300, #000300);
}
.bg-slate-400 {
background-color: var(--color-slate-400, #100400);
}
.bg-slate-500 {
background-color: var(--color-slate-500, #100500);
}
.bg-slate-600 {
background-color: #200600;
}
.hover-bg-slate-100 {
&:hover {
background-color: #000100;
}
}
.hover-bg-slate-200 {
&:hover {
background-color: #200200;
}
}
.hover-bg-slate-300 {
&:hover {
background-color: #000300;
}
}
.hover-bg-slate-400 {
&:hover {
background-color: #100400;
}
}
.hover-bg-slate-500 {
&:hover {
background-color: #100500;
}
}
.hover-bg-slate-600 {
&:hover {
background-color: #200600;
}
}
"
`)
})
})
describe('default font family compatibility', () => {
test('overriding `fontFamily.sans` sets `--default-font-family`', async ({ expect }) => {
let input = css`

View file

@ -10,7 +10,8 @@ export function isPlainObject<T>(value: T): value is T & Record<keyof T, unknown
export function deepMerge<T extends object>(
target: T,
sources: (Partial<T> | null | undefined)[],
customizer: (a: any, b: any) => any,
customizer: (a: any, b: any, keypath: (keyof T)[]) => any,
parentPath: (keyof T)[] = [],
) {
type Key = keyof T
type Value = T[Key]
@ -21,14 +22,20 @@ export function deepMerge<T extends object>(
}
for (let k of Reflect.ownKeys(source) as Key[]) {
let merged = customizer(target[k], source[k])
let currentParentPath = [...parentPath, k]
let merged = customizer(target[k], source[k], currentParentPath)
if (merged !== undefined) {
target[k] = merged
} else if (!isPlainObject(target[k]) || !isPlainObject(source[k])) {
target[k] = source[k] as Value
} else {
target[k] = deepMerge({}, [target[k], source[k]], customizer) as Value
target[k] = deepMerge(
{},
[target[k], source[k]],
customizer,
currentParentPath as any,
) as Value
}
}
}

View file

@ -0,0 +1,19 @@
type Colors = {
[key: string | number]: string | Colors
}
export function flattenColorPalette(colors: Colors) {
let result: Record<string, string> = {}
for (let [root, children] of Object.entries(colors ?? {})) {
if (typeof children === 'object' && children !== null) {
for (let [parent, value] of Object.entries(flattenColorPalette(children))) {
result[`${root}${parent === 'DEFAULT' ? '' : `-${parent}`}`] = value
}
} else {
result[root] = children
}
}
return result
}

View file

@ -1,5 +1,5 @@
import type { DesignSystem } from '../design-system'
import type { Theme, ThemeKey } from '../theme'
import { ThemeOptions, type Theme, type ThemeKey } from '../theme'
import { withAlpha } from '../utilities'
import { DefaultMap } from '../utils/default-map'
import { toKeyPath } from '../utils/to-key-path'
@ -24,16 +24,75 @@ export function createThemeFn(
let resolvedValue = (() => {
let keypath = toKeyPath(path)
let cssValue = readFromCss(designSystem.theme, keypath)
if (typeof cssValue !== 'object') {
return cssValue
}
let [cssValue, options] = readFromCss(designSystem.theme, keypath)
let configValue = resolveValue(get(configTheme() ?? {}, keypath) ?? null)
// Resolved to a primitive value.
if (typeof cssValue !== 'object') {
if (typeof options !== 'object' && options & ThemeOptions.DEFAULT) {
return configValue ?? cssValue
}
return cssValue
}
//
if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) {
return deepMerge({}, [configValue, cssValue], (_, b) => b)
let configValueCopy: Record<string, unknown> & { __CSS_VALUES__?: Record<string, number> } =
// We want to make sure that we don't mutate the original config
// value. Ideally we use `structuredClone` here, but it's not possible
// because it can contain functions.
deepMerge({}, [configValue], (_, b) => b)
// There is no `cssValue`, which means we can back-fill it with values
// from the `configValue`.
if (cssValue === null && Object.hasOwn(configValue, '__CSS_VALUES__')) {
let localCssValue: Record<string, unknown> = {}
for (let key in configValue.__CSS_VALUES__) {
localCssValue[key] = configValue[key]
delete configValueCopy[key]
}
cssValue = localCssValue
}
for (let key in cssValue) {
if (key === '__CSS_VALUES__') continue
// If the value is coming from a default source (`@theme default`),
// then we keep the value from the JS config (which is also a
// default source, but wins over the built-in defaults).
if (
configValue?.__CSS_VALUES__?.[key] & ThemeOptions.DEFAULT &&
get(configValueCopy, key.split('-')) !== undefined
) {
continue
}
// CSS values from `@theme` win over values from the config
configValueCopy[key] = cssValue[key]
}
return configValueCopy
}
// Handle the tuple case
if (Array.isArray(cssValue) && Array.isArray(options) && Array.isArray(configValue)) {
let base = cssValue[0]
let extra = cssValue[1]
// Values from the config overwrite any default values from the CSS theme
if (options[0] & ThemeOptions.DEFAULT) {
base = configValue[0] ?? base
}
for (let key of Object.keys(extra)) {
if (options[1][key] & ThemeOptions.DEFAULT) {
extra[key] = configValue[1][key] ?? extra[key]
}
}
return [base, extra]
}
// Values from CSS take precedence over values from the config
@ -49,11 +108,16 @@ export function createThemeFn(
}
}
function readFromCss(theme: Theme, path: string[]) {
function readFromCss(
theme: Theme,
path: string[],
):
| [value: string | null | Record<string, unknown>, options: number]
| [value: Record<string, unknown>, options: Record<string, number>] {
// `--color-red-500` should resolve to the theme variable directly, no look up
// and handling of nested objects is required.
if (path.length === 1 && path[0].startsWith('--')) {
return theme.get([path[0] as ThemeKey])
return [theme.get([path[0] as ThemeKey]), theme.getOptions(path[0])] as const
}
type ThemeValue =
@ -80,18 +144,22 @@ function readFromCss(theme: Theme, path: string[]) {
.join('-')
let map = new Map<string | null, ThemeValue>()
let nested = new DefaultMap<string | null, Map<string, string>>(() => new Map())
let nested = new DefaultMap<string | null, Map<string, [value: string, options: number]>>(
() => new Map(),
)
let ns = theme.namespace(`--${themeKey}` as any)
if (ns.size === 0) {
return null
return [null, ThemeOptions.NONE]
}
let options = new Map()
for (let [key, value] of ns) {
// Non-nested values can be set directly
if (!key || !key.includes('--')) {
map.set(key, value)
options.set(key, theme.getOptions(!key ? `--${themeKey}` : `--${themeKey}-${key}`))
continue
}
@ -104,34 +172,38 @@ function readFromCss(theme: Theme, path: string[]) {
// Make `nestedKey` camel case:
nestedKey = nestedKey.replace(/-([a-z])/g, (_, a) => a.toUpperCase())
nested.get(mainKey === '' ? null : mainKey).set(nestedKey, value)
nested
.get(mainKey === '' ? null : mainKey)
.set(nestedKey, [value, theme.getOptions(`--${themeKey}${key}`)])
}
let baseOptions = theme.getOptions(`--${themeKey}`)
for (let [key, extra] of nested) {
let value = map.get(key)
if (typeof value !== 'string') continue
map.set(key, [value, Object.fromEntries(extra)])
let extraObj: Record<string, string> = {}
let extraOptionsObj: Record<string, number> = {}
for (let [nestedKey, [nestedValue, nestedOptions]] of extra) {
extraObj[nestedKey] = nestedValue
extraOptionsObj[nestedKey] = nestedOptions
}
map.set(key, [value, extraObj])
options.set(key, [baseOptions, extraOptionsObj])
}
// We have to turn the map into object-like structure for v3 compatibility
let obj: Record<string, unknown> = {}
let useNestedObjects = false // paths.some((path) => nestedKeys.has(path))
let optionsObj: Record<string, number> = {}
for (let [key, value] of map) {
key = key ?? 'DEFAULT'
set(obj, [key ?? 'DEFAULT'], value)
}
let path: string[] = []
let splitIndex = key.indexOf('-')
if (useNestedObjects && splitIndex !== -1) {
path.push(key.slice(0, splitIndex))
path.push(key.slice(splitIndex + 1))
} else {
path.push(key)
}
set(obj, path, value)
for (let [key, value] of options) {
set(optionsObj, [key ?? 'DEFAULT'], value)
}
// If the request looked like `theme('animation.DEFAULT')` it would have been
@ -139,17 +211,21 @@ function readFromCss(theme: Theme, path: string[]) {
// the `DEFAULT` key from the list of possible values. If there is no
// `DEFAULT` in the list, there is no match so return `null`.
if (path[path.length - 1] === 'DEFAULT') {
return obj?.DEFAULT ?? null
return [(obj?.DEFAULT ?? null) as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const
}
// The request looked like `theme('animation.spin')` and was turned into a
// lookup for `--animation-spin-*` which had only one entry which means it
// should be returned directly.
if ('DEFAULT' in obj && Object.keys(obj).length === 1) {
return obj.DEFAULT
return [obj.DEFAULT as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const
}
return obj
// Attach the CSS values to the object for later use. This object could be
// mutated by the user so we want to keep the original CSS values around.
obj.__CSS_VALUES__ = optionsObj
return [obj, optionsObj] as const
}
function get(obj: any, path: string[]) {

View file

@ -524,6 +524,7 @@ describe('theme', async () => {
})
expect(fn).toHaveBeenCalledWith({
__CSS_VALUES__: { bounce: 2, spin: 2 },
spin: 'spin 1s linear infinite',
bounce: 'bounce 1s linear infinite',
})

View file

@ -66,7 +66,11 @@ export class Theme {
}
hasDefault(key: string): boolean {
return ((this.values.get(key)?.options ?? 0) & ThemeOptions.DEFAULT) === ThemeOptions.DEFAULT
return (this.getOptions(key) & ThemeOptions.DEFAULT) === ThemeOptions.DEFAULT
}
getOptions(key: string) {
return this.values.get(key)?.options ?? ThemeOptions.NONE
}
entries() {