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 diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 7b539c4e4050..a058f04b9eb5 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -965,6 +965,89 @@ 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"', + + // Quotes are optional in CSS for these kinds of attribute + // selectors + bar: 'bar=true', + + // Not automatically handled by bare values because names differ + baz: 'qux="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-baz (&[aria-qux="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', 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..3d45362e18c4 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -97,13 +97,14 @@ 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 = { base, config: { ...unresolvedConfig, plugins: [], presets: undefined }, reference: false, + src: undefined, } let { resolvedConfig, replacedThemeKeys } = resolveConfig(designSystem, [configToResolve]) @@ -113,10 +114,47 @@ 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 = '' + let variants = new Map() + + // 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 + } + + 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\\1$`).test(`${value}`)) continue + + // Create custom variant + variants.set(`aria-${key}`, `&[aria-${value}]`) + } + delete resolvedConfig.theme.aria + } + } + + // Convert theme values to CSS custom properties for (let [key, value] of themeableValues(resolvedConfig.theme)) { if (typeof value !== 'string' && typeof value !== 'number') { continue @@ -151,14 +189,9 @@ async function migrateTheme( } } - if (key[0] === 'keyframes') { - continue - } - containsThemeKeys = true - let sectionKey = createSectionKey(key) if (sectionKey !== prevSectionKey) { - css += `\n` + themeSection.push('') prevSectionKey = sectionKey } @@ -166,36 +199,36 @@ 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)]) + 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' } - css += '}\n' // @tw-bucket - return css } @@ -356,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)) {