Skip to content

Commit 9e498a3

Browse files
Migrate aria theme keys (#18815)
This PR migrates `aria` theme keys when migrating from Tailwind CSS v3 to v4. While working on improving some of the error messages to get more insights into why migrating the JS file changed (#18808), I ran into an issue where I couldn't think of a good comment to why `aria` theme keys were not being migrated. (Internally we have `aria` "blocked"). So instead of figuring out a good error message..., I just went ahead and added the migration for `aria` theme keys. Let's imagine you have the following Tailwind CSS v3 configuration: ```ts export default { content: ['./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"', // ^^^ ^^^ ← same names // Not automatically handled by bare values because names differ bar: 'baz="true"', // ^^^ ^^^ ← different names // Completely custom asc: 'sort="ascending"', desc: 'sort="descending"', }, }, }, } ``` Then we would generate the following Tailwind CSS v4 CSS: ```css @custom-variant aria-bar (&[aria-baz="true"]); @custom-variant aria-asc (&[aria-sort="ascending"]); @custom-variant aria-desc (&[aria-sort="descending"]); ``` Notice how we didn't generate a custom variant for `aria-busy` or `aria-foo` because those are automatically handled by bare values. We could also emit a comment near the CSS to warn about the fact that `@custom-variant` will always be sorted _after_ any other built-in variants. This could result in slightly different behavior, or different order of classes when using `prettier-plugin-tailwindcss`. I don't know how important this is, because before this PR we would just use `@config './tailwind.config.js';`. Edit: when using the `@config` we override `aria` and extend it, which means that it would be in the expected order 🤔 --------- Co-authored-by: Jordan Pittman <[email protected]>
1 parent 8165e04 commit 9e498a3

File tree

3 files changed

+143
-26
lines changed

3 files changed

+143
-26
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Discard matched variants with unknown named values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799))
1818
- Discard matched variants with non-string values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799))
1919
- Show suggestions for known `matchVariant` values ([#18798](https://github.com/tailwindlabs/tailwindcss/pull/18798))
20+
- Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815))
2021

2122
## [4.1.12] - 2025-08-13
2223

integrations/upgrade/js-config.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,89 @@ test(
965965
},
966966
)
967967

968+
test(
969+
'migrate aria theme keys to custom variants',
970+
{
971+
fs: {
972+
'package.json': json`
973+
{
974+
"dependencies": {
975+
"tailwindcss": "^3",
976+
"@tailwindcss/upgrade": "workspace:^"
977+
}
978+
}
979+
`,
980+
'tailwind.config.ts': ts`
981+
export default {
982+
content: {
983+
relative: true,
984+
files: ['./src/**/*.html'],
985+
},
986+
theme: {
987+
extend: {
988+
aria: {
989+
// Built-in (not really, but visible because of intellisense)
990+
busy: 'busy="true"',
991+
992+
// Automatically handled by bare values
993+
foo: 'foo="true"',
994+
995+
// Quotes are optional in CSS for these kinds of attribute
996+
// selectors
997+
bar: 'bar=true',
998+
999+
// Not automatically handled by bare values because names differ
1000+
baz: 'qux="true"',
1001+
1002+
// Completely custom
1003+
asc: 'sort="ascending"',
1004+
desc: 'sort="descending"',
1005+
},
1006+
},
1007+
},
1008+
}
1009+
`,
1010+
'src/input.css': css`
1011+
@tailwind base;
1012+
@tailwind components;
1013+
@tailwind utilities;
1014+
`,
1015+
},
1016+
},
1017+
async ({ exec, fs, expect }) => {
1018+
await exec('npx @tailwindcss/upgrade')
1019+
1020+
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
1021+
"
1022+
--- src/input.css ---
1023+
@import 'tailwindcss';
1024+
1025+
@custom-variant aria-baz (&[aria-qux="true"]);
1026+
@custom-variant aria-asc (&[aria-sort="ascending"]);
1027+
@custom-variant aria-desc (&[aria-sort="descending"]);
1028+
1029+
/*
1030+
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
1031+
so we've added these compatibility styles to make sure everything still
1032+
looks the same as it did with Tailwind CSS v3.
1033+
1034+
If we ever want to remove these styles, we need to add an explicit border
1035+
color utility to any element that depends on these defaults.
1036+
*/
1037+
@layer base {
1038+
*,
1039+
::after,
1040+
::before,
1041+
::backdrop,
1042+
::file-selector-button {
1043+
border-color: var(--color-gray-200, currentcolor);
1044+
}
1045+
}
1046+
"
1047+
`)
1048+
},
1049+
)
1050+
9681051
describe('border compatibility', () => {
9691052
test(
9701053
'migrate border compatibility',

packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,14 @@ async function migrateTheme(
9797
designSystem: DesignSystem,
9898
unresolvedConfig: Config,
9999
base: string,
100-
): Promise<string | null> {
100+
): Promise<string> {
101101
// Resolve the config file without applying plugins and presets, as these are
102102
// migrated to CSS separately.
103103
let configToResolve: ConfigFile = {
104104
base,
105105
config: { ...unresolvedConfig, plugins: [], presets: undefined },
106106
reference: false,
107+
src: undefined,
107108
}
108109
let { resolvedConfig, replacedThemeKeys } = resolveConfig(designSystem, [configToResolve])
109110

@@ -113,10 +114,47 @@ async function migrateTheme(
113114

114115
removeUnnecessarySpacingKeys(designSystem, resolvedConfig, replacedThemeKeys)
115116

117+
let css = ''
116118
let prevSectionKey = ''
117-
let css = '\n@tw-bucket theme {\n'
118-
css += `\n@theme {\n`
119-
let containsThemeKeys = false
119+
let themeSection: string[] = []
120+
let keyframesCss = ''
121+
let variants = new Map<string, string>()
122+
123+
// Special handling of specific theme keys:
124+
{
125+
if ('keyframes' in resolvedConfig.theme) {
126+
keyframesCss += keyframesToCss(resolvedConfig.theme.keyframes)
127+
delete resolvedConfig.theme.keyframes
128+
}
129+
130+
if ('container' in resolvedConfig.theme) {
131+
let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem)
132+
if (rules.length > 0) {
133+
// Using `theme` instead of `utility` so it sits before the `@layer
134+
// base` with compatibility CSS. While this is technically a utility, it
135+
// makes a bit more sense to emit this closer to the `@theme` values
136+
// since it is needed for backwards compatibility.
137+
css += `\n@tw-bucket theme {\n`
138+
css += toCss([atRule('@utility', 'container', rules)])
139+
css += '}\n' // @tw-bucket
140+
}
141+
delete resolvedConfig.theme.container
142+
}
143+
144+
if ('aria' in resolvedConfig.theme) {
145+
for (let [key, value] of Object.entries(resolvedConfig.theme.aria ?? {})) {
146+
// Will be handled by bare values if the names match.
147+
// E.g.: `aria-foo:flex` should produce `[aria-foo="true"]`
148+
if (new RegExp(`^${key}=(['"]?)true\\1$`).test(`${value}`)) continue
149+
150+
// Create custom variant
151+
variants.set(`aria-${key}`, `&[aria-${value}]`)
152+
}
153+
delete resolvedConfig.theme.aria
154+
}
155+
}
156+
157+
// Convert theme values to CSS custom properties
120158
for (let [key, value] of themeableValues(resolvedConfig.theme)) {
121159
if (typeof value !== 'string' && typeof value !== 'number') {
122160
continue
@@ -151,51 +189,46 @@ async function migrateTheme(
151189
}
152190
}
153191

154-
if (key[0] === 'keyframes') {
155-
continue
156-
}
157-
containsThemeKeys = true
158-
159192
let sectionKey = createSectionKey(key)
160193
if (sectionKey !== prevSectionKey) {
161-
css += `\n`
194+
themeSection.push('')
162195
prevSectionKey = sectionKey
163196
}
164197

165198
if (resetNamespaces.has(key[0]) && resetNamespaces.get(key[0]) === false) {
166199
resetNamespaces.set(key[0], true)
167200
let property = keyPathToCssProperty([key[0]])
168201
if (property !== null) {
169-
css += ` ${escape(`--${property}`)}-*: initial;\n`
202+
themeSection.push(` ${escape(`--${property}`)}-*: initial;`)
170203
}
171204
}
172205

173206
let property = keyPathToCssProperty(key)
174207
if (property !== null) {
175-
css += ` ${escape(`--${property}`)}: ${value};\n`
208+
themeSection.push(` ${escape(`--${property}`)}: ${value};`)
176209
}
177210
}
178211

179-
if ('keyframes' in resolvedConfig.theme) {
180-
containsThemeKeys = true
181-
css += '\n' + keyframesToCss(resolvedConfig.theme.keyframes)
212+
if (keyframesCss) {
213+
themeSection.push('', keyframesCss)
182214
}
183215

184-
if (!containsThemeKeys) {
185-
return null
216+
if (themeSection.length > 0) {
217+
css += `\n@tw-bucket theme {\n`
218+
css += `\n@theme {\n`
219+
css += themeSection.join('\n') + '\n'
220+
css += '}\n' // @theme
221+
css += '}\n' // @tw-bucket
186222
}
187223

188-
css += '}\n' // @theme
189-
190-
if ('container' in resolvedConfig.theme) {
191-
let rules = buildCustomContainerUtilityRules(resolvedConfig.theme.container, designSystem)
192-
if (rules.length > 0) {
193-
css += '\n' + toCss([atRule('@utility', 'container', rules)])
224+
if (variants.size > 0) {
225+
css += '\n@tw-bucket custom-variant {\n'
226+
for (let [name, selector] of variants) {
227+
css += `@custom-variant ${name} (${selector});\n`
194228
}
229+
css += '}\n'
195230
}
196231

197-
css += '}\n' // @tw-bucket
198-
199232
return css
200233
}
201234

@@ -356,7 +389,7 @@ const ALLOWED_THEME_KEYS = [
356389
// Used by @tailwindcss/container-queries
357390
'containers',
358391
]
359-
const BLOCKED_THEME_KEYS = ['supports', 'data', 'aria']
392+
const BLOCKED_THEME_KEYS = ['supports', 'data']
360393
function onlyAllowedThemeValues(theme: ThemeConfig): boolean {
361394
for (let key of Object.keys(theme)) {
362395
if (!ALLOWED_THEME_KEYS.includes(key)) {

0 commit comments

Comments
 (0)