diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d3513c1..e1fbf3177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - _Experimental_: Add `user-valid` and `user-invalid` variants ([#12370](https://github.com/tailwindlabs/tailwindcss/pull/12370)) - _Experimental_: Add `wrap-anywhere`, `wrap-break-word`, and `wrap-normal` utilities ([#12128](https://github.com/tailwindlabs/tailwindcss/pull/12128)) - _Experimental_: Add `@source inline(…)` ([#17147](https://github.com/tailwindlabs/tailwindcss/pull/17147)) +- Add support for literal values in `--value('…')` and `--modifier('…')` ([#17304](https://github.com/tailwindlabs/tailwindcss/pull/17304)) +- Add suggestions when `--spacing(--value(integer, number))` is used ([#17308](https://github.com/tailwindlabs/tailwindcss/pull/17308)) ### [4.0.15] - 2025-03-20 diff --git a/packages/tailwindcss/src/intellisense.test.ts b/packages/tailwindcss/src/intellisense.test.ts index 4a69aa500..24e418be3 100644 --- a/packages/tailwindcss/src/intellisense.test.ts +++ b/packages/tailwindcss/src/intellisense.test.ts @@ -476,16 +476,31 @@ test('Custom functional @utility', async () => { --leading-foo: 1.5; --leading-bar: 2; + + --spacing: 0.25rem; + --spacing-custom: 123px; } @utility tab-* { - tab-size: --value(--tab-size); + tab-size: --value(--tab-size, 'revert', 'initial'); } @utility example-* { font-size: --value(--text); line-height: --value(--text- * --line-height); - line-height: --modifier(--leading); + line-height: --modifier(--leading, 'normal'); + } + + @utility with-custom-spacing-* { + size: --value(--spacing); + } + + @utility with-integer-spacing-* { + size: --spacing(--value(integer)); + } + + @utility with-number-spacing-* { + size: --spacing(--value(number)); } @utility -negative-* { @@ -507,12 +522,32 @@ test('Custom functional @utility', async () => { expect(classNames).toContain('tab-2') expect(classNames).toContain('tab-4') expect(classNames).toContain('tab-github') + expect(classNames).toContain('tab-revert') + expect(classNames).toContain('tab-initial') expect(classNames).not.toContain('-tab-1') expect(classNames).not.toContain('-tab-2') expect(classNames).not.toContain('-tab-4') expect(classNames).not.toContain('-tab-github') + expect(classNames).toContain('with-custom-spacing-custom') + expect(classNames).not.toContain('with-custom-spacing-0') + expect(classNames).not.toContain('with-custom-spacing-0.5') + expect(classNames).not.toContain('with-custom-spacing-1') + expect(classNames).not.toContain('with-custom-spacing-1.5') + + expect(classNames).not.toContain('with-integer-spacing-custom') + expect(classNames).toContain('with-integer-spacing-0') + expect(classNames).not.toContain('with-integer-spacing-0.5') + expect(classNames).toContain('with-integer-spacing-1') + expect(classNames).not.toContain('with-integer-spacing-1.5') + + expect(classNames).not.toContain('with-number-spacing-custom') + expect(classNames).toContain('with-number-spacing-0') + expect(classNames).toContain('with-number-spacing-0.5') + expect(classNames).toContain('with-number-spacing-1') + expect(classNames).toContain('with-number-spacing-1.5') + expect(classNames).toContain('-negative-1') expect(classNames).toContain('-negative-2') expect(classNames).toContain('-negative-4') @@ -524,7 +559,7 @@ test('Custom functional @utility', async () => { expect(classNames).not.toContain('--negative-github') expect(classNames).toContain('example-xs') - expect(classMap.get('example-xs')?.modifiers).toEqual(['foo', 'bar']) + expect(classMap.get('example-xs')?.modifiers).toEqual(['normal', 'foo', 'bar']) }) test('Theme keys with underscores are suggested with underscores', async () => { diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 21b43daba..908a2398a 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17256,6 +17256,23 @@ describe('custom utilities', () => { expect(await compileCss(input, ['tab-foo'])).toEqual('') }) + test('resolve literal values', async () => { + let input = css` + @utility tab-* { + tab-size: --value('revert'); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['tab-revert'])).toMatchInlineSnapshot(` + ".tab-revert { + tab-size: revert; + }" + `) + expect(await compileCss(input, ['tab-initial'])).toEqual('') + }) + test('resolving bare values with constraints for integer, percentage, and ratio', async () => { let input = css` @utility example-* { @@ -17720,6 +17737,7 @@ describe('custom utilities', () => { --value: --value(--value, [length]); --modifier: --modifier(--modifier, [length]); --modifier-with-calc: calc(--modifier(--modifier, [length]) * 2); + --modifier-literals: --modifier('literal', 'literal-2'); } @tailwind utilities; @@ -17731,6 +17749,8 @@ describe('custom utilities', () => { 'example-sm/7', 'example-[12px]', 'example-[12px]/[16px]', + 'example-sm/literal', + 'example-sm/literal-2', ]), ).toMatchInlineSnapshot(` ".example-\\[12px\\]\\/\\[16px\\] { @@ -17745,6 +17765,16 @@ describe('custom utilities', () => { --modifier-with-calc: calc(var(--modifier-7, 28px) * 2); } + .example-sm\\/literal { + --value: var(--value-sm, 14px); + --modifier-literals: literal; + } + + .example-sm\\/literal-2 { + --value: var(--value-sm, 14px); + --modifier-literals: literal-2; + } + .example-\\[12px\\] { --value: 12px; } @@ -17754,7 +17784,12 @@ describe('custom utilities', () => { }" `) expect( - await compileCss(input, ['example-foo', 'example-foo/[12px]', 'example-foo/12']), + await compileCss(input, [ + 'example-foo', + 'example-foo/[12px]', + 'example-foo/12', + 'example-sm/unknown-literal', + ]), ).toEqual('') }) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 587dc2d50..84b5d61c6 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -29,6 +29,43 @@ import * as ValueParser from './value-parser' const IS_VALID_STATIC_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*$/ const IS_VALID_FUNCTIONAL_UTILITY_NAME = /^-?[a-z][a-zA-Z0-9/%._-]*-\*$/ +const DEFAULT_SPACING_SUGGESTIONS = [ + '0', + '0.5', + '1', + '1.5', + '2', + '2.5', + '3', + '3.5', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '14', + '16', + '20', + '24', + '28', + '32', + '36', + '40', + '44', + '48', + '52', + '56', + '60', + '64', + '72', + '80', + '96', +] + type CompileFn = ( value: Extract, ) => AstNode[] | undefined | null @@ -476,44 +513,7 @@ export function createUtilities(theme: Theme) { suggest(name, () => [ { - values: theme.get(['--spacing']) - ? [ - '0', - '0.5', - '1', - '1.5', - '2', - '2.5', - '3', - '3.5', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '14', - '16', - '20', - '24', - '28', - '32', - '36', - '40', - '44', - '48', - '52', - '56', - '60', - '64', - '72', - '80', - '96', - ] - : [], + values: theme.get(['--spacing']) ? DEFAULT_SPACING_SUGGESTIONS : [], supportsNegative, supportsFractions, valueThemeKeys: themeKeys, @@ -4706,6 +4706,7 @@ export function createCssUtility(node: AtRule) { if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) { // API: // + // - `--value('literal')` resolves a literal named value // - `--value(number)` resolves a bare value of type number // - `--value([number])` resolves an arbitrary value of type number // - `--value(--color)` resolves a theme value in the `color` namespace @@ -4730,8 +4731,20 @@ export function createCssUtility(node: AtRule) { // If you then use `foo-1/2`, this is invalid, because the modifier is not used. return (designSystem: DesignSystem) => { - let valueThemeKeys = new Set<`--${string}`>() - let modifierThemeKeys = new Set<`--${string}`>() + let storage = { + '--value': { + usedSpacingInteger: false, + usedSpacingNumber: false, + themeKeys: new Set<`--${string}`>(), + literals: new Set(), + }, + '--modifier': { + usedSpacingInteger: false, + usedSpacingNumber: false, + themeKeys: new Set<`--${string}`>(), + literals: new Set(), + }, + } // Pre-process the AST to make it easier to work with. // @@ -4747,17 +4760,52 @@ export function createCssUtility(node: AtRule) { // Required manipulations: // - // - `--value(--spacing)` -> `--value(--spacing-*)` - // - `--value(--spacing- *)` -> `--value(--spacing-*)` - // - `--value(--text- * --line-height)` -> `--value(--text-*--line-height)` - // - `--value(--text --line-height)` -> `--value(--text-*--line-height)` - // - `--value(--text-\\* --line-height)` -> `--value(--text-*--line-height)` - // - `--value([ *])` -> `--value([*])` + // - `--value(--spacing)` -> `--value(--spacing-*)` + // - `--value(--spacing- *)` -> `--value(--spacing-*)` + // - `--value(--text- * --line-height)` -> `--value(--text-*--line-height)` + // - `--value(--text --line-height)` -> `--value(--text-*--line-height)` + // - `--value(--text-\\* --line-height)` -> `--value(--text-*--line-height)` + // - `--value([ *])` -> `--value([*])` // // Once Prettier / Biome handle these better (e.g.: not crashing without // `\\*` or not inserting whitespace) then most of these can go away. ValueParser.walk(declarationValueAst, (fn) => { if (fn.kind !== 'function') return + + // Track usage of `--spacing(…)` + if ( + fn.value === '--spacing' && + // Quick bail check if we already know that `--value` and `--modifier` are + // using the full `--spacing` theme scale. + !(storage['--modifier'].usedSpacingNumber && storage['--value'].usedSpacingNumber) + ) { + ValueParser.walk(fn.nodes, (node) => { + if (node.kind !== 'function') return + if (node.value !== '--value' && node.value !== '--modifier') return + const key = node.value + + for (let arg of node.nodes) { + if (arg.kind !== 'word') continue + + if (arg.value === 'integer') { + storage[key].usedSpacingInteger ||= true + } else if (arg.value === 'number') { + storage[key].usedSpacingNumber ||= true + + // Once both `--value` and `--modifier` are using the full + // `number` spacing scale, then there's no need to continue + if ( + storage['--modifier'].usedSpacingNumber && + storage['--value'].usedSpacingNumber + ) { + return ValueParser.ValueWalkAction.Stop + } + } + } + }) + return ValueParser.ValueWalkAction.Continue + } + if (fn.value !== '--value' && fn.value !== '--modifier') return let args = segment(ValueParser.toCss(fn.nodes), ',') @@ -4783,16 +4831,22 @@ export function createCssUtility(node: AtRule) { } fn.nodes = ValueParser.parse(args.join(',')) - // Track the theme keys for suggestions + // Track information for suggestions for (let node of fn.nodes) { - if (node.kind === 'word' && node.value[0] === '-' && node.value[1] === '-') { - let value = node.value.replace(/-\*.*$/g, '') as `--${string}` + // Track literal values + if ( + node.kind === 'word' && + (node.value[0] === '"' || node.value[0] === "'") && + node.value[0] === node.value[node.value.length - 1] + ) { + let value = node.value.slice(1, -1) + storage[fn.value].literals.add(value) + } - if (fn.value === '--value') { - valueThemeKeys.add(value) - } else if (fn.value === '--modifier') { - modifierThemeKeys.add(value) - } + // Track theme keys + else if (node.kind === 'word' && node.value[0] === '-' && node.value[1] === '-') { + let value = node.value.replace(/-\*.*$/g, '') as `--${string}` + storage[fn.value].themeKeys.add(value) } } }) @@ -4929,16 +4983,36 @@ export function createCssUtility(node: AtRule) { }) designSystem.utilities.suggest(name.slice(0, -2), () => { - return [ - { - values: designSystem.theme - .keysInNamespaces(valueThemeKeys) - .map((x) => x.replaceAll('_', '.')), - modifiers: designSystem.theme - .keysInNamespaces(modifierThemeKeys) - .map((x) => x.replaceAll('_', '.')), - }, - ] satisfies SuggestionGroup[] + let values: string[] = [] + let modifiers: string[] = [] + + for (let [target, { literals, usedSpacingNumber, usedSpacingInteger, themeKeys }] of [ + [values, storage['--value']], + [modifiers, storage['--modifier']], + ] as const) { + // Suggest literal values. E.g.: `--value('literal')` + for (let value of literals) { + target.push(value) + } + + // Suggest `--spacing(…)` values. E.g.: `--spacing(--value(integer))` + if (usedSpacingNumber) { + target.push(...DEFAULT_SPACING_SUGGESTIONS) + } else if (usedSpacingInteger) { + for (let value of DEFAULT_SPACING_SUGGESTIONS) { + if (isPositiveInteger(value)) { + target.push(value) + } + } + } + + // Suggest theme values. E.g.: `--value(--color-*)` + for (let value of designSystem.theme.keysInNamespaces(themeKeys)) { + target.push(value) + } + } + + return [{ values, modifiers }] satisfies SuggestionGroup[] }) } } @@ -4961,8 +5035,21 @@ function resolveValueFunction( designSystem: DesignSystem, ): { nodes: ValueParser.ValueAstNode[]; ratio?: boolean } | undefined { for (let arg of fn.nodes) { - // Resolving theme value, e.g.: `--value(--color)` + // Resolve literal value, e.g.: `--modifier('closest-side')` if ( + value.kind === 'named' && + arg.kind === 'word' && + // Should be wreapped in quotes + (arg.value[0] === "'" || arg.value[0] === '"') && + arg.value[arg.value.length - 1] === arg.value[0] && + // Values should match + arg.value.slice(1, -1) === value.value + ) { + return { nodes: ValueParser.parse(value.value) } + } + + // Resolving theme value, e.g.: `--value(--color)` + else if ( value.kind === 'named' && arg.kind === 'word' && arg.value[0] === '-' &&