diff --git a/CHANGELOG.md b/CHANGELOG.md index 87256839c2be..209d999c79cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index 72278a60ba1a..9e8849513b6a 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -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` diff --git a/packages/tailwindcss/src/compat/config/deep-merge.ts b/packages/tailwindcss/src/compat/config/deep-merge.ts index 49ac100b5392..3bbae51ddcef 100644 --- a/packages/tailwindcss/src/compat/config/deep-merge.ts +++ b/packages/tailwindcss/src/compat/config/deep-merge.ts @@ -10,7 +10,8 @@ export function isPlainObject(value: T): value is T & Record( target: T, sources: (Partial | 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( } 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 } } } diff --git a/packages/tailwindcss/src/compat/flatten-color-palette.ts b/packages/tailwindcss/src/compat/flatten-color-palette.ts new file mode 100644 index 000000000000..198ae060d7c5 --- /dev/null +++ b/packages/tailwindcss/src/compat/flatten-color-palette.ts @@ -0,0 +1,19 @@ +type Colors = { + [key: string | number]: string | Colors +} + +export function flattenColorPalette(colors: Colors) { + let result: Record = {} + + 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 +} diff --git a/packages/tailwindcss/src/compat/plugin-functions.ts b/packages/tailwindcss/src/compat/plugin-functions.ts index 810953aab29d..4523fb8dc473 100644 --- a/packages/tailwindcss/src/compat/plugin-functions.ts +++ b/packages/tailwindcss/src/compat/plugin-functions.ts @@ -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) + 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 } - let configValue = resolveValue(get(configTheme() ?? {}, keypath) ?? null) - + // if (configValue !== null && typeof configValue === 'object' && !Array.isArray(configValue)) { - return deepMerge({}, [configValue, cssValue], (_, b) => b) + let configValueCopy: Record & { __CSS_VALUES__?: Record } = + // 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 = {} + 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, options: number] + | [value: Record, options: Record] { // `--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() - let nested = new DefaultMap>(() => new Map()) + let nested = new DefaultMap>( + () => 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 = {} + let extraOptionsObj: Record = {} + + 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 = {} - let useNestedObjects = false // paths.some((path) => nestedKeys.has(path)) + let optionsObj: Record = {} for (let [key, value] of map) { - key = key ?? 'DEFAULT' - - 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, [key ?? 'DEFAULT'], value) + } - 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[]) { diff --git a/packages/tailwindcss/src/plugin-api.test.ts b/packages/tailwindcss/src/plugin-api.test.ts index e7ed5e8a681e..1063f1e90fe6 100644 --- a/packages/tailwindcss/src/plugin-api.test.ts +++ b/packages/tailwindcss/src/plugin-api.test.ts @@ -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', }) diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 7c2f5cb07930..b545462efb76 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -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() {