From 4d8ca12df0b28a938363664e4374946fd4db0f3f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 11:21:11 +0200 Subject: [PATCH 1/6] make TypeScript happy --- .../src/codemods/config/migrate-js-config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts index 2ccddeb74742..b86adb221b42 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -104,6 +104,7 @@ async function migrateTheme( base, config: { ...unresolvedConfig, plugins: [], presets: undefined }, reference: false, + src: undefined, } let { resolvedConfig, replacedThemeKeys } = resolveConfig(designSystem, [configToResolve]) From 79cae4b8c68c5536732d154f60a44ded3dc8e398 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 12:04:49 +0200 Subject: [PATCH 2/6] refactor: when migrating theme key, handle special cases first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were handling special cases (keyframes, container) _after_ we handled the normal theme keys. When handling the normal theme keys we skipped the special keys which means that you have to keep the list in sync. Now we will handle special cases first. Another refactor here is that we will push data to an intermediate variable before emitting the `@theme {…}` wrapper. This will allow us to only emit CSS when needed. We were explicitly returning `null` in some cases, but we can also just return the empty string when there is no CSS to get the same effect. --- .../src/codemods/config/migrate-js-config.ts | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts index b86adb221b42..0e9841b4cda0 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -97,7 +97,7 @@ async function migrateTheme( designSystem: DesignSystem, unresolvedConfig: Config, base: string, -): Promise { +): Promise { // Resolve the config file without applying plugins and presets, as these are // migrated to CSS separately. let configToResolve: ConfigFile = { @@ -114,10 +114,34 @@ async function migrateTheme( removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys) + let css = '' let prevSectionKey = '' - let css = '\n@tw-bucket theme {\n' - css += `\n@theme {\n` - let containsThemeKeys = false + let themeSection: string[] = [] + let keyframesCss = '' + + // Special handling of specific theme keys: + { + if ('keyframes' in resolvedConfig.theme) { + keyframesCss += keyframesToCss(resolvedConfig.theme.keyframes) + delete resolvedConfig.theme.keyframes + } + + if ('container' in resolvedConfig.theme) { + let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem) + if (rules.length > 0) { + // Using `theme` instead of `utility` so it sits before the `@layer + // base` with compatibility CSS. While this is technically a utility, it + // makes a bit more sense to emit this closer to the `@theme` values + // since it is needed for backwards compatibility. + css += `\n@tw-bucket theme {\n` + css += toCss([atRule('@utility', 'container', rules)]) + css += '}\n' // @tw-bucket + } + delete resolvedConfig.theme.container + } + } + + // Convert theme values to CSS custom properties for (let [key, value] of themeableValues(resolvedConfig.theme)) { if (typeof value !== 'string' && typeof value !== 'number') { continue @@ -152,14 +176,9 @@ async function migrateTheme( } } - if (key[0] === 'keyframes') { - continue - } - containsThemeKeys = true - let sectionKey = createSectionKey(key) if (sectionKey !== prevSectionKey) { - css += `\n` + themeSection.push('') prevSectionKey = sectionKey } @@ -167,36 +186,28 @@ async function migrateTheme( resetNamespaces.set(key[0], true) let property = keyPathToCssProperty([key[0]]) if (property !== null) { - css += ` ${escape(`--${property}`)}-*: initial;\n` + themeSection.push(` ${escape(`--${property}`)}-*: initial;`) } } let property = keyPathToCssProperty(key) if (property !== null) { - css += ` ${escape(`--${property}`)}: ${value};\n` + themeSection.push(` ${escape(`--${property}`)}: ${value};`) } } - if ('keyframes' in resolvedConfig.theme) { - containsThemeKeys = true - css += '\n' + keyframesToCss(resolvedConfig.theme.keyframes) + if (keyframesCss) { + themeSection.push('', keyframesCss) } - if (!containsThemeKeys) { - return null + if (themeSection.length > 0) { + css += `\n@tw-bucket theme {\n` + css += `\n@theme {\n` + css += themeSection.join('\n') + '\n' + css += '}\n' // @theme + css += '}\n' // @tw-bucket } - css += '}\n' // @theme - - if ('container' in resolvedConfig.theme) { - let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem) - if (rules.length > 0) { - css += '\n' + toCss([atRule('@utility', 'container', rules)]) - } - } - - css += '}\n' // @tw-bucket - return css } From bfa608463d28d252ba6afd7e38f7c50f17ac2e79 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 12:16:36 +0200 Subject: [PATCH 3/6] add failing test --- integrations/upgrade/js-config.test.ts | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 7b539c4e4050..17cb982f6c2d 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -965,6 +965,85 @@ test( }, ) +test( + 'migrate aria theme keys to custom variants', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + export default { + content: { + relative: true, + files: ['./src/**/*.html'], + }, + theme: { + extend: { + aria: { + // Built-in (not really, but visible because of intellisense) + busy: 'busy="true"', + + // Automatically handled by bare values + foo: 'foo="true"', + + // Not automatically handled by bare values because names differ + bar: 'baz="true"', + + // Completely custom + asc: 'sort="ascending"', + desc: 'sort="descending"', + }, + }, + }, + } + `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + }, + }, + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') + + expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` + " + --- src/input.css --- + @import 'tailwindcss'; + + @custom-variant aria-bar (&[aria-baz="true"]); + @custom-variant aria-asc (&[aria-sort="ascending"]); + @custom-variant aria-desc (&[aria-sort="descending"]); + + /* + The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. + */ + @layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + } + " + `) + }, +) + describe('border compatibility', () => { test( 'migrate border compatibility', From 139e1cc63f7ce713981aa677b27964d8684286f3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 12:07:57 +0200 Subject: [PATCH 4/6] migrate `theme.aria` to custom `@custom-variant aria-*` variants But we will make sure that we skip the ones that would be handled automatically by bare values. E.g.: `aria-foo:flex` will generate `[aria-foo="true"]`. --- .../src/codemods/config/migrate-js-config.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts index 0e9841b4cda0..2bf440f0888f 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -118,6 +118,7 @@ async function migrateTheme( let prevSectionKey = '' let themeSection: string[] = [] let keyframesCss = '' + let variants = new Map() // Special handling of specific theme keys: { @@ -139,6 +140,18 @@ async function migrateTheme( } delete resolvedConfig.theme.container } + + if ('aria' in resolvedConfig.theme) { + for (let [key, value] of Object.entries(resolvedConfig.theme.aria ?? {})) { + // Will be handled by bare values if the names match. + // E.g.: `aria-foo:flex` should produce `[aria-foo="true"]` + if (new RegExp(`^${key}=['"]true['"]$`).test(`${value}`)) continue + + // Create custom variant + variants.set(`aria-${key}`, `&[aria-${value}]`) + } + delete resolvedConfig.theme.aria + } } // Convert theme values to CSS custom properties @@ -208,6 +221,14 @@ async function migrateTheme( css += '}\n' // @tw-bucket } + if (variants.size > 0) { + css += '\n@tw-bucket custom-variant {\n' + for (let [name, selector] of variants) { + css += `@custom-variant ${name} (${selector});\n` + } + css += '}\n' + } + return css } @@ -368,7 +389,7 @@ const ALLOWED_THEME_KEYS = [ // Used by @tailwindcss/container-queries 'containers', ] -const BLOCKED_THEME_KEYS = ['supports', 'data', 'aria'] +const BLOCKED_THEME_KEYS = ['supports', 'data'] function onlyAllowedThemeValues(theme: ThemeConfig): boolean { for (let key of Object.keys(theme)) { if (!ALLOWED_THEME_KEYS.includes(key)) { From 4fe4301c9cbb09f97091977652a3c395e6c16551 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 12:33:36 +0200 Subject: [PATCH 5/6] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19e60de1a94..245c0b707461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Discard matched variants with unknown named values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799)) - Discard matched variants with non-string values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799)) - Show suggestions for known `matchVariant` values ([#18798](https://github.com/tailwindlabs/tailwindcss/pull/18798)) +- Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815)) ## [4.1.12] - 2025-08-13 From ebfee41b5b714543afda06c5fdd4050b59eede93 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 15:56:56 +0200 Subject: [PATCH 6/6] quotes are technically optional, let's account for that Co-Authored-By: Jordan Pittman --- integrations/upgrade/js-config.test.ts | 8 ++++++-- .../src/codemods/config/migrate-js-config.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 17cb982f6c2d..a058f04b9eb5 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -992,8 +992,12 @@ test( // Automatically handled by bare values foo: 'foo="true"', + // Quotes are optional in CSS for these kinds of attribute + // selectors + bar: 'bar=true', + // Not automatically handled by bare values because names differ - bar: 'baz="true"', + baz: 'qux="true"', // Completely custom asc: 'sort="ascending"', @@ -1018,7 +1022,7 @@ test( --- src/input.css --- @import 'tailwindcss'; - @custom-variant aria-bar (&[aria-baz="true"]); + @custom-variant aria-baz (&[aria-qux="true"]); @custom-variant aria-asc (&[aria-sort="ascending"]); @custom-variant aria-desc (&[aria-sort="descending"]); diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts index 2bf440f0888f..3d45362e18c4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -145,7 +145,7 @@ async function migrateTheme( for (let [key, value] of Object.entries(resolvedConfig.theme.aria ?? {})) { // Will be handled by bare values if the names match. // E.g.: `aria-foo:flex` should produce `[aria-foo="true"]` - if (new RegExp(`^${key}=['"]true['"]$`).test(`${value}`)) continue + if (new RegExp(`^${key}=(['"]?)true\\1$`).test(`${value}`)) continue // Create custom variant variants.set(`aria-${key}`, `&[aria-${value}]`)