Properly negate custom variants with @container when used with not-* (#20059)
This PR fixes an issue where a custom variant declared with a
`@container` wasn't properly negated when using it in combination with
the `not-*` variant.
Given this CSS:
```css
@custom-variant has-a {
@container style(--a) {
@slot;
}
}
```
If you then used `not-has-a:flex`, then the following CSS was produced:
```css
.not-has-a\:flex {
@container style(--a) not {
display: flex;
}
}
```
But we expect the `not` to be in the correct location:
```css
.not-has-a\:flex {
@container not style(--a) {
display: flex;
}
}
```
The issue was that we did some string related checks, and we assumed
that the `query` part of the `@container` (`style(--a)`) had to start
with a `(` character.
To fix this, we now parse the value to an AST, and verify the AST shape
before manipulating it. This now checks whether the `query` part is a
function (both `(…)` and `style(…)` are considered functions).
Also added some additional tests that were already handled, these cases
look like:
- `@container {query}`
- `@container not {query}`
- `@container {name} not {query}`
- `@container {name} {query}`
Fixes: #20058
## Test plan
1. Added a failing test for the use case of the linked issue.
2. Added a few more additional tests to explicitly track some use cases
we handled already but didn't track via tests.
3. All other tests still pass as expected.
This commit is contained in:
parent
460a008120
commit
36417cbd12
3 changed files with 136 additions and 23 deletions
|
|
@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028))
|
||||
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
|
||||
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
|
||||
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
|
||||
|
||||
## [4.3.0] - 2026-05-08
|
||||
|
||||
|
|
|
|||
|
|
@ -1770,6 +1770,69 @@ test('not', async () => {
|
|||
"
|
||||
`)
|
||||
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/20058
|
||||
expect(
|
||||
await run(
|
||||
['not-has-a:flex', 'not-has-b:flex', 'not-has-c:flex', 'not-has-d:flex'],
|
||||
css`
|
||||
@custom-variant has-a {
|
||||
@container style(--a) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* Already negated case */
|
||||
@custom-variant has-b {
|
||||
@container not style(--b) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* Named @container */
|
||||
@custom-variant has-c {
|
||||
@container foo style(--c) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
/* Named @container, that's already negated case */
|
||||
@custom-variant has-d {
|
||||
@container bar not style(--d) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@tailwind utilities;
|
||||
`,
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
"
|
||||
@container not style(--a) {
|
||||
.not-has-a\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container style(--b) {
|
||||
.not-has-b\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container foo not style(--c) {
|
||||
.not-has-c\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@container bar style(--d) {
|
||||
.not-has-d\\:flex {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
"
|
||||
`)
|
||||
|
||||
expect(
|
||||
await run(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { compareBreakpoints } from './utils/compare-breakpoints'
|
|||
import { DefaultMap } from './utils/default-map'
|
||||
import { isPositiveInteger } from './utils/infer-data-type'
|
||||
import { segment } from './utils/segment'
|
||||
import * as ValueParser from './value-parser'
|
||||
import { walk, WalkAction } from './walk'
|
||||
|
||||
export const IS_VALID_VARIANT_NAME = /^@?[a-z0-9][a-zA-Z0-9_-]*(?<![_-])$/
|
||||
|
|
@ -375,35 +376,83 @@ export function createVariants(theme: Theme): Variants {
|
|||
|
||||
function negateConditions(ruleName: string, conditions: string[]) {
|
||||
return conditions.map((condition) => {
|
||||
condition = condition.trim()
|
||||
switch (ruleName) {
|
||||
case '@container': {
|
||||
let ast = ValueParser.parse(condition.trim())
|
||||
|
||||
let parts = segment(condition, ' ')
|
||||
// @container {query}
|
||||
// ^^^^^^^
|
||||
// ast 0
|
||||
if (ast.length >= 1 && ast[0].kind === 'function') {
|
||||
return `not ${condition}`
|
||||
}
|
||||
|
||||
// @media not {query}
|
||||
// @supports not {query}
|
||||
// @container not {query}
|
||||
if (parts[0] === 'not') {
|
||||
return parts.slice(1).join(' ')
|
||||
}
|
||||
// @container not {query}
|
||||
// ^^^ ^ ^^^^^^^
|
||||
// ast 0 1 2
|
||||
else if (
|
||||
ast.length >= 3 &&
|
||||
ast[0].kind === 'word' &&
|
||||
ast[0].value === 'not' &&
|
||||
ast[2].kind === 'function'
|
||||
) {
|
||||
// Drop the leading `not` (ast[0]) and separator (ast[1])
|
||||
ast.splice(0, 2)
|
||||
|
||||
return ValueParser.toCss(ast)
|
||||
}
|
||||
|
||||
// @container {name} not {query}
|
||||
// ^^^^^^ ^ ^^^ ^ ^^^^^^^
|
||||
// ast 0 1 2 3 4
|
||||
else if (
|
||||
ast.length >= 5 &&
|
||||
ast[0].kind === 'word' &&
|
||||
ast[2].kind === 'word' &&
|
||||
ast[2].value === 'not' &&
|
||||
ast[4].kind === 'function'
|
||||
) {
|
||||
// Drop the `not` (ast[2]) and separator (ast[3])
|
||||
ast.splice(2, 2)
|
||||
|
||||
return ValueParser.toCss(ast)
|
||||
}
|
||||
|
||||
// @container {name} {query}
|
||||
// ^^^^^^ ^ ^^^^^^^
|
||||
// ast 0 1 2
|
||||
else if (
|
||||
ast.length >= 3 &&
|
||||
ast[0].kind === 'word' &&
|
||||
ast[0].value !== 'not' &&
|
||||
ast[2].kind === 'function'
|
||||
) {
|
||||
// Inject a separator and a `not`, after the `name` (ast[0])
|
||||
ast.splice(1, 0, { kind: 'separator', value: ' ' }, { kind: 'word', value: 'not' })
|
||||
|
||||
return ValueParser.toCss(ast)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
else {
|
||||
return `not ${condition}`
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
condition = condition.trim()
|
||||
|
||||
let parts = segment(condition, ' ')
|
||||
|
||||
// @media not {query}
|
||||
// @supports not {query}
|
||||
if (parts[0] === 'not') {
|
||||
return parts.slice(1).join(' ')
|
||||
}
|
||||
|
||||
if (ruleName === '@container') {
|
||||
// @container {query}
|
||||
if (parts[0][0] === '(') {
|
||||
return `not ${condition}`
|
||||
}
|
||||
|
||||
// @container {name} not {query}
|
||||
else if (parts[1] === 'not') {
|
||||
return `${parts[0]} ${parts.slice(2).join(' ')}`
|
||||
}
|
||||
|
||||
// @container {name} {query}
|
||||
else {
|
||||
return `${parts[0]} not ${parts.slice(1).join(' ')}`
|
||||
}
|
||||
}
|
||||
|
||||
return `not ${condition}`
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue