Canonicalization: migrate [&:has(…)] to has-[…] variant (#19991)

This PR improves the canonicalization around arbitrary variants
containing `[&:has(…)]`, by converting them to `has-[…]`.

Essentially when you have a variant, with `&:has(…)`, we will convert
it:

```diff
- [&:has([role=checkbox])]:flex
+ has-[[role=checkbox]]:flex
```

This also means that if the arbitrary selector inside of the `&:has(…)`
can be converted to something that aligns with known variants, then we
will do that as well:

```diff
  // `data-*` can be hoisted outside of the arbitrary value
- [&:has([data-slot=description])]:flex
+ has-data-[slot=description]:flex

  // `aria-visible="true"` can be entirely replaced by `aria-visible`
- [&:has([aria-visible="true"])]:flex
+ has-aria-visible:flex
```

Noticed this while looking into:
https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1562

## Test plan

1. Added additional tests for this use case
2. Existing tests still pass
This commit is contained in:
Robin Malfait 2026-04-29 14:20:11 +02:00 • committed by GitHub
parent b9449612ba
commit 73f3a6a743
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 28 additions and 0 deletions

View file

@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: preserve significant `_` whitespace in arbitrary values ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. `-mt-[20in]` → `mt-[-20in]`, not `mt-[-1920px]`) ([#19988](https://github.com/tailwindlabs/tailwindcss/pull/19988))
- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991))
## [4.2.4] - 2026-04-21

View file

@ -1022,6 +1022,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'],
['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'],
// Arbitrary variant to compound + arbitrary variants
['[&:has([role=checkbox])]:flex', 'has-[[role=checkbox]]:flex'],
['[&:has([aria-visible="true"])]:flex', 'has-aria-visible:flex'],
['[&:has([data-slot=description])]:flex', 'has-data-[slot=description]:flex'],
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';

View file

@ -1796,6 +1796,28 @@ function modernizeArbitraryValuesVariant(
continue
}
// `[&:has(…)]` can be replaced with `has-[…]`
if (
// Only top-level, so `group-[&:has(…)]` is not covered
parent === null &&
// [&:has(…)]:flex
// ^ ^^^^^^
ast.length === 2 &&
ast[0].kind === 'selector' &&
ast[0].value === '&' &&
ast[1].kind === 'function' &&
ast[1].value === ':has' &&
ast[1].nodes.length === 1 &&
ast[1].nodes[0].kind === 'selector'
) {
replaceObject(
variant,
designSystem.parseVariant(`has-[${SelectorParser.toCss(ast[1].nodes)}]`),
)
continue
}
// `in-*` variant. If the selector ends with ` &`, we can convert it to an
// `in-*` variant.
//