diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b35a7d755e..6f0181e87ea0 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 ### Fixed - Substitute `@variant` inside legacy JS APIs ([#19263](https://github.com/tailwindlabs/tailwindcss/pull/19263)) +- Handle utilities with `1` keys in a JS config ([#19254](https://github.com/tailwindlabs/tailwindcss/pull/19254)) ### Added diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts index 83fc2f2c94e9..3fe171dfcd95 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'vitest' import { buildDesignSystem } from '../design-system' import { Theme, ThemeOptions } from '../theme' -import { applyConfigToTheme, keyPathToCssProperty } from './apply-config-to-theme' +import { + applyConfigToTheme, + keyPathToCssProperty, + keyPathsToCssProperty, +} from './apply-config-to-theme' import { resolveConfig } from './config/resolve-config' test('config values can be merged into the theme', () => { @@ -213,6 +217,48 @@ describe('keyPathToCssProperty', () => { }) }) +describe('keyPathsToCssProperty', () => { + test.each([ + // No "1" entries - should return single result + [['width', '40', '2/5'], ['width-40-2/5']], + [['spacing', '0.5'], ['spacing-0_5']], + + // Single "1" entry before the end - should return two variants + [ + ['fontSize', 'xs', '1', 'lineHeight'], + ['text-xs--line-height', 'text-xs-1-line-height'], + ], + + // Multiple "1" entries before the end - should only split the last "1" + [ + ['test', '1', 'middle', '1', 'end'], + ['test-1-middle--end', 'test-1-middle-1-end'], + ], + + // A "1" at the end means everything should be kept as-is + [['spacing', '1'], ['spacing-1']], + + // Even when there are other 1s in the path + [['test', '1', 'middle', '1'], ['test-1-middle-1']], + + [['colors', 'a', '1', 'DEFAULT'], ['color-a-1']], + [ + ['colors', 'a', '1', 'foo'], + ['color-a--foo', 'color-a-1-foo'], + ], + ])('converts %s to %s', (keyPath, expected) => { + expect(keyPathsToCssProperty(keyPath)).toEqual(expected) + }) + + test('returns empty array for container path', () => { + expect(keyPathsToCssProperty(['container', 'sm'])).toEqual([]) + }) + + test('returns empty array for invalid keys', () => { + expect(keyPathsToCssProperty(['test', 'invalid@key'])).toEqual([]) + }) +}) + test('converts opacity modifiers from decimal to percentage values', () => { let theme = new Theme() let design = buildDesignSystem(theme) diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index a430f9faa332..60f42c3bd012 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -25,10 +25,9 @@ export function applyConfigToTheme( replacedThemeKeys: Set, ) { for (let replacedThemeKey of replacedThemeKeys) { - let name = keyPathToCssProperty([replacedThemeKey]) - if (!name) continue - - designSystem.theme.clearNamespace(`--${name}`, ThemeOptions.DEFAULT) + for (let name of keyPathsToCssProperty([replacedThemeKey])) { + designSystem.theme.clearNamespace(`--${name}`, ThemeOptions.DEFAULT) + } } for (let [path, value] of themeableValues(theme)) { @@ -50,14 +49,13 @@ export function applyConfigToTheme( } } - let name = keyPathToCssProperty(path) - if (!name) continue - - designSystem.theme.add( - `--${name}`, - '' + value, - ThemeOptions.INLINE | ThemeOptions.REFERENCE | ThemeOptions.DEFAULT, - ) + for (let name of keyPathsToCssProperty(path)) { + designSystem.theme.add( + `--${name}`, + '' + value, + ThemeOptions.INLINE | ThemeOptions.REFERENCE | ThemeOptions.DEFAULT, + ) + } } // If someone has updated `fontFamily.sans` or `fontFamily.mono` in a JS @@ -150,8 +148,12 @@ export function themeableValues(config: ResolvedConfig['theme']): [string[], unk const IS_VALID_KEY = /^[a-zA-Z0-9-_%/\.]+$/ export function keyPathToCssProperty(path: string[]) { + return keyPathsToCssProperty(path)[0] ?? null +} + +export function keyPathsToCssProperty(path: string[]): string[] { // The legacy container component config should not be included in the Theme - if (path[0] === 'container') return null + if (path[0] === 'container') return [] path = path.slice() @@ -170,32 +172,43 @@ export function keyPathToCssProperty(path: string[]) { if (path[0] === 'transitionTimingFunction') path[0] = 'ease' for (let part of path) { - if (!IS_VALID_KEY.test(part)) return null + if (!IS_VALID_KEY.test(part)) return [] } - return ( - path - // [1] should move into the nested object tuple. To create the CSS variable - // name for this, we replace it with an empty string that will result in two - // subsequent dashes when joined. - // - // E.g.: - // - `fontSize.xs.1.lineHeight` -> `font-size-xs--line-height` - // - `spacing.1` -> `--spacing-1` - .map((path, idx, all) => (path === '1' && idx !== all.length - 1 ? '' : path)) - - // Resolve the key path to a CSS variable segment + // Find the position of the last `1` as long as it's not at the end + let lastOnePosition = path.lastIndexOf('1') + if (lastOnePosition === path.length - 1) lastOnePosition = -1 + + // Generate two combinations based on tuple access: + let paths: string[][] = [] + + // Option 1: Replace the last "1" with empty string if it exists + // + // We place this first as we "prefer" treating this as a tuple access. The exception to this is if + // the keypath ends in `DEFAULT` otherwise we'd see a key that ends in a dash like `--color-a-` + if (lastOnePosition !== -1 && path.at(-1) !== 'DEFAULT') { + let modified = path.slice() + modified[lastOnePosition] = '' + paths.push(modified) + } + + // Option 2: The path as is + paths.push(path) + + return paths.map((path) => { + // Remove the `DEFAULT` key at the end of a path + // We're reading from CSS anyway so it'll be a string + if (path.at(-1) === 'DEFAULT') path = path.slice(0, -1) + + // Resolve the key path to a CSS variable segment + return path .map((part) => part .replaceAll('.', '_') .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`), ) - - // Remove the `DEFAULT` key at the end of a path - // We're reading from CSS anyway so it'll be a string - .filter((part, index) => part !== 'DEFAULT' || index !== path.length - 1) .join('-') - ) + }) } function isValidThemePrimitive(value: unknown) { diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index d86d5304b3e7..94cfd1ad6734 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -297,6 +297,74 @@ test('Variants in CSS overwrite variants from plugins', async () => { `) }) +test('A `1` key is treated like a nested theme key *and* a normal theme key', async () => { + let input = css` + @tailwind utilities; + @config "./config.js"; + ` + + let compiler = await compile(input, { + loadModule: async () => ({ + module: { + theme: { + fontSize: { + xs: ['0.5rem', { lineHeight: '0.25rem' }], + }, + colors: { + a: { + 1: { DEFAULT: '#ffffff', hovered: '#000000' }, + 2: { DEFAULT: '#000000', hovered: '#c0ffee' }, + }, + b: { + 1: '#ffffff', + 2: '#000000', + }, + }, + }, + }, + base: '/root', + path: '', + }), + }) + + expect( + compiler.build([ + 'text-xs', + + 'text-a-1', + 'text-a-2', + 'text-a-1-hovered', + 'text-a-2-hovered', + 'text-b-1', + 'text-b-2', + ]), + ).toMatchInlineSnapshot(` + ".text-xs { + font-size: 0.5rem; + line-height: var(--tw-leading, 0.25rem); + } + .text-a-1 { + color: #ffffff; + } + .text-a-1-hovered { + color: #000000; + } + .text-a-2 { + color: #000000; + } + .text-a-2-hovered { + color: #c0ffee; + } + .text-b-1 { + color: #ffffff; + } + .text-b-2 { + color: #000000; + } + " + `) +}) + describe('theme callbacks', () => { test('tuple values from the config overwrite `@theme default` tuple-ish values from the CSS theme', async ({ expect,