Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 47 additions & 1 deletion packages/tailwindcss/src/compat/apply-config-to-theme.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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)
Expand Down
75 changes: 44 additions & 31 deletions packages/tailwindcss/src/compat/apply-config-to-theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ export function applyConfigToTheme(
replacedThemeKeys: Set<string>,
) {
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)) {
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand All @@ -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) {
Expand Down
68 changes: 68 additions & 0 deletions packages/tailwindcss/src/compat/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down