diff --git a/CHANGELOG.md b/CHANGELOG.md index 084028ccb..34d5c1b9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index ff6f530c8..305859d5b 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -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( [ diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 76ead2dc9..4d4637d8f 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -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_-]*(? { - 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}` }) }