From 274be93fd8b3358bfbcb5ecbfda227fbfa075112 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Sep 2025 14:17:07 +0200 Subject: [PATCH 01/39] Handle `'` syntax in ClojureScript when extracting classes (#18888) This PR fixes an issue where the `'` syntax in ClojureScript was not handled properly, resulting in missing extracted classes. This PR now supports the following ClojureScript syntaxes: ```cljs ; Keyword (print 'text-red-500) ; List (print '(flex flex-col underline)) ; Vector (print '[flex flex-col underline]) ``` ### Test plan 1. Added regression tests 2. Verified that we extract classes correctly now in various scenarios: Top is before, bottom is with this PR: image Fixes: #18882 --- CHANGELOG.md | 4 +- .../src/extractor/pre_processors/clojure.rs | 152 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee41d2d10f1..ae4c34e855df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888)) ## [4.1.13] - 2025-09-03 diff --git a/crates/oxide/src/extractor/pre_processors/clojure.rs b/crates/oxide/src/extractor/pre_processors/clojure.rs index c53afd552b19..ecf02b4ff634 100644 --- a/crates/oxide/src/extractor/pre_processors/clojure.rs +++ b/crates/oxide/src/extractor/pre_processors/clojure.rs @@ -108,6 +108,75 @@ impl PreProcessor for Clojure { } } + // Handle quote with a list, e.g.: `'(…)` + // and with a vector, e.g.: `'[…]` + b'\'' if matches!(cursor.next, b'[' | b'(') => { + result[cursor.pos] = b' '; + cursor.advance(); + result[cursor.pos] = b' '; + let end = match cursor.curr { + b'[' => b']', + b'(' => b')', + _ => unreachable!(), + }; + + // Consume until the closing `]` + while cursor.pos < len { + match cursor.curr { + x if x == end => { + result[cursor.pos] = b' '; + break; + } + + // Consume strings as-is + b'"' => { + result[cursor.pos] = b' '; + cursor.advance(); + + while cursor.pos < len { + match cursor.curr { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + + // End of the string + b'"' => { + result[cursor.pos] = b' '; + break; + } + + // Everything else is valid + _ => cursor.advance(), + }; + } + } + _ => {} + }; + + cursor.advance(); + } + } + + // Handle quote with a keyword, e.g.: `'bg-white` + b'\'' if !cursor.next.is_ascii_whitespace() => { + result[cursor.pos] = b' '; + cursor.advance(); + + while cursor.pos < len { + match cursor.curr { + // End of keyword. + _ if !is_keyword_character(cursor.curr) => { + result[cursor.pos] = b' '; + break; + } + + // Consume everything else. + _ => {} + }; + + cursor.advance(); + } + } + // Aggressively discard everything else, reducing false positives and preventing // characters surrounding keywords from producing false negatives. // E.g.: @@ -281,4 +350,87 @@ mod tests { vec!["py-5", "flex", "pr-1.5", "bg-white", "bg-black"], ); } + + // https://github.com/tailwindlabs/tailwindcss/issues/18882 + #[test] + fn test_extract_from_symbol_list() { + let input = r#" + [:div {:class '[z-1 z-2 + z-3 z-4]}] + "#; + Clojure::test_extract_contains(input, vec!["z-1", "z-2", "z-3", "z-4"]); + + // https://github.com/tailwindlabs/tailwindcss/pull/18345#issuecomment-3253403847 + let input = r#" + (def hl-class-names '[ring ring-blue-500]) + + [:div + {:class (cond-> '[input w-full] + textarea? (conj 'textarea) + (seq errors) (concat '[border-red-500 bg-red-100]) + highlight? (concat hl-class-names))}] + "#; + Clojure::test_extract_contains( + input, + vec![ + "ring", + "ring-blue-500", + "input", + "w-full", + "textarea", + "border-red-500", + "bg-red-100", + ], + ); + + let input = r#" + [:div + {:class '[h-100 lg:h-200 max-w-32 mx-auto py-60 + flex flex-col justify-end items-center + lg:flex-row lg:justify-between + bg-cover bg-center bg-no-repeat rounded-3xl overflow-hidden + font-semibold text-gray-900]}] + "#; + Clojure::test_extract_contains( + input, + vec![ + "h-100", + "lg:h-200", + "max-w-32", + "mx-auto", + "py-60", + "flex", + "flex-col", + "justify-end", + "items-center", + "lg:flex-row", + "lg:justify-between", + "bg-cover", + "bg-center", + "bg-no-repeat", + "rounded-3xl", + "overflow-hidden", + "font-semibold", + "text-gray-900", + ], + ); + + // `/` is invalid and requires explicit quoting + let input = r#" + '[p-32 "text-black/50"] + "#; + Clojure::test_extract_contains(input, vec!["p-32", "text-black/50"]); + + // `[…]` is invalid and requires explicit quoting + let input = r#" + (print '[ring ring-blue-500 "bg-[#0088cc]"]) + "#; + Clojure::test_extract_contains(input, vec!["ring", "ring-blue-500", "bg-[#0088cc]"]); + + // `'(…)` looks similar to `[…]` but uses parentheses instead of brackets + let input = r#" + (print '(ring ring-blue-500 "bg-[#0088cc]")) + "#; + Clojure::test_extract_contains(input, vec!["ring", "ring-blue-500", "bg-[#0088cc]"]); + } } From 77b3cb5318840925d8a75a11cc90552a93507ddc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Sep 2025 14:24:11 +0200 Subject: [PATCH 02/39] Handle `@variant` inside `@custom-variant` (#18885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes an issue where you cannot use `@variant` inside a `@custom-variant`. While you can use `@variant` in normal CSS, you cannot inside of `@custom-variant`. Today this silently fails and emits invalid CSS. ```css @custom-variant dark { @variant data-dark { @slot; } } ``` ```html
``` Would result in: ```css .dark\:flex { @variant data-dark { display: flex; } } ``` To solve it we have 3 potential solutions: 1. Consider it user error — but since it generates CSS and you don't really get an error you could be shipping broken CSS unknowingly. 1. We could try and detect this and not generate CSS for this and potentially show a warning. 1. We could make it work as expected — which is what this PR does. Some important notes: 1. The evaluation of the `@custom-variant` only happens when you actually need it. That means that `@variant` inside `@custom-variant` will always have the implementation of the last definition of that variant. In other words, if you use `@variant hover` inside a `@custom-variant`, and later you override the `hover` variant, the `@custom-variant` will use the new implementation. 1. If you happen to introduce a circular dependency, then an error will be thrown during the build step. You can consider it a bug fix or a new feature it's a bit of a gray area. But one thing that is cool about this is that you can ship a plugin that looks like this: ```css @custom-variant hocus { @variant hover { @slot; } @variant focus { @slot; } } ``` And it will use the implementation of `hover` and `focus` that the user has defined. So if they have a custom `hover` or `focus` variant it will just work. By default `hocus:underline` would generate: ```css @media (hover: hover) { .hocus\:underline:hover { text-decoration-line: underline; } } .hocus\:underline:focus { text-decoration-line: underline; } ``` But if you have a custom `hover` variant like: ```css @custom-variant hover (&:hover); ``` Then `hocus:underline` would generate: ```css .hocus\:underline:hover, .hocus\:underline:focus { text-decoration-line: underline; } ``` ### Test plan 1. Existing tests pass 2. Added tests with this new functionality handled 3. Made sure to add a test for circular dependencies + error message 4. Made sure that if you "fix" the circular dependency (by overriding a variant) that everything is generated as expected. Fixes: https://github.com/tailwindlabs/tailwindcss/issues/18524 --- CHANGELOG.md | 1 + packages/tailwindcss/src/compat/plugin-api.ts | 2 +- packages/tailwindcss/src/index.test.ts | 174 ++++++++++++++++++ packages/tailwindcss/src/index.ts | 71 +++---- .../tailwindcss/src/utils/topological-sort.ts | 36 ++++ packages/tailwindcss/src/variants.ts | 40 +++- 6 files changed, 287 insertions(+), 37 deletions(-) create mode 100644 packages/tailwindcss/src/utils/topological-sort.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4c34e855df..3c161e571aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888)) +- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885)) ## [4.1.13] - 2025-09-03 diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 6d13f4678e95..3b2f0712c2af 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -154,7 +154,7 @@ export function buildPluginApi({ // CSS-in-JS object else if (typeof variant === 'object') { - designSystem.variants.fromAst(name, objectToAst(variant)) + designSystem.variants.fromAst(name, objectToAst(variant), designSystem) } }, matchVariant(name, fn, options) { diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index edddb55f0201..cfd0d1db8909 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -4343,6 +4343,180 @@ describe('@custom-variant', () => { }" `) }) + + test('@custom-variant can reuse existing @variant in the definition', async () => { + expect( + await compileCss( + css` + @custom-variant hocus { + @variant hover { + @variant focus { + @slot; + } + } + } + + @tailwind utilities; + `, + ['hocus:flex'], + ), + ).toMatchInlineSnapshot(` + "@media (hover: hover) { + .hocus\\:flex:hover:focus { + display: flex; + } + }" + `) + }) + + test('@custom-variant can reuse @custom-variant that is defined later', async () => { + expect( + await compileCss( + css` + @custom-variant hocus { + @variant custom-hover { + @variant focus { + @slot; + } + } + } + + @custom-variant custom-hover (&:hover); + + @tailwind utilities; + `, + ['hocus:flex'], + ), + ).toMatchInlineSnapshot(` + ".hocus\\:flex:hover:focus { + display: flex; + }" + `) + }) + + test('@custom-variant can reuse existing @variant that is overwritten later', async () => { + expect( + await compileCss( + css` + @custom-variant hocus { + @variant hover { + @variant focus { + @slot; + } + } + } + + @custom-variant hover (&:hover); + + @tailwind utilities; + `, + ['hocus:flex'], + ), + ).toMatchInlineSnapshot(` + ".hocus\\:flex:hover:focus { + display: flex; + }" + `) + }) + + test('@custom-variant cannot use @variant that eventually results in a circular dependency', async () => { + return expect(() => + compileCss( + css` + @custom-variant custom-variant { + @variant foo { + @slot; + } + } + + @custom-variant foo { + @variant hover { + @variant bar { + @slot; + } + } + } + + @custom-variant bar { + @variant focus { + @variant baz { + @slot; + } + } + } + + @custom-variant baz { + @variant active { + @variant foo { + @slot; + } + } + } + + @tailwind utilities; + `, + ['foo:flex'], + ), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [Error: Circular dependency detected in custom variants: + + @custom-variant custom-variant { + @variant foo { … } + } + @custom-variant foo { /* ← */ + @variant bar { … } + } + @custom-variant bar { + @variant baz { … } + } + @custom-variant baz { + @variant foo { … } + } + ] + `) + }) + + test('@custom-variant setup that results in a circular dependency error can be solved', async () => { + expect( + await compileCss( + css` + @custom-variant foo { + @variant hover { + @variant bar { + @slot; + } + } + } + + @custom-variant bar { + @variant focus { + @variant baz { + @slot; + } + } + } + + @custom-variant baz { + @variant active { + @variant foo { + @slot; + } + } + } + + /* Break the circle */ + @custom-variant foo ([data-broken-circle] &); + + @tailwind utilities; + `, + ['baz:flex'], + ), + ).toMatchInlineSnapshot(` + "[data-broken-circle] .baz\\:flex:active { + display: flex; + }" + `) + }) }) describe('@utility', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 4be573efbb11..ca2a0b0df16b 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -22,7 +22,7 @@ import { substituteAtImports } from './at-import' import { applyCompatibilityHooks } from './compat/apply-compat-hooks' import type { UserConfig } from './compat/config/types' import { type Plugin } from './compat/plugin-api' -import { applyVariant, compileCandidates } from './compile' +import { compileCandidates } from './compile' import { substituteFunctions } from './css-functions' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' @@ -32,7 +32,8 @@ import { createCssUtility } from './utilities' import { expand } from './utils/brace-expansion' import { escape, unescape } from './utils/escape' import { segment } from './utils/segment' -import { compoundsForSelectors, IS_VALID_VARIANT_NAME } from './variants' +import { topologicalSort } from './utils/topological-sort' +import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtVariant } from './variants' export type Config = UserConfig const IS_VALID_PREFIX = /^[a-z]+$/ @@ -150,7 +151,8 @@ async function parseCss( let important = null as boolean | null let theme = new Theme() - let customVariants: ((designSystem: DesignSystem) => void)[] = [] + let customVariants = new Map void>() + let customVariantDependencies = new Map>() let customUtilities: ((designSystem: DesignSystem) => void)[] = [] let firstThemeRule = null as StyleRule | null let utilitiesNode = null as AtRule | null @@ -390,7 +392,7 @@ async function parseCss( } } - customVariants.push((designSystem) => { + customVariants.set(name, (designSystem) => { designSystem.variants.static( name, (r) => { @@ -411,6 +413,7 @@ async function parseCss( }, ) }) + customVariantDependencies.set(name, new Set()) return } @@ -431,9 +434,17 @@ async function parseCss( // } // ``` else { - customVariants.push((designSystem) => { - designSystem.variants.fromAst(name, node.nodes) + let dependencies = new Set() + walk(node.nodes, (child) => { + if (child.kind === 'at-rule' && child.name === '@variant') { + dependencies.add(child.params) + } + }) + + customVariants.set(name, (designSystem) => { + designSystem.variants.fromAst(name, node.nodes, designSystem) }) + customVariantDependencies.set(name, dependencies) return } @@ -605,8 +616,27 @@ async function parseCss( sources, }) - for (let customVariant of customVariants) { - customVariant(designSystem) + for (let name of customVariants.keys()) { + // Pre-register the variant to ensure its position in the variant list is + // based on the order we see them in the CSS. + designSystem.variants.static(name, () => {}) + } + + // Register custom variants in order + for (let variant of topologicalSort(customVariantDependencies, { + onCircularDependency(path, start) { + let output = toCss( + path.map((name, idx) => { + return atRule('@custom-variant', name, [atRule('@variant', path[idx + 1] ?? start, [])]) + }), + ) + .replaceAll(';', ' { … }') + .replace(`@custom-variant ${start} {`, `@custom-variant ${start} { /* ← */`) + + throw new Error(`Circular dependency detected in custom variants:\n\n${output}`) + }, + })) { + customVariants.get(variant)?.(designSystem) } for (let customUtility of customUtilities) { @@ -636,30 +666,7 @@ async function parseCss( firstThemeRule.nodes = [context({ theme: true }, nodes)] } - // Replace the `@variant` at-rules with the actual variant rules. - if (variantNodes.length > 0) { - for (let variantNode of variantNodes) { - // Starting with the `&` rule node - let node = styleRule('&', variantNode.nodes) - - let variant = variantNode.params - - let variantAst = designSystem.parseVariant(variant) - if (variantAst === null) { - throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`) - } - - let result = applyVariant(node, variantAst, designSystem.variants) - if (result === null) { - throw new Error(`Cannot use \`@variant\` with variant: ${variant}`) - } - - // Update the variant at-rule node, to be the `&` rule node - Object.assign(variantNode, node) - } - features |= Features.Variants - } - + features |= substituteAtVariant(ast, designSystem) features |= substituteFunctions(ast, designSystem) features |= substituteAtApply(ast, designSystem) diff --git a/packages/tailwindcss/src/utils/topological-sort.ts b/packages/tailwindcss/src/utils/topological-sort.ts new file mode 100644 index 000000000000..ae20da4aac09 --- /dev/null +++ b/packages/tailwindcss/src/utils/topological-sort.ts @@ -0,0 +1,36 @@ +export function topologicalSort( + graph: Map>, + options: { onCircularDependency: (path: Key[], start: Key) => void }, +): Key[] { + let seen = new Set() + let wip = new Set() + + let sorted: Key[] = [] + + function visit(node: Key, path: Key[] = []) { + if (!graph.has(node)) return + if (seen.has(node)) return + + // Circular dependency detected + if (wip.has(node)) options.onCircularDependency?.(path, node) + + wip.add(node) + + for (let dependency of graph.get(node) ?? []) { + path.push(node) + visit(dependency, path) + path.pop() + } + + seen.add(node) + wip.delete(node) + + sorted.push(node) + } + + for (let node of graph.keys()) { + visit(node) + } + + return sorted +} diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 7fba81d20100..4e5c46894913 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1,3 +1,4 @@ +import { Features } from '.' import { WalkAction, atRoot, @@ -12,6 +13,8 @@ import { type StyleRule, } from './ast' import { type Variant } from './candidate' +import { applyVariant } from './compile' +import type { DesignSystem } from './design-system' import type { Theme } from './theme' import { compareBreakpoints } from './utils/compare-breakpoints' import { DefaultMap } from './utils/default-map' @@ -80,12 +83,15 @@ export class Variants { }) } - fromAst(name: string, ast: AstNode[]) { + fromAst(name: string, ast: AstNode[], designSystem: DesignSystem) { let selectors: string[] = [] + let usesAtVariant = false walk(ast, (node) => { if (node.kind === 'rule') { selectors.push(node.selector) + } else if (node.kind === 'at-rule' && node.name === '@variant') { + usesAtVariant = true } else if (node.kind === 'at-rule' && node.name !== '@slot') { selectors.push(`${node.name} ${node.params}`) } @@ -95,12 +101,11 @@ export class Variants { name, (r) => { let body = structuredClone(ast) + if (usesAtVariant) substituteAtVariant(body, designSystem) substituteAtSlot(body, r.nodes) r.nodes = body }, - { - compounds: compoundsForSelectors(selectors), - }, + { compounds: compoundsForSelectors(selectors) }, ) } @@ -1198,3 +1203,30 @@ export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) { } }) } + +export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): Features { + let features = Features.None + walk(ast, (variantNode, { replaceWith }) => { + if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return + + // Starting with the `&` rule node + let node = styleRule('&', variantNode.nodes) + + let variant = variantNode.params + + let variantAst = designSystem.parseVariant(variant) + if (variantAst === null) { + throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`) + } + + let result = applyVariant(node, variantAst, designSystem.variants) + if (result === null) { + throw new Error(`Cannot use \`@variant\` with variant: ${variant}`) + } + + // Update the variant at-rule node, to be the `&` rule node + replaceWith(node) + features |= Features.Variants + }) + return features +} From 2f1cbbfed28729798eebdaa57935e8f7b0c622e1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Mon, 8 Sep 2025 12:18:30 +0200 Subject: [PATCH 03/39] Merge suggestions when using `@utility` (#18900) This PR fixes a bug where custom `@utility` implementations with a name that match an existing utility would override the existing suggestions even though we generate both utilities. With this, we want to make sure that both the custom and the built-in utilities are suggested. We also want to make sure that we don't get duplicate suggestions. E.g.: - `font-` would suggest: - 'font-black' - 'font-bold' - 'font-extrabold' - 'font-extralight' - 'font-light' - 'font-medium' - 'font-mono' - 'font-normal' - 'font-sans' - 'font-semibold' - 'font-serif' - 'font-thin' But if you introduce this little custom utility: ```css @theme { --custom-font-weights-foo: 123; } @utility font-* { --my-weight: --value(--custom-font-weights- *); } ``` - `font-` would suggest: - 'font-foo' With this fix, we would suggest: - `font-` would suggest: - 'font-black' - 'font-bold' - 'font-extrabold' - 'font-extralight' - 'font-foo' // This is now added - 'font-light' - 'font-medium' - 'font-mono' - 'font-normal' - 'font-sans' - 'font-semibold' - 'font-serif' - 'font-thin' We also make sure that they are unique, so if you have a custom utility that happens to match another existing utility (e.g. `font-bold`), you won't see `font-bold` twice in the suggestions. ```css @theme { --custom-font-weights-bold: bold; --custom-font-weights-normal: normal; --custom-font-weights-foo: 1234; } @utility font-* { --my-weight: --value(--custom-font-weights-*); } ``` - `font-` would suggest: - 'font-black' - 'font-bold' // Overlaps with existing utility - 'font-extrabold' - 'font-extralight' - 'font-foo' // This is now added - 'font-light' - 'font-medium' - 'font-mono' - 'font-normal' // Overlaps with existing utility - 'font-sans' - 'font-semibold' - 'font-serif' - 'font-thin' --- CHANGELOG.md | 1 + packages/tailwindcss/src/intellisense.test.ts | 33 +++++++++++++++++++ packages/tailwindcss/src/intellisense.ts | 3 ++ packages/tailwindcss/src/utilities.ts | 9 +++-- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c161e571aa8..4d7a1fcbeb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888)) - Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885)) +- Merge suggestions when using `@utility` ([#18900](https://github.com/tailwindlabs/tailwindcss/pull/18900)) ## [4.1.13] - 2025-09-03 diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index 32893b1a0c8e..5dbfc8c54c80 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -572,6 +572,39 @@ test('Custom functional @utility', async () => { expect(classMap.get('example-xs')?.modifiers).toEqual(['normal', 'foo', 'bar']) }) +test('Custom utilities sharing a root with built-in utilities should merge suggestions', async () => { + let input = css` + @import 'tailwindcss/utilities'; + @theme { + --font-sans: sans-serif; + } + + @theme { + --font-weight-custom: 1234; + --font-weight-bold: bold; /* Overlap with existing utility */ + } + + @utility font-* { + --my-font-weight: --value(--font-weight- *); + } + ` + + let design = await __unstable__loadDesignSystem(input, { + loadStylesheet: async (_, base) => ({ + path: '', + base, + content: '@tailwind utilities;', + }), + }) + + let classMap = new Map(design.getClassList()) + let classNames = Array.from(classMap.keys()) + + expect(classNames).toContain('font-sans') // Existing font-family utility + expect(classNames).toContain('font-bold') // Existing font-family utility & custom font-weight utility + expect(classNames).toContain('font-custom') // Custom font-weight utility +}) + test('Theme keys with underscores are suggested with underscores', async () => { let input = css` @import 'tailwindcss/utilities'; diff --git a/packages/tailwindcss/src/intellisense.ts b/packages/tailwindcss/src/intellisense.ts index 27db3a540bc3..e6db73b50f4a 100644 --- a/packages/tailwindcss/src/intellisense.ts +++ b/packages/tailwindcss/src/intellisense.ts @@ -55,6 +55,9 @@ export function getClassList(design: DesignSystem): ClassEntry[] { item.fraction ||= fraction item.modifiers.push(...group.modifiers) } + + // Deduplicate modifiers + item.modifiers = Array.from(new Set(item.modifiers)) } } } diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index fa97dbd5273a..9a6906bbd6eb 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -124,9 +124,12 @@ export class Utilities { } suggest(name: string, groups: () => SuggestionGroup[]) { - // TODO: We are calling this multiple times on purpose but ideally only ever - // once per utility root. - this.completions.set(name, groups) + let existingGroups = this.completions.get(name) + if (existingGroups) { + this.completions.set(name, () => [...existingGroups?.(), ...groups?.()]) + } else { + this.completions.set(name, groups) + } } keys(kind: 'static' | 'functional') { From b7c7e48c5d897d1b3d20c936e2baccc89b75f43e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 9 Sep 2025 14:20:04 +0200 Subject: [PATCH 04/39] Add `@container-size` utility (#18901) This PR adds a new `@container-size` utility instead of `@container-[size]`. The main reason we didn't do this before is because we only have container width related container queries, and not block based ones so we never needed `size` and `inline-size` was enough. However, `@container-size` is still useful if you are using container query related units such as `cqb` which are using the block size of the container not the inline size. I also added a little helper such that `@container-size` is only available in `insiders` and `4.2.0` (and later) so `4.1.x` releases won't have this utility yet. This will require some CHANGELOG changes such that we don't include this when releasing the next minor release. --- CHANGELOG.md | 4 ++++ packages/tailwindcss/src/feature-flags.ts | 1 + packages/tailwindcss/src/utilities.test.ts | 24 +++++++++++----------- packages/tailwindcss/src/utilities.ts | 7 +++++++ 4 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 packages/tailwindcss/src/feature-flags.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7a1fcbeb31..6d41d6da9c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901)) + ### Fixed - Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888)) diff --git a/packages/tailwindcss/src/feature-flags.ts b/packages/tailwindcss/src/feature-flags.ts new file mode 100644 index 000000000000..5a262056c78c --- /dev/null +++ b/packages/tailwindcss/src/feature-flags.ts @@ -0,0 +1 @@ +export const enableContainerSizeUtility = process.env.FEATURES_ENV !== 'stable' diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index a7d3fd97c53c..32a01fda86eb 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -26214,16 +26214,16 @@ test('@container', async () => { '@container-normal', '@container/sidebar', '@container-normal/sidebar', - '@container-[size]', - '@container-[size]/sidebar', + '@container-size', + '@container-size/sidebar', ]), ).toMatchInlineSnapshot(` - ".\\@container-\\[size\\]\\/sidebar { - container: sidebar / size; + ".\\@container-normal\\/sidebar { + container: sidebar; } - .\\@container-normal\\/sidebar { - container: sidebar; + .\\@container-size\\/sidebar { + container: sidebar / size; } .\\@container\\/sidebar { @@ -26234,12 +26234,12 @@ test('@container', async () => { container-type: inline-size; } - .\\@container-\\[size\\] { - container-type: size; - } - .\\@container-normal { container-type: normal; + } + + .\\@container-size { + container-type: size; }" `) expect( @@ -26248,8 +26248,8 @@ test('@container', async () => { '-@container-normal', '-@container/sidebar', '-@container-normal/sidebar', - '-@container-[size]', - '-@container-[size]/sidebar', + '-@container-size', + '-@container-size/sidebar', ]), ).toEqual('') }) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 9a6906bbd6eb..2cc1fe37e89a 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -12,6 +12,7 @@ import { } from './ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate' import type { DesignSystem } from './design-system' +import { enableContainerSizeUtility } from './feature-flags' import type { Theme, ThemeKey } from './theme' import { compareBreakpoints } from './utils/compare-breakpoints' import { DefaultMap } from './utils/default-map' @@ -5712,6 +5713,12 @@ export function createUtilities(theme: Theme) { value = candidate.value.value } else if (candidate.value.kind === 'named' && candidate.value.value === 'normal') { value = 'normal' + } else if ( + enableContainerSizeUtility && + candidate.value.kind === 'named' && + candidate.value.value === 'size' + ) { + value = 'size' } if (value === null) return From ee1c7a69dc4cbbf8745c9f5495b616fd193a9f19 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Tue, 9 Sep 2025 17:14:09 +0200 Subject: [PATCH 05/39] Fix CLI watcher cleanup race (#18905) This PR supersets #18559 and fixes the same issue reported by @Gazler. Upon testing, we noticed that it's possible that two parallel invocations of file system change events could cause some cleanup functions to get swallowed. This happens because we only remember one global cleanup function but it is possible timing wise that two calls to `createWatcher()` are created before the old watchers are cleaned and thus only one of the new cleanup functions get retained. To fix this, this PR changes `cleanupWatchers` to an array and ensures that all functions are retained. In some local testing, I was able to trigger this, based on the reproduction by @Gazler in https://github.com/tailwindlabs/tailwindcss/pull/18559, to often call a cleanup with more than one cleanup function in the array. I'm going to paste the amazing reproduction from #18559 here as well: # Requirements We need a way to stress the CPU to slow down tailwind compilation, for example stress-ng. ``` stress-ng --cpu 16 --timeout 10 ``` It can be install with apt, homebrew or similar. # Installation There is a one-liner at the bottom to perform the required setup and run the tailwindcli. Create a new directory: ```shell mkdir twtest && cd twtest ``` Create a package.json with the correct deps. ```shell cat << 'EOF' > package.json { "dependencies": { "@tailwindcss/cli": "^4.1.11", "daisyui": "^5.0.46", "tailwindcss": "^4.1.11" } } EOF ``` Create the input css: ```shell mkdir src cat << 'EOF' > src/.input.css @import "tailwindcss" source(none); @plugin "daisyui"; @source "../core_components.ex"; @source "../home.html.heex"; @source "./input.css"; EOF ``` Install tailwind, daisyui, and some HTML to make tailwind do some work: ``` npm install wget https://raw.githubusercontent.com/phoenixframework/phoenix/refs/heads/main/installer/templates/phx_web/components/core_components.ex wget https://github.com/phoenixframework/phoenix/blob/main/installer/templates/phx_web/controllers/page_html/home.html.heex ``` # Usage This is easiest with 3 terminal windows: Start a tailwindcli watcher in one terminal: ```shell npx @tailwindcss/cli -i src/input.css -o src/output.css --watch ``` Start a stress test in another: ```shell stress-ng --cpu 16 --timeout 30 ``` Force repeated compilation in another: ```shell for i in $(seq 1 50); do touch src/input.css; sleep 0.1; done ``` # Result Once the stress test has completed, you can run: ```shell touch src/input.css ``` You should see that there is repeated output, and the duration is in the multiple seconds. If this setup doesn't cause the issue, you can also add the `-p` flag which causes the CSS to be printed, slowing things down further: ```shell npx @tailwindcss/cli -i src/input.css -p --watch ``` ## One-liner ```shell mkdir twtest && cd twtest cat << 'EOF' > package.json { "dependencies": { "@tailwindcss/cli": "^4.1.11", "daisyui": "^5.0.46", "tailwindcss": "^4.1.11" } } EOF mkdir src cat << 'EOF' > src/input.css @import "tailwindcss" source(none); @plugin "daisyui"; @source "../core_components.ex"; @source "../home.html.heex"; @source "./input.css"; EOF npm install wget https://raw.githubusercontent.com/phoenixframework/phoenix/refs/heads/main/installer/templates/phx_web/components/core_components.ex wget https://github.com/phoenixframework/phoenix/blob/main/installer/templates/phx_web/controllers/page_html/home.html.heex npx @tailwindcss/cli -i src/input.css -o src/output.css --watch ``` ## Test plan - Not able to reproduce this with a local build of the CLI after the patch is applied but was able to reproduce it again once the patch was reverted. Co-authored-by: Gary Rennie --- CHANGELOG.md | 1 + .../@tailwindcss-cli/src/commands/build/index.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d41d6da9c70..45f2264e8324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888)) - Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885)) - Merge suggestions when using `@utility` ([#18900](https://github.com/tailwindlabs/tailwindcss/pull/18900)) +- Ensure that file system watchers created when using the CLI are always cleaned up ([#18905](https://github.com/tailwindlabs/tailwindcss/pull/18905)) ## [4.1.13] - 2025-09-03 diff --git a/packages/@tailwindcss-cli/src/commands/build/index.ts b/packages/@tailwindcss-cli/src/commands/build/index.ts index 206bf27fbca2..794b54ae3be6 100644 --- a/packages/@tailwindcss-cli/src/commands/build/index.ts +++ b/packages/@tailwindcss-cli/src/commands/build/index.ts @@ -239,9 +239,9 @@ export async function handle(args: Result>) { // Watch for changes if (args['--watch']) { - let cleanupWatchers = await createWatchers( - watchDirectories(scanner), - async function handle(files) { + let cleanupWatchers: (() => Promise)[] = [] + cleanupWatchers.push( + await createWatchers(watchDirectories(scanner), async function handle(files) { try { // If the only change happened to the output file, then we don't want to // trigger a rebuild because that will result in an infinite loop. @@ -304,15 +304,15 @@ export async function handle(args: Result>) { // Setup new watchers DEBUG && I.start('Setup new watchers') - let newCleanupWatchers = await createWatchers(watchDirectories(scanner), handle) + let newCleanupFunction = await createWatchers(watchDirectories(scanner), handle) DEBUG && I.end('Setup new watchers') // Clear old watchers DEBUG && I.start('Cleanup old watchers') - await cleanupWatchers() + await Promise.all(cleanupWatchers.splice(0).map((cleanup) => cleanup())) DEBUG && I.end('Cleanup old watchers') - cleanupWatchers = newCleanupWatchers + cleanupWatchers.push(newCleanupFunction) // Re-compile the CSS DEBUG && I.start('Build CSS') @@ -362,14 +362,14 @@ export async function handle(args: Result>) { eprintln(err.toString()) } } - }, + }), ) // Abort the watcher if `stdin` is closed to avoid zombie processes. You can // disable this behavior with `--watch=always`. if (args['--watch'] !== 'always') { process.stdin.on('end', () => { - cleanupWatchers().then( + Promise.all(cleanupWatchers.map((fn) => fn())).then( () => process.exit(0), () => process.exit(1), ) From 340b59dcde2401f03f1edecf1c1332190adc9d44 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 10 Sep 2025 15:57:29 +0200 Subject: [PATCH 06/39] Do not generate `grid-column` when configuring `grid-column-start` or `grid-column-end` (#18907) This PR fixes an issue where configuring a custom `--grid-column-start` or `--grid-column-end` also generated a `grid-column` utility due to the overlapping namespace. ```css @theme { --grid-column-start-custom: custom-start; --grid-column-end-custom: custom-end; } ``` Would then generate: ```css .col-end-custom { grid-column: var(--grid-column-end-custom); } .col-start-custom { grid-column: var(--grid-column-start-custom); } .col-start-custom { grid-column-start: var(--grid-column-start-custom); } .col-end-custom { grid-column-end: var(--grid-column-end-custom); } ``` Instead of the expected: ```css .col-start-custom { grid-column-start: var(--grid-column-start-custom); } .col-end-custom { grid-column-end: var(--grid-column-end-custom); } ``` Fixes: #18906 --- CHANGELOG.md | 2 + packages/tailwindcss/src/theme.ts | 2 + packages/tailwindcss/src/utilities.test.ts | 160 +++++++++++++++------ 3 files changed, 124 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f2264e8324..9f7d9be27aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885)) - Merge suggestions when using `@utility` ([#18900](https://github.com/tailwindlabs/tailwindcss/pull/18900)) - Ensure that file system watchers created when using the CLI are always cleaned up ([#18905](https://github.com/tailwindlabs/tailwindcss/pull/18905)) +- Do not generate `grid-column` utilities when configuring `grid-column-start` or `grid-column-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907)) +- Do not generate `grid-row` utilities when configuring `grid-row-start` or `grid-row-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907)) ## [4.1.13] - 2025-09-03 diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index dc9c750a12f8..073fb242f1e0 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -28,6 +28,8 @@ const ignoredThemeKeyMap = new Map([ '--text-underline-offset', ], ], + ['--grid-column', ['--grid-column-start', '--grid-column-end']], + ['--grid-row', ['--grid-row-start', '--grid-row-end']], ]) function isIgnoredThemeKey(themeKey: ThemeKey, namespace: ThemeKey) { diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 32a01fda86eb..5a1fe7596014 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -1180,9 +1180,28 @@ test('col', async () => { test('col-start', async () => { expect( - await run(['col-start-auto', 'col-start-4', 'col-start-99', 'col-start-[123]', '-col-start-4']), + await compileCss( + css` + @theme { + --grid-column-start-custom: 1 column-start; + } + @tailwind utilities; + `, + [ + 'col-start-auto', + 'col-start-4', + 'col-start-99', + 'col-start-[123]', + '-col-start-4', + 'col-start-custom', + ], + ), ).toMatchInlineSnapshot(` - ".-col-start-4 { + ":root, :host { + --grid-column-start-custom: 1 column-start; + } + + .-col-start-4 { grid-column-start: calc(4 * -1); } @@ -1200,6 +1219,10 @@ test('col-start', async () => { .col-start-auto { grid-column-start: auto; + } + + .col-start-custom { + grid-column-start: var(--grid-column-start-custom); }" `) expect( @@ -1217,28 +1240,45 @@ test('col-start', async () => { }) test('col-end', async () => { - expect(await run(['col-end-auto', 'col-end-4', 'col-end-99', 'col-end-[123]', '-col-end-4'])) - .toMatchInlineSnapshot(` - ".-col-end-4 { - grid-column-end: calc(4 * -1); - } + expect( + await compileCss( + css` + @theme { + --grid-column-end-custom: 1 column-end; + } + @tailwind utilities; + `, + ['col-end-auto', 'col-end-4', 'col-end-99', 'col-end-[123]', '-col-end-4', 'col-end-custom'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-column-end-custom: 1 column-end; + } - .col-end-4 { - grid-column-end: 4; - } + .-col-end-4 { + grid-column-end: calc(4 * -1); + } - .col-end-99 { - grid-column-end: 99; - } + .col-end-4 { + grid-column-end: 4; + } - .col-end-\\[123\\] { - grid-column-end: 123; - } + .col-end-99 { + grid-column-end: 99; + } - .col-end-auto { - grid-column-end: auto; - }" - `) + .col-end-\\[123\\] { + grid-column-end: 123; + } + + .col-end-auto { + grid-column-end: auto; + } + + .col-end-custom { + grid-column-end: var(--grid-column-end-custom); + }" + `) expect( await run([ 'col-end', @@ -1317,9 +1357,28 @@ test('row', async () => { test('row-start', async () => { expect( - await run(['row-start-auto', 'row-start-4', 'row-start-99', 'row-start-[123]', '-row-start-4']), + await compileCss( + css` + @theme { + --grid-row-start-custom: 1 row-start; + } + @tailwind utilities; + `, + [ + 'row-start-auto', + 'row-start-4', + 'row-start-99', + 'row-start-[123]', + '-row-start-4', + 'row-start-custom', + ], + ), ).toMatchInlineSnapshot(` - ".-row-start-4 { + ":root, :host { + --grid-row-start-custom: 1 row-start; + } + + .-row-start-4 { grid-row-start: calc(4 * -1); } @@ -1337,6 +1396,10 @@ test('row-start', async () => { .row-start-auto { grid-row-start: auto; + } + + .row-start-custom { + grid-row-start: var(--grid-row-start-custom); }" `) expect( @@ -1354,28 +1417,45 @@ test('row-start', async () => { }) test('row-end', async () => { - expect(await run(['row-end-auto', 'row-end-4', 'row-end-99', 'row-end-[123]', '-row-end-4'])) - .toMatchInlineSnapshot(` - ".-row-end-4 { - grid-row-end: calc(4 * -1); - } + expect( + await compileCss( + css` + @theme { + --grid-row-end-custom: 1 row-end; + } + @tailwind utilities; + `, + ['row-end-auto', 'row-end-4', 'row-end-99', 'row-end-[123]', '-row-end-4', 'row-end-custom'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-row-end-custom: 1 row-end; + } - .row-end-4 { - grid-row-end: 4; - } + .-row-end-4 { + grid-row-end: calc(4 * -1); + } - .row-end-99 { - grid-row-end: 99; - } + .row-end-4 { + grid-row-end: 4; + } - .row-end-\\[123\\] { - grid-row-end: 123; - } + .row-end-99 { + grid-row-end: 99; + } - .row-end-auto { - grid-row-end: auto; - }" - `) + .row-end-\\[123\\] { + grid-row-end: 123; + } + + .row-end-auto { + grid-row-end: auto; + } + + .row-end-custom { + grid-row-end: var(--grid-row-end-custom); + }" + `) expect( await run([ 'row-end', From d1fd645bebb6086772584c326c8cfe793b4a7480 Mon Sep 17 00:00:00 2001 From: Philipp Spiess Date: Thu, 11 Sep 2025 12:21:50 +0200 Subject: [PATCH 07/39] Proposal: Allow overwriting static utilities that have a namespace (#18056) This PR attempts to move static utilities that are overwriteable by a theme value to be a fallback rather than a conflicting implementation. The idea is to allow a theme value to take presedence over that static utility _and cause it not to generate_. For example, when overwriting the `--radius-full` variant, it should ensure that the default `rounded-full` no longer emits the `calc(infinity * 1px)` declaration: ```ts expect( await compileCss( css` @theme { --radius-full: 99999px; } @tailwind utilities; `, ['rounded-full'], ), ).toMatchInlineSnapshot(` ":root, :host { --radius-full: 99999px; } .rounded-full { border-radius: var(--radius-full); }" `) ``` This allows anyone who wants `--radius-full` to be a CSS variable to simply define it in their theme: ```css @theme { /* Make `--radius-full` a CSS variable without the utility generating two CSS classes */ --radius-full: calc(infinity * 1px); } ``` The idea is to extend this pattern across all functional utilities that also have static utilities that can collide with the namespace. This gives users more control over what they want as CSS variables when the defaults don't work for them, allowing them to resolve #16639 and #15115 in user space. You may now find yourself thinking "but Philipp, why would someone want to be able to overwrite `--animate-none`. `none` surely always will mean no animation" and I would agree [but it's already possible right now anyways so this is not a new behavior! This PR just cleans up the generated output.](https://play.tailwindcss.com/StnQqm4V2e) --------- Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + packages/tailwindcss/src/utilities.test.ts | 794 ++++++++++++++++++++- packages/tailwindcss/src/utilities.ts | 340 +++++---- 3 files changed, 972 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f7d9be27aa5..b8a11dcb3d5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure that file system watchers created when using the CLI are always cleaned up ([#18905](https://github.com/tailwindlabs/tailwindcss/pull/18905)) - Do not generate `grid-column` utilities when configuring `grid-column-start` or `grid-column-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907)) - Do not generate `grid-row` utilities when configuring `grid-row-start` or `grid-row-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907)) +- Prevent duplicate CSS when overwriting a static utility with a theme key ([#18056](https://github.com/tailwindlabs/tailwindcss/pull/18056)) ## [4.1.13] - 2025-09-03 diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 5a1fe7596014..c7615708af46 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -1054,6 +1054,26 @@ test('z-index', async () => { '-z-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --z-index-auto: 42; + } + @tailwind utilities; + `, + ['z-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --z-index-auto: 42; + } + + .z-auto { + z-index: var(--z-index-auto); + }" + `) }) test('order', async () => { @@ -1114,6 +1134,46 @@ test('order', async () => { 'order-none/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --order-first: 1; + } + @tailwind utilities; + `, + ['order-first'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --order-first: 1; + } + + .order-first { + order: var(--order-first); + }" + `) + + expect( + await compileCss( + css` + @theme { + --order-last: -1; + } + @tailwind utilities; + `, + ['order-last'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --order-last: -1; + } + + .order-last { + order: var(--order-last); + }" + `) }) test('col', async () => { @@ -1176,6 +1236,26 @@ test('col', async () => { 'col-span-[var(--my-variable)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-column-auto: 5; + } + @tailwind utilities; + `, + ['col-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-column-auto: 5; + } + + .col-auto { + grid-column: var(--grid-column-auto); + }" + `) }) test('col-start', async () => { @@ -1237,6 +1317,26 @@ test('col-start', async () => { '-col-start-4/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-column-start-auto: 7; + } + @tailwind utilities; + `, + ['col-start-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-column-start-auto: 7; + } + + .col-start-auto { + grid-column-start: var(--grid-column-start-auto); + }" + `) }) test('col-end', async () => { @@ -1291,6 +1391,26 @@ test('col-end', async () => { '-col-end-4/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-column-end-auto: 3; + } + @tailwind utilities; + `, + ['col-end-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-column-end-auto: 3; + } + + .col-end-auto { + grid-column-end: var(--grid-column-end-auto); + }" + `) }) test('row', async () => { @@ -1353,6 +1473,26 @@ test('row', async () => { 'row-span-[var(--my-variable)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-row-auto: 9; + } + @tailwind utilities; + `, + ['row-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-row-auto: 9; + } + + .row-auto { + grid-row: var(--grid-row-auto); + }" + `) }) test('row-start', async () => { @@ -1414,6 +1554,26 @@ test('row-start', async () => { '-row-start-4/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-row-start-auto: 11; + } + @tailwind utilities; + `, + ['row-start-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-row-start-auto: 11; + } + + .row-start-auto { + grid-row-start: var(--grid-row-start-auto); + }" + `) }) test('row-end', async () => { @@ -1468,6 +1628,26 @@ test('row-end', async () => { '-row-end-4/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-row-end-auto: 13; + } + @tailwind utilities; + `, + ['row-end-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-row-end-auto: 13; + } + + .row-end-auto { + grid-row-end: var(--grid-row-end-auto); + }" + `) }) test('float', async () => { @@ -2422,6 +2602,29 @@ test('line-clamp', async () => { 'line-clamp-none/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --line-clamp-none: 0; + } + @tailwind utilities; + `, + ['line-clamp-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --line-clamp-none: 0; + } + + .line-clamp-none { + -webkit-line-clamp: var(--line-clamp-none); + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + }" + `) }) test('display', async () => { @@ -3980,6 +4183,26 @@ test('origin', async () => { 'origin-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --transform-origin-top: 10px 20px; + } + @tailwind utilities; + `, + ['origin-top'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --transform-origin-top: 10px 20px; + } + + .origin-top { + transform-origin: var(--transform-origin-top); + }" + `) }) test('perspective-origin', async () => { @@ -4059,6 +4282,27 @@ test('perspective-origin', async () => { 'perspective-origin-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --perspective-origin-top: 10px 20px; + } + @tailwind utilities; + `, + ['perspective-origin-top'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --perspective-origin-top: 10px 20px; + } + + .perspective-origin-top { + perspective-origin: var(--perspective-origin-top); + perspective: var(--perspective-origin-top); + }" + `) }) test('translate', async () => { @@ -5560,6 +5804,26 @@ test('perspective', async () => { 'perspective-[456px]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --perspective-none: 400px; + } + @tailwind utilities; + `, + ['perspective-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --perspective-none: 400px; + } + + .perspective-none { + perspective: var(--perspective-none); + }" + `) }) test('cursor', async () => { @@ -6937,6 +7201,26 @@ test('list', async () => { 'list-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --list-style-type-none: disc; + } + @tailwind utilities; + `, + ['list-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --list-style-type-none: disc; + } + + .list-none { + list-style-type: var(--list-style-type-none); + }" + `) }) test('list-image', async () => { @@ -6958,6 +7242,26 @@ test('list-image', async () => { 'list-image-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --list-style-image-none: url(../foo.png); + } + @tailwind utilities; + `, + ['list-image-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --list-style-image-none: url("../foo.png"); + } + + .list-image-none { + list-style-image: var(--list-style-image-none); + }" + `) }) test('appearance', async () => { @@ -7099,6 +7403,26 @@ test('columns', async () => { 'columns-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --columns-auto: 3; + } + @tailwind utilities; + `, + ['columns-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --columns-auto: 3; + } + + .columns-auto { + columns: var(--columns-auto); + }" + `) }) test('break-before', async () => { @@ -7319,9 +7643,29 @@ test('auto-cols', async () => { 'auto-cols-[2fr]/foo', ]), ).toEqual('') -}) -test('grid-flow', async () => { + expect( + await compileCss( + css` + @theme { + --grid-auto-columns-auto: 2fr; + } + @tailwind utilities; + `, + ['auto-cols-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-auto-columns-auto: 2fr; + } + + .auto-cols-auto { + grid-auto-columns: var(--grid-auto-columns-auto); + }" + `) +}) + +test('grid-flow', async () => { expect( await run([ 'grid-flow-row', @@ -7410,6 +7754,26 @@ test('auto-rows', async () => { 'auto-rows-[2fr]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-auto-rows-auto: 2fr; + } + @tailwind utilities; + `, + ['auto-rows-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-auto-rows-auto: 2fr; + } + + .auto-rows-auto { + grid-auto-rows: var(--grid-auto-rows-auto); + }" + `) }) test('grid-cols', async () => { @@ -7459,6 +7823,26 @@ test('grid-cols', async () => { 'grid-cols-[123]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-template-columns-none: 200px 1fr; + } + @tailwind utilities; + `, + ['grid-cols-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-template-columns-none: 200px 1fr; + } + + .grid-cols-none { + grid-template-columns: var(--grid-template-columns-none); + }" + `) }) test('grid-rows', async () => { @@ -7508,6 +7892,26 @@ test('grid-rows', async () => { 'grid-rows-[123]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --grid-template-rows-none: 200px 1fr; + } + @tailwind utilities; + `, + ['grid-rows-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --grid-template-rows-none: 200px 1fr; + } + + .grid-rows-none { + grid-template-rows: var(--grid-template-rows-none); + }" + `) }) test('flex-direction', async () => { @@ -9770,6 +10174,25 @@ test('rounded', async () => { border-radius: var(--radius-sm); }" `) + expect( + await compileCss( + css` + @theme { + --radius-full: 99999px; + } + @tailwind utilities; + `, + ['rounded-full'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --radius-full: 99999px; + } + + .rounded-full { + border-radius: var(--radius-full); + }" + `) expect( await run([ '-rounded', @@ -9945,15 +10368,11 @@ test('rounded-t', async () => { } .rounded-t-full { - border-top-left-radius: 3.40282e38px; - border-top-right-radius: 3.40282e38px; border-top-left-radius: var(--radius-full); border-top-right-radius: var(--radius-full); } .rounded-t-none { - border-top-left-radius: 0; - border-top-right-radius: 0; border-top-left-radius: var(--radius-none); border-top-right-radius: var(--radius-none); } @@ -10012,15 +10431,11 @@ test('rounded-r', async () => { } .rounded-r-full { - border-top-right-radius: 3.40282e38px; - border-bottom-right-radius: 3.40282e38px; border-top-right-radius: var(--radius-full); border-bottom-right-radius: var(--radius-full); } .rounded-r-none { - border-top-right-radius: 0; - border-bottom-right-radius: 0; border-top-right-radius: var(--radius-none); border-bottom-right-radius: var(--radius-none); } @@ -10079,15 +10494,11 @@ test('rounded-b', async () => { } .rounded-b-full { - border-bottom-right-radius: 3.40282e38px; - border-bottom-left-radius: 3.40282e38px; border-bottom-right-radius: var(--radius-full); border-bottom-left-radius: var(--radius-full); } .rounded-b-none { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; border-bottom-right-radius: var(--radius-none); border-bottom-left-radius: var(--radius-none); } @@ -10146,15 +10557,11 @@ test('rounded-l', async () => { } .rounded-l-full { - border-top-left-radius: 3.40282e38px; - border-bottom-left-radius: 3.40282e38px; border-top-left-radius: var(--radius-full); border-bottom-left-radius: var(--radius-full); } .rounded-l-none { - border-top-left-radius: 0; - border-bottom-left-radius: 0; border-top-left-radius: var(--radius-none); border-bottom-left-radius: var(--radius-none); } @@ -10443,12 +10850,10 @@ test('rounded-tl', async () => { } .rounded-tl-full { - border-top-left-radius: 3.40282e38px; border-top-left-radius: var(--radius-full); } .rounded-tl-none { - border-top-left-radius: 0; border-top-left-radius: var(--radius-none); } @@ -10503,12 +10908,10 @@ test('rounded-tr', async () => { } .rounded-tr-full { - border-top-right-radius: 3.40282e38px; border-top-right-radius: var(--radius-full); } .rounded-tr-none { - border-top-right-radius: 0; border-top-right-radius: var(--radius-none); } @@ -10563,12 +10966,10 @@ test('rounded-br', async () => { } .rounded-br-full { - border-bottom-right-radius: 3.40282e38px; border-bottom-right-radius: var(--radius-full); } .rounded-br-none { - border-bottom-right-radius: 0; border-bottom-right-radius: var(--radius-none); } @@ -10623,12 +11024,10 @@ test('rounded-bl', async () => { } .rounded-bl-full { - border-bottom-left-radius: 3.40282e38px; border-bottom-left-radius: var(--radius-full); } .rounded-bl-none { - border-bottom-left-radius: 0; border-bottom-left-radius: var(--radius-none); } @@ -20025,6 +20424,26 @@ test('object', async () => { 'object-top/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --object-position-center: top left; + } + @tailwind utilities; + `, + ['object-center'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --object-position-center: top left; + } + + .object-center { + object-position: var(--object-position-center); + }" + `) }) test('p', async () => { @@ -21327,6 +21746,26 @@ test('animate', async () => { 'animate-not-found/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --animate-none: bounce 1s infinite; + } + @tailwind utilities; + `, + ['animate-none'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --animate-none: bounce 1s infinite; + } + + .animate-none { + animation: var(--animate-none); + }" + `) }) test('filter', async () => { @@ -21756,6 +22195,113 @@ test('filter', async () => { 'sepia-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --blur-none: 2px; + } + @tailwind utilities; + `, + ['blur-none'], + ), + ).toMatchInlineSnapshot(` + "@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + } + } + } + + :root, :host { + --blur-none: 2px; + } + + .blur-none { + --tw-blur: blur(var(--blur-none)); + filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, ); + } + + @property --tw-blur { + syntax: "*"; + inherits: false + } + + @property --tw-brightness { + syntax: "*"; + inherits: false + } + + @property --tw-contrast { + syntax: "*"; + inherits: false + } + + @property --tw-grayscale { + syntax: "*"; + inherits: false + } + + @property --tw-hue-rotate { + syntax: "*"; + inherits: false + } + + @property --tw-invert { + syntax: "*"; + inherits: false + } + + @property --tw-opacity { + syntax: "*"; + inherits: false + } + + @property --tw-saturate { + syntax: "*"; + inherits: false + } + + @property --tw-sepia { + syntax: "*"; + inherits: false + } + + @property --tw-drop-shadow { + syntax: "*"; + inherits: false + } + + @property --tw-drop-shadow-color { + syntax: "*"; + inherits: false + } + + @property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; + } + + @property --tw-drop-shadow-size { + syntax: "*"; + inherits: false + }" + `) }) test('backdrop-filter', async () => { @@ -22136,6 +22682,89 @@ test('backdrop-filter', async () => { 'backdrop-sepia-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --backdrop-blur-none: 2px; + } + @tailwind utilities; + `, + ['backdrop-blur-none'], + ), + ).toMatchInlineSnapshot(` + "@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + } + } + } + + :root, :host { + --backdrop-blur-none: 2px; + } + + .backdrop-blur-none { + --tw-backdrop-blur: blur(var(--backdrop-blur-none)); + -webkit-backdrop-filter: var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, ); + backdrop-filter: var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, ); + } + + @property --tw-backdrop-blur { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-brightness { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-contrast { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-invert { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-opacity { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-saturate { + syntax: "*"; + inherits: false + } + + @property --tw-backdrop-sepia { + syntax: "*"; + inherits: false + }" + `) }) test('transition', async () => { @@ -22195,9 +22824,6 @@ test('transition', async () => { } .transition-opacity { - transition-property: opacity; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); transition-property: var(--transition-property-opacity); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); @@ -22283,6 +22909,28 @@ test('transition', async () => { 'transition-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --transition-property-colors: transform; + } + @tailwind utilities; + `, + ['transition-colors'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --transition-property-colors: transform; + } + + .transition-colors { + transition-property: var(--transition-property-colors); + transition-timing-function: var(--tw-ease, ease); + transition-duration: var(--tw-duration, 0s); + }" + `) }) test('transition-behavior', async () => { @@ -22426,6 +23074,40 @@ test('ease', async () => { 'ease-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --ease-linear: steps(4); + } + @tailwind utilities; + `, + ['ease-linear'], + ), + ).toMatchInlineSnapshot(` + "@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-ease: initial; + } + } + } + + :root, :host { + --ease-linear: steps(4); + } + + .ease-linear { + --tw-ease: var(--ease-linear); + transition-timing-function: var(--ease-linear); + } + + @property --tw-ease { + syntax: "*"; + inherits: false + }" + `) }) test('will-change', async () => { @@ -22683,6 +23365,40 @@ test('leading', async () => { 'leading-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --leading-none: 2; + } + @tailwind utilities; + `, + ['leading-none'], + ), + ).toMatchInlineSnapshot(` + "@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, :before, :after, ::backdrop { + --tw-leading: initial; + } + } + } + + :root, :host { + --leading-none: 2; + } + + .leading-none { + --tw-leading: var(--leading-none); + line-height: var(--leading-none); + } + + @property --tw-leading { + syntax: "*"; + inherits: false + }" + `) }) test('tracking', async () => { @@ -23410,6 +24126,26 @@ test('underline-offset', async () => { '-underline-offset-[var(--value)]/foo', ]), ).toEqual('') + + expect( + await compileCss( + css` + @theme { + --text-underline-offset-auto: 4px; + } + @tailwind utilities; + `, + ['underline-offset-auto'], + ), + ).toMatchInlineSnapshot(` + ":root, :host { + --text-underline-offset-auto: 4px; + } + + .underline-offset-auto { + text-underline-offset: var(--text-underline-offset-auto); + }" + `) }) test('text', async () => { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 2cc1fe37e89a..efe7b3dc65ad 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -375,6 +375,7 @@ export function createUtilities(theme: Theme) { supportsFractions?: boolean themeKeys?: ThemeKey[] defaultValue?: string | null + staticValues?: Record handleBareValue?: (value: NamedUtilityValue) => string | null handleNegativeBareValue?: (value: NamedUtilityValue) => string | null handle: (value: string, dataType: string | null) => AstNode[] | undefined @@ -431,6 +432,11 @@ export function createUtilities(theme: Theme) { value = desc.handleBareValue(candidate.value) if (!value?.includes('/') && candidate.modifier) return } + + if (value === null && !negative && desc.staticValues && !candidate.modifier) { + let fallback = desc.staticValues[candidate.value.value] + if (fallback) return fallback + } } // If there is no value, don't generate any rules. @@ -454,6 +460,13 @@ export function createUtilities(theme: Theme) { supportsFractions: desc.supportsFractions, }, ]) + + // Also suggest any staticValues automatically so callers don't need to + // manually add suggestion groups for e.g. `auto`, `none`, `full`, etc. + if (desc.staticValues && Object.keys(desc.staticValues).length > 0) { + let values = Object.keys(desc.staticValues) + suggest(classRoot, () => [{ values }]) + } } type ColorUtilityDescription = { @@ -506,9 +519,11 @@ export function createUtilities(theme: Theme) { { supportsNegative = false, supportsFractions = false, + staticValues, }: { supportsNegative?: boolean supportsFractions?: boolean + staticValues?: Record } = {}, ) { if (supportsNegative) { @@ -535,6 +550,7 @@ export function createUtilities(theme: Theme) { return `calc(${multiplier} * -${value})` }, handle, + staticValues, }) suggest(name, () => [ @@ -629,7 +645,6 @@ export function createUtilities(theme: Theme) { /** * @css `z-index` */ - staticUtility('z-auto', [['z-index', 'auto']]) functionalUtility('z', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -638,6 +653,9 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--z-index'], handle: (value) => [decl('z-index', value)], + staticValues: { + auto: [decl('z-index', 'auto')], + }, }) suggest('z', () => [ @@ -651,8 +669,6 @@ export function createUtilities(theme: Theme) { /** * @css `order` */ - staticUtility('order-first', [['order', '-9999']]) - staticUtility('order-last', [['order', '9999']]) functionalUtility('order', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -661,6 +677,10 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--order'], handle: (value) => [decl('order', value)], + staticValues: { + first: [decl('order', '-9999')], + last: [decl('order', '9999')], + }, }) suggest('order', () => [ @@ -674,7 +694,6 @@ export function createUtilities(theme: Theme) { /** * @css `grid-column` */ - staticUtility('col-auto', [['grid-column', 'auto']]) functionalUtility('col', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -683,20 +702,25 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--grid-column'], handle: (value) => [decl('grid-column', value)], + staticValues: { + auto: [decl('grid-column', 'auto')], + }, }) - staticUtility('col-span-full', [['grid-column', '1 / -1']]) + functionalUtility('col-span', { handleBareValue: ({ value }) => { if (!isPositiveInteger(value)) return null return value }, handle: (value) => [decl('grid-column', `span ${value} / span ${value}`)], + staticValues: { + full: [decl('grid-column', '1 / -1')], + }, }) /** * @css `grid-column-start` */ - staticUtility('col-start-auto', [['grid-column-start', 'auto']]) functionalUtility('col-start', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -705,12 +729,14 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--grid-column-start'], handle: (value) => [decl('grid-column-start', value)], + staticValues: { + auto: [decl('grid-column-start', 'auto')], + }, }) /** * @css `grid-column-end` */ - staticUtility('col-end-auto', [['grid-column-end', 'auto']]) functionalUtility('col-end', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -719,6 +745,9 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--grid-column-end'], handle: (value) => [decl('grid-column-end', value)], + staticValues: { + auto: [decl('grid-column-end', 'auto')], + }, }) suggest('col-span', () => [ @@ -747,7 +776,6 @@ export function createUtilities(theme: Theme) { /** * @css `grid-row` */ - staticUtility('row-auto', [['grid-row', 'auto']]) functionalUtility('row', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -756,8 +784,11 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--grid-row'], handle: (value) => [decl('grid-row', value)], + staticValues: { + auto: [decl('grid-row', 'auto')], + }, }) - staticUtility('row-span-full', [['grid-row', '1 / -1']]) + functionalUtility('row-span', { themeKeys: [], handleBareValue: ({ value }) => { @@ -765,12 +796,14 @@ export function createUtilities(theme: Theme) { return value }, handle: (value) => [decl('grid-row', `span ${value} / span ${value}`)], + staticValues: { + full: [decl('grid-row', '1 / -1')], + }, }) /** * @css `grid-row-start` */ - staticUtility('row-start-auto', [['grid-row-start', 'auto']]) functionalUtility('row-start', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -779,12 +812,14 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--grid-row-start'], handle: (value) => [decl('grid-row-start', value)], + staticValues: { + auto: [decl('grid-row-start', 'auto')], + }, }) /** * @css `grid-row-end` */ - staticUtility('row-end-auto', [['grid-row-end', 'auto']]) functionalUtility('row-end', { supportsNegative: true, handleBareValue: ({ value }) => { @@ -793,6 +828,9 @@ export function createUtilities(theme: Theme) { }, themeKeys: ['--grid-row-end'], handle: (value) => [decl('grid-row-end', value)], + staticValues: { + auto: [decl('grid-row-end', 'auto')], + }, }) suggest('row-span', () => [ @@ -866,12 +904,6 @@ export function createUtilities(theme: Theme) { /** * @css `line-clamp` */ - staticUtility('line-clamp-none', [ - ['overflow', 'visible'], - ['display', 'block'], - ['-webkit-box-orient', 'horizontal'], - ['-webkit-line-clamp', 'unset'], - ]) functionalUtility('line-clamp', { themeKeys: ['--line-clamp'], handleBareValue: ({ value }) => { @@ -884,6 +916,14 @@ export function createUtilities(theme: Theme) { decl('-webkit-box-orient', 'vertical'), decl('-webkit-line-clamp', value), ], + staticValues: { + none: [ + decl('overflow', 'visible'), + decl('display', 'block'), + decl('-webkit-box-orient', 'horizontal'), + decl('-webkit-line-clamp', 'unset'), + ], + }, }) suggest('line-clamp', () => [ @@ -928,8 +968,6 @@ export function createUtilities(theme: Theme) { /** * @css `aspect-ratio` */ - staticUtility('aspect-auto', [['aspect-ratio', 'auto']]) - staticUtility('aspect-square', [['aspect-ratio', '1 / 1']]) functionalUtility('aspect', { themeKeys: ['--aspect'], handleBareValue: ({ fraction }) => { @@ -939,6 +977,10 @@ export function createUtilities(theme: Theme) { return fraction }, handle: (value) => [decl('aspect-ratio', value)], + staticValues: { + auto: [decl('aspect-ratio', 'auto')], + square: [decl('aspect-ratio', '1 / 1')], + }, }) /** @@ -1171,41 +1213,47 @@ export function createUtilities(theme: Theme) { /** * @css `transform-origin` */ - staticUtility('origin-center', [['transform-origin', 'center']]) - staticUtility('origin-top', [['transform-origin', 'top']]) - staticUtility('origin-top-right', [['transform-origin', 'top right']]) - staticUtility('origin-right', [['transform-origin', 'right']]) - staticUtility('origin-bottom-right', [['transform-origin', 'bottom right']]) - staticUtility('origin-bottom', [['transform-origin', 'bottom']]) - staticUtility('origin-bottom-left', [['transform-origin', 'bottom left']]) - staticUtility('origin-left', [['transform-origin', 'left']]) - staticUtility('origin-top-left', [['transform-origin', 'top left']]) functionalUtility('origin', { themeKeys: ['--transform-origin'], handle: (value) => [decl('transform-origin', value)], + staticValues: { + center: [decl('transform-origin', 'center')], + top: [decl('transform-origin', 'top')], + 'top-right': [decl('transform-origin', '100% 0')], + right: [decl('transform-origin', '100%')], + 'bottom-right': [decl('transform-origin', '100% 100%')], + bottom: [decl('transform-origin', 'bottom')], + 'bottom-left': [decl('transform-origin', '0 100%')], + left: [decl('transform-origin', '0')], + 'top-left': [decl('transform-origin', '0 0')], + }, }) - staticUtility('perspective-origin-center', [['perspective-origin', 'center']]) - staticUtility('perspective-origin-top', [['perspective-origin', 'top']]) - staticUtility('perspective-origin-top-right', [['perspective-origin', 'top right']]) - staticUtility('perspective-origin-right', [['perspective-origin', 'right']]) - staticUtility('perspective-origin-bottom-right', [['perspective-origin', 'bottom right']]) - staticUtility('perspective-origin-bottom', [['perspective-origin', 'bottom']]) - staticUtility('perspective-origin-bottom-left', [['perspective-origin', 'bottom left']]) - staticUtility('perspective-origin-left', [['perspective-origin', 'left']]) - staticUtility('perspective-origin-top-left', [['perspective-origin', 'top left']]) functionalUtility('perspective-origin', { themeKeys: ['--perspective-origin'], handle: (value) => [decl('perspective-origin', value)], + staticValues: { + center: [decl('perspective-origin', 'center')], + top: [decl('perspective-origin', 'top')], + 'top-right': [decl('perspective-origin', '100% 0')], + right: [decl('perspective-origin', '100%')], + 'bottom-right': [decl('perspective-origin', '100% 100%')], + bottom: [decl('perspective-origin', 'bottom')], + 'bottom-left': [decl('perspective-origin', '0 100%')], + left: [decl('perspective-origin', '0')], + 'top-left': [decl('perspective-origin', '0 0')], + }, }) /** * @css `perspective` */ - staticUtility('perspective-none', [['perspective', 'none']]) functionalUtility('perspective', { themeKeys: ['--perspective'], handle: (value) => [decl('perspective', value)], + staticValues: { + none: [decl('perspective', 'none')], + }, }) let translateProperties = () => @@ -1757,23 +1805,24 @@ export function createUtilities(theme: Theme) { staticUtility('list-inside', [['list-style-position', 'inside']]) staticUtility('list-outside', [['list-style-position', 'outside']]) - /** - * @css `list-style-type` - */ - staticUtility('list-none', [['list-style-type', 'none']]) - staticUtility('list-disc', [['list-style-type', 'disc']]) - staticUtility('list-decimal', [['list-style-type', 'decimal']]) functionalUtility('list', { themeKeys: ['--list-style-type'], handle: (value) => [decl('list-style-type', value)], + staticValues: { + none: [decl('list-style-type', 'none')], + disc: [decl('list-style-type', 'disc')], + decimal: [decl('list-style-type', 'decimal')], + }, }) // list-image-* - staticUtility('list-image-none', [['list-style-image', 'none']]) functionalUtility('list-image', { themeKeys: ['--list-style-image'], handle: (value) => [decl('list-style-image', value)], + staticValues: { + none: [decl('list-style-image', 'none')], + }, }) staticUtility('appearance-none', [['appearance', 'none']]) @@ -1786,9 +1835,6 @@ export function createUtilities(theme: Theme) { staticUtility('scheme-only-dark', [['color-scheme', 'only dark']]) staticUtility('scheme-only-light', [['color-scheme', 'only light']]) - // columns-* - staticUtility('columns-auto', [['columns', 'auto']]) - functionalUtility('columns', { themeKeys: ['--columns', '--container'], handleBareValue: ({ value }) => { @@ -1796,6 +1842,9 @@ export function createUtilities(theme: Theme) { return value }, handle: (value) => [decl('columns', value)], + staticValues: { + auto: [decl('columns', 'auto')], + }, }) suggest('columns', () => [ @@ -1823,26 +1872,28 @@ export function createUtilities(theme: Theme) { staticUtility('grid-flow-row-dense', [['grid-auto-flow', 'row dense']]) staticUtility('grid-flow-col-dense', [['grid-auto-flow', 'column dense']]) - staticUtility('auto-cols-auto', [['grid-auto-columns', 'auto']]) - staticUtility('auto-cols-min', [['grid-auto-columns', 'min-content']]) - staticUtility('auto-cols-max', [['grid-auto-columns', 'max-content']]) - staticUtility('auto-cols-fr', [['grid-auto-columns', 'minmax(0, 1fr)']]) functionalUtility('auto-cols', { themeKeys: ['--grid-auto-columns'], handle: (value) => [decl('grid-auto-columns', value)], + staticValues: { + auto: [decl('grid-auto-columns', 'auto')], + min: [decl('grid-auto-columns', 'min-content')], + max: [decl('grid-auto-columns', 'max-content')], + fr: [decl('grid-auto-columns', 'minmax(0, 1fr)')], + }, }) - staticUtility('auto-rows-auto', [['grid-auto-rows', 'auto']]) - staticUtility('auto-rows-min', [['grid-auto-rows', 'min-content']]) - staticUtility('auto-rows-max', [['grid-auto-rows', 'max-content']]) - staticUtility('auto-rows-fr', [['grid-auto-rows', 'minmax(0, 1fr)']]) functionalUtility('auto-rows', { themeKeys: ['--grid-auto-rows'], handle: (value) => [decl('grid-auto-rows', value)], + staticValues: { + auto: [decl('grid-auto-rows', 'auto')], + min: [decl('grid-auto-rows', 'min-content')], + max: [decl('grid-auto-rows', 'max-content')], + fr: [decl('grid-auto-rows', 'minmax(0, 1fr)')], + }, }) - staticUtility('grid-cols-none', [['grid-template-columns', 'none']]) - staticUtility('grid-cols-subgrid', [['grid-template-columns', 'subgrid']]) functionalUtility('grid-cols', { themeKeys: ['--grid-template-columns'], handleBareValue: ({ value }) => { @@ -1850,10 +1901,12 @@ export function createUtilities(theme: Theme) { return `repeat(${value}, minmax(0, 1fr))` }, handle: (value) => [decl('grid-template-columns', value)], + staticValues: { + none: [decl('grid-template-columns', 'none')], + subgrid: [decl('grid-template-columns', 'subgrid')], + }, }) - staticUtility('grid-rows-none', [['grid-template-rows', 'none']]) - staticUtility('grid-rows-subgrid', [['grid-template-rows', 'subgrid']]) functionalUtility('grid-rows', { themeKeys: ['--grid-template-rows'], handleBareValue: ({ value }) => { @@ -1861,6 +1914,10 @@ export function createUtilities(theme: Theme) { return `repeat(${value}, minmax(0, 1fr))` }, handle: (value) => [decl('grid-template-rows', value)], + staticValues: { + none: [decl('grid-template-rows', 'none')], + subgrid: [decl('grid-template-rows', 'subgrid')], + }, }) suggest('grid-cols', () => [ @@ -2125,17 +2182,13 @@ export function createUtilities(theme: Theme) { ['rounded-br', ['border-bottom-right-radius']], ['rounded-bl', ['border-bottom-left-radius']], ] as const) { - staticUtility( - `${root}-none`, - properties.map((property) => [property, '0']), - ) - staticUtility( - `${root}-full`, - properties.map((property) => [property, 'calc(infinity * 1px)']), - ) functionalUtility(root, { themeKeys: ['--radius'], handle: (value) => properties.map((property) => decl(property, value)), + staticValues: { + none: properties.map((property) => decl(property, '0')), + full: properties.map((property) => decl(property, 'calc(infinity * 1px)')), + }, }) } } @@ -3628,18 +3681,20 @@ export function createUtilities(theme: Theme) { staticUtility('object-none', [['object-fit', 'none']]) staticUtility('object-scale-down', [['object-fit', 'scale-down']]) - staticUtility('object-top', [['object-position', 'top']]) - staticUtility('object-top-left', [['object-position', 'left top']]) - staticUtility('object-top-right', [['object-position', 'right top']]) - staticUtility('object-bottom', [['object-position', 'bottom']]) - staticUtility('object-bottom-left', [['object-position', 'left bottom']]) - staticUtility('object-bottom-right', [['object-position', 'right bottom']]) - staticUtility('object-left', [['object-position', 'left']]) - staticUtility('object-right', [['object-position', 'right']]) - staticUtility('object-center', [['object-position', 'center']]) functionalUtility('object', { themeKeys: ['--object-position'], handle: (value) => [decl('object-position', value)], + staticValues: { + top: [decl('object-position', 'top')], + 'top-left': [decl('object-position', 'left top')], + 'top-right': [decl('object-position', 'right top')], + bottom: [decl('object-position', 'bottom')], + 'bottom-left': [decl('object-position', 'left bottom')], + 'bottom-right': [decl('object-position', 'right bottom')], + left: [decl('object-position', 'left')], + right: [decl('object-position', 'right')], + center: [decl('object-position', 'center')], + }, }) for (let [name, property] of [ @@ -3867,10 +3922,12 @@ export function createUtilities(theme: Theme) { }, ]) - staticUtility('animate-none', [['animation', 'none']]) functionalUtility('animate', { themeKeys: ['--animate'], handle: (value) => [decl('animation', value)], + staticValues: { + none: [decl('animation', 'none')], + }, }) { @@ -3978,10 +4035,11 @@ export function createUtilities(theme: Theme) { decl('--tw-blur', `blur(${value})`), decl('filter', cssFilterValue), ], + staticValues: { + none: [filterProperties(), decl('--tw-blur', ' '), decl('filter', cssFilterValue)], + }, }) - staticUtility('blur-none', [filterProperties, ['--tw-blur', ' '], ['filter', cssFilterValue]]) - functionalUtility('backdrop-blur', { themeKeys: ['--backdrop-blur', '--blur'], handle: (value) => [ @@ -3990,15 +4048,16 @@ export function createUtilities(theme: Theme) { decl('-webkit-backdrop-filter', cssBackdropFilterValue), decl('backdrop-filter', cssBackdropFilterValue), ], + staticValues: { + none: [ + backdropFilterProperties(), + decl('--tw-backdrop-blur', ' '), + decl('-webkit-backdrop-filter', cssBackdropFilterValue), + decl('backdrop-filter', cssBackdropFilterValue), + ], + }, }) - staticUtility('backdrop-blur-none', [ - backdropFilterProperties, - ['--tw-backdrop-blur', ' '], - ['-webkit-backdrop-filter', cssBackdropFilterValue], - ['backdrop-filter', cssBackdropFilterValue], - ]) - functionalUtility('brightness', { themeKeys: ['--brightness'], handleBareValue: ({ value }) => { @@ -4476,36 +4535,6 @@ export function createUtilities(theme: Theme) { let defaultTimingFunction = `var(--tw-ease, ${theme.resolve(null, ['--default-transition-timing-function']) ?? 'ease'})` let defaultDuration = `var(--tw-duration, ${theme.resolve(null, ['--default-transition-duration']) ?? '0s'})` - staticUtility('transition-none', [['transition-property', 'none']]) - staticUtility('transition-all', [ - ['transition-property', 'all'], - ['transition-timing-function', defaultTimingFunction], - ['transition-duration', defaultDuration], - ]) - staticUtility('transition-colors', [ - [ - 'transition-property', - 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to', - ], - ['transition-timing-function', defaultTimingFunction], - ['transition-duration', defaultDuration], - ]) - staticUtility('transition-opacity', [ - ['transition-property', 'opacity'], - ['transition-timing-function', defaultTimingFunction], - ['transition-duration', defaultDuration], - ]) - staticUtility('transition-shadow', [ - ['transition-property', 'box-shadow'], - ['transition-timing-function', defaultTimingFunction], - ['transition-duration', defaultDuration], - ]) - staticUtility('transition-transform', [ - ['transition-property', 'transform, translate, scale, rotate'], - ['transition-timing-function', defaultTimingFunction], - ['transition-duration', defaultDuration], - ]) - functionalUtility('transition', { defaultValue: 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events', @@ -4515,6 +4544,37 @@ export function createUtilities(theme: Theme) { decl('transition-timing-function', defaultTimingFunction), decl('transition-duration', defaultDuration), ], + staticValues: { + none: [decl('transition-property', 'none')], + all: [ + decl('transition-property', 'all'), + decl('transition-timing-function', defaultTimingFunction), + decl('transition-duration', defaultDuration), + ], + colors: [ + decl( + 'transition-property', + 'color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to', + ), + decl('transition-timing-function', defaultTimingFunction), + decl('transition-duration', defaultDuration), + ], + opacity: [ + decl('transition-property', 'opacity'), + decl('transition-timing-function', defaultTimingFunction), + decl('transition-duration', defaultDuration), + ], + shadow: [ + decl('transition-property', 'box-shadow'), + decl('transition-timing-function', defaultTimingFunction), + decl('transition-duration', defaultDuration), + ], + transform: [ + decl('transition-property', 'transform, translate, scale, rotate'), + decl('transition-timing-function', defaultTimingFunction), + decl('transition-duration', defaultDuration), + ], + }, }) staticUtility('transition-discrete', [['transition-behavior', 'allow-discrete']]) @@ -4590,12 +4650,6 @@ export function createUtilities(theme: Theme) { return atRoot([property('--tw-ease')]) } - staticUtility('ease-initial', [transitionTimingFunctionProperty, ['--tw-ease', 'initial']]) - staticUtility('ease-linear', [ - transitionTimingFunctionProperty, - ['--tw-ease', 'linear'], - ['transition-timing-function', 'linear'], - ]) functionalUtility('ease', { themeKeys: ['--ease'], handle: (value) => [ @@ -4603,6 +4657,14 @@ export function createUtilities(theme: Theme) { decl('--tw-ease', value), decl('transition-timing-function', value), ], + staticValues: { + initial: [transitionTimingFunctionProperty(), decl('--tw-ease', 'initial')], + linear: [ + transitionTimingFunctionProperty(), + decl('--tw-ease', 'linear'), + decl('transition-timing-function', 'linear'), + ], + }, }) } @@ -4683,16 +4745,24 @@ export function createUtilities(theme: Theme) { staticUtility('forced-color-adjust-none', [['forced-color-adjust', 'none']]) staticUtility('forced-color-adjust-auto', [['forced-color-adjust', 'auto']]) - staticUtility('leading-none', [ - () => atRoot([property('--tw-leading')]), - ['--tw-leading', '1'], - ['line-height', '1'], - ]) - spacingUtility('leading', ['--leading', '--spacing'], (value) => [ - atRoot([property('--tw-leading')]), - decl('--tw-leading', value), - decl('line-height', value), - ]) + spacingUtility( + 'leading', + ['--leading', '--spacing'], + (value) => [ + atRoot([property('--tw-leading')]), + decl('--tw-leading', value), + decl('line-height', value), + ], + { + staticValues: { + none: [ + atRoot([property('--tw-leading')]), + decl('--tw-leading', '1'), + decl('line-height', '1'), + ], + }, + }, + ) functionalUtility('tracking', { supportsNegative: true, @@ -4931,7 +5001,6 @@ export function createUtilities(theme: Theme) { }, ]) - staticUtility('underline-offset-auto', [['text-underline-offset', 'auto']]) functionalUtility('underline-offset', { supportsNegative: true, themeKeys: ['--text-underline-offset'], @@ -4940,6 +5009,9 @@ export function createUtilities(theme: Theme) { return `${value}px` }, handle: (value) => [decl('text-underline-offset', value)], + staticValues: { + auto: [decl('text-underline-offset', 'auto')], + }, }) suggest('underline-offset', () => [ From 65bad113808702ba7cbae712b748ee9f5473be51 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 12 Sep 2025 11:20:18 +0200 Subject: [PATCH 08/39] Do not migrate `variant = 'outline'` during upgrades (#18922) This PR improves the upgrade tool for shadcn/ui projects where the `variant = "outline"` is incorrectly migrated to `variant = "outline-solid"`. This PR also handles a few more cases: ```ts // As default argument function Button({ variant = "outline", ...props }: ButtonProps) { } // With different kinds of quotes (single, double, backticks) function Button({ variant = 'outline', ...props }: ButtonProps) { } // Regardless of whitespace function Button({ variant="outline", ...props }: ButtonProps) { } // In JSX