diff --git a/CHANGELOG.md b/CHANGELOG.md index e738d386e..5b73a76fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `@tailwindcss/browser` package to run Tailwind CSS in the browser ([#15558](https://github.com/tailwindlabs/tailwindcss/pull/15558)) - Add `@reference "…"` API as a replacement for the previous `@import "…" reference` option ([#15565](https://github.com/tailwindlabs/tailwindcss/pull/15565)) - Add functional utility syntax ([#15455](https://github.com/tailwindlabs/tailwindcss/pull/15455)) +- Add new `--spacing(…)`, `--alpha(…)`, and `--theme(…)` CSS functions ([#15572](https://github.com/tailwindlabs/tailwindcss/pull/15572)) ### Fixed diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 0564bcf91..c40b83f99 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -253,6 +253,29 @@ function upgradeToFullPluginSupport({ userConfig, ) + // Replace `resolveThemeValue` with a version that is backwards compatible + // with dot-notation but also aware of any JS theme configurations registered + // by plugins or JS config files. This is significantly slower than just + // upgrading dot-notation keys so we only use this version if plugins or + // config files are actually being used. In the future we may want to optimize + // this further by only doing this if plugins or config files _actually_ + // registered JS config objects. + designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) { + let resolvedValue = pluginApi.theme(path, defaultValue) + + if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { + // When a tuple is returned, return the first element + return resolvedValue[0] + } else if (Array.isArray(resolvedValue)) { + // Arrays get serialized into a comma-separated lists + return resolvedValue.join(', ') + } else if (typeof resolvedValue === 'string') { + // Otherwise only allow string values here, objects (and namespace maps) + // are treated as non-resolved values for the CSS `theme()` function. + return resolvedValue + } + } + let pluginApi = buildPluginApi(designSystem, ast, resolvedConfig, { set current(value: number) { features |= value @@ -319,29 +342,6 @@ function upgradeToFullPluginSupport({ designSystem.invalidCandidates.add(candidate) } - // Replace `resolveThemeValue` with a version that is backwards compatible - // with dot-notation but also aware of any JS theme configurations registered - // by plugins or JS config files. This is significantly slower than just - // upgrading dot-notation keys so we only use this version if plugins or - // config files are actually being used. In the future we may want to optimize - // this further by only doing this if plugins or config files _actually_ - // registered JS config objects. - designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) { - let resolvedValue = pluginApi.theme(path, defaultValue) - - if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { - // When a tuple is returned, return the first element - return resolvedValue[0] - } else if (Array.isArray(resolvedValue)) { - // Arrays get serialized into a comma-separated lists - return resolvedValue.join(', ') - } else if (typeof resolvedValue === 'string') { - // Otherwise only allow string values here, objects (and namespace maps) - // are treated as non-resolved values for the CSS `theme()` function. - return resolvedValue - } - } - for (let file of resolvedConfig.content.files) { if ('raw' in file) { throw new Error( diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 95f4db9e5..05ce811bb 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -95,7 +95,7 @@ export function buildPluginApi( let api: PluginAPI = { addBase(css) { let baseNodes = objectToAst(css) - featuresRef.current |= substituteFunctions(baseNodes, api.theme) + featuresRef.current |= substituteFunctions(baseNodes, designSystem) ast.push(atRule('@layer', 'base', baseNodes)) }, diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index 81419ce77..2275971fb 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -62,7 +62,7 @@ export function compileCandidates( try { substituteFunctions( rules.map(({ node }) => node), - designSystem.resolveThemeValue, + designSystem, ) } catch (err) { // If substitution fails then the candidate likely contains a call to diff --git a/packages/tailwindcss/src/css-functions.test.ts b/packages/tailwindcss/src/css-functions.test.ts index f042b40c2..bca419c3a 100644 --- a/packages/tailwindcss/src/css-functions.test.ts +++ b/packages/tailwindcss/src/css-functions.test.ts @@ -7,7 +7,168 @@ import { compileCss, optimizeCss } from './test-utils/run' const css = String.raw -describe('theme function', () => { +describe('--alpha(…)', () => { + test('--alpha(…)', async () => { + expect( + await compileCss(css` + .foo { + margin: --alpha(red, 50%); + } + `), + ).toMatchInlineSnapshot(` + ".foo { + margin: oklab(62.7955% .22486 .12584 / .5); + }" + `) + }) + + test('--alpha(…) errors when no arguments are used', async () => { + expect(() => + compileCss(css` + .foo { + margin: --alpha(); + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The --alpha(…) function requires two arguments, e.g.: \`--alpha(var(--my-color), 50%)\`]`, + ) + }) + + test('--alpha(…) errors when alpha value is missing', async () => { + expect(() => + compileCss(css` + .foo { + margin: --alpha(red); + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The --alpha(…) function requires two arguments, e.g.: \`--alpha(red, 50%)\`]`, + ) + }) + + test('--alpha(…) errors multiple arguments are used', async () => { + expect(() => + compileCss(css` + .foo { + margin: --alpha(red, 50%, blue); + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The --alpha(…) function only accepts two arguments, e.g.: \`--alpha(red, 50%)\`]`, + ) + }) +}) + +describe('--spacing(…)', () => { + test('--spacing(…)', async () => { + expect( + await compileCss(css` + @theme { + --spacing: 0.25rem; + } + + .foo { + margin: --spacing(4); + } + `), + ).toMatchInlineSnapshot(` + ":root { + --spacing: .25rem; + } + + .foo { + margin: calc(var(--spacing) * 4); + }" + `) + }) + + test('--spacing(…) with inline `@theme` value', async () => { + expect( + await compileCss(css` + @theme inline { + --spacing: 0.25rem; + } + + .foo { + margin: --spacing(4); + } + `), + ).toMatchInlineSnapshot(` + ":root { + --spacing: .25rem; + } + + .foo { + margin: 1rem; + }" + `) + }) + + test('--spacing(…) relies on `--spacing` to be defined', async () => { + expect(() => + compileCss(css` + .foo { + margin: --spacing(4); + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The --spacing(…) function requires that the \`--spacing\` theme variable exists, but it was not found.]`, + ) + }) + + test('--spacing(…) requires a single value', async () => { + expect(() => + compileCss(css` + @theme { + --spacing: 0.25rem; + } + + .foo { + margin: --spacing(); + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The --spacing(…) function requires an argument, but received none.]`, + ) + }) + + test('--spacing(…) does not have multiple arguments', async () => { + expect(() => + compileCss(css` + .foo { + margin: --spacing(4, 5, 6); + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The --spacing(…) function only accepts a single argument, but received 3.]`, + ) + }) +}) + +describe('--theme(…)', () => { + test('theme(--color-red-500)', async () => { + expect( + await compileCss(css` + @theme { + --color-red-500: #f00; + } + .red { + color: --theme(--color-red-500); + } + `), + ).toMatchInlineSnapshot(` + ":root { + --color-red-500: red; + } + + .red { + color: red; + }" + `) + }) +}) + +describe('theme(…)', () => { describe('in declaration values', () => { describe('without fallback values', () => { test('theme(colors.red.500)', async () => { diff --git a/packages/tailwindcss/src/css-functions.ts b/packages/tailwindcss/src/css-functions.ts index fe259a0a2..ee3460db9 100644 --- a/packages/tailwindcss/src/css-functions.ts +++ b/packages/tailwindcss/src/css-functions.ts @@ -1,19 +1,85 @@ import { Features } from '.' import { walk, type AstNode } from './ast' +import type { DesignSystem } from './design-system' +import { withAlpha } from './utilities' +import { segment } from './utils/segment' import * as ValueParser from './value-parser' -import { type ValueAstNode } from './value-parser' -export const THEME_FUNCTION_INVOCATION = 'theme(' +const functions: Record any> = { + '--alpha': alpha, + '--spacing': spacing, + '--theme': theme, + theme, +} -type ResolveThemeValue = (path: string) => string | undefined +function alpha(_designSystem: DesignSystem, value: string, alpha: string, ...rest: string[]) { + if (!value || !alpha) { + throw new Error( + `The --alpha(…) function requires two arguments, e.g.: \`--alpha(${value || 'var(--my-color)'}, ${alpha || '50%'})\``, + ) + } -export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveThemeValue) { + if (rest.length > 0) { + throw new Error( + `The --alpha(…) function only accepts two arguments, e.g.: \`--alpha(${value || 'var(--my-color)'}, ${alpha || '50%'})\``, + ) + } + + return withAlpha(value, alpha) +} + +function spacing(designSystem: DesignSystem, value: string, ...rest: string[]) { + if (!value) { + throw new Error(`The --spacing(…) function requires an argument, but received none.`) + } + + if (rest.length > 0) { + throw new Error( + `The --spacing(…) function only accepts a single argument, but received ${rest.length + 1}.`, + ) + } + + let multiplier = designSystem.theme.resolve(null, ['--spacing']) + if (!multiplier) { + throw new Error( + 'The --spacing(…) function requires that the `--spacing` theme variable exists, but it was not found.', + ) + } + + return `calc(${multiplier} * ${value})` +} + +function theme(designSystem: DesignSystem, path: string, ...fallback: string[]) { + path = eventuallyUnquote(path) + + let resolvedValue = designSystem.resolveThemeValue(path) + + if (!resolvedValue && fallback.length > 0) { + return fallback.join(', ') + } + + if (!resolvedValue) { + throw new Error( + `Could not resolve value for theme function: \`theme(${path})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`, + ) + } + + return resolvedValue +} + +export const THEME_FUNCTION_INVOCATION = new RegExp( + Object.keys(functions) + .map((x) => `${x}\\(`) + .join('|'), +) + +export function substituteFunctions(ast: AstNode[], designSystem: DesignSystem) { let features = Features.None walk(ast, (node) => { // Find all declaration values - if (node.kind === 'declaration' && node.value?.includes(THEME_FUNCTION_INVOCATION)) { + if (node.kind === 'declaration' && node.value && THEME_FUNCTION_INVOCATION.test(node.value)) { features |= Features.ThemeFunction - node.value = substituteFunctionsInValue(node.value, resolveThemeValue) + node.value = substituteFunctionsInValue(node.value, designSystem) return } @@ -24,91 +90,29 @@ export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveTh node.name === '@custom-media' || node.name === '@container' || node.name === '@supports') && - node.params.includes(THEME_FUNCTION_INVOCATION) + THEME_FUNCTION_INVOCATION.test(node.params) ) { features |= Features.ThemeFunction - node.params = substituteFunctionsInValue(node.params, resolveThemeValue) + node.params = substituteFunctionsInValue(node.params, designSystem) } } }) return features } -export function substituteFunctionsInValue( - value: string, - resolveThemeValue: ResolveThemeValue, -): string { +export function substituteFunctionsInValue(value: string, designSystem: DesignSystem): string { let ast = ValueParser.parse(value) ValueParser.walk(ast, (node, { replaceWith }) => { - if (node.kind === 'function' && node.value === 'theme') { - if (node.nodes.length < 1) { - throw new Error( - 'Expected `theme()` function call to have a path. For example: `theme(--color-red-500)`.', - ) - } - - // Ignore whitespace before the first argument - if (node.nodes[0].kind === 'separator' && node.nodes[0].value.trim() === '') { - node.nodes.shift() - } - - let pathNode = node.nodes[0] - if (pathNode.kind !== 'word') { - throw new Error( - `Expected \`theme()\` function to start with a path, but instead found ${pathNode.value}.`, - ) - } - let path = pathNode.value - - // For the theme function arguments, we require all separators to contain - // comma (`,`), spaces alone should be merged into the previous word to - // avoid splitting in this case: - // - // theme(--color-red-500 / 75%) theme(--color-red-500 / 75%, foo, bar) - // - // We only need to do this for the first node, as the fallback values are - // passed through as-is. - let skipUntilIndex = 1 - for (let i = skipUntilIndex; i < node.nodes.length; i++) { - if (node.nodes[i].value.includes(',')) { - break - } - path += ValueParser.toCss([node.nodes[i]]) - skipUntilIndex = i + 1 - } - - path = eventuallyUnquote(path) - let fallbackValues = node.nodes.slice(skipUntilIndex + 1) - - replaceWith(cssThemeFn(resolveThemeValue, path, fallbackValues)) + if (node.kind === 'function' && node.value in functions) { + let args = segment(ValueParser.toCss(node.nodes).trim(), ',').map((x) => x.trim()) + let result = functions[node.value as keyof typeof functions](designSystem, ...args) + return replaceWith(ValueParser.parse(result)) } }) return ValueParser.toCss(ast) } -function cssThemeFn( - resolveThemeValue: ResolveThemeValue, - path: string, - fallbackValues: ValueAstNode[], -): ValueAstNode[] { - let resolvedValue = resolveThemeValue(path) - - if (!resolvedValue && fallbackValues.length > 0) { - return fallbackValues - } - - if (!resolvedValue) { - throw new Error( - `Could not resolve value for theme function: \`theme(${path})\`. Consider checking if the path is correct or provide a fallback value to silence this error.`, - ) - } - - // We need to parse the values recursively since this can resolve with another - // `theme()` function definition. - return ValueParser.parse(resolvedValue) -} - function eventuallyUnquote(value: string) { if (value[0] !== "'" && value[0] !== '"') return value diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index dd4700f6e..794caeaaa 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -533,7 +533,7 @@ async function parseCss( node.context = {} } - features |= substituteFunctions(ast, designSystem.resolveThemeValue) + features |= substituteFunctions(ast, designSystem) features |= substituteAtApply(ast, designSystem) // Remove `@utility`, we couldn't replace it before yet because we had to diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index c1b3dfe67..f4530db74 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -17989,6 +17989,50 @@ describe('custom utilities', () => { `) }) + test('using `--spacing(…)` shorthand', async () => { + let input = css` + @theme { + --spacing: 4px; + } + + @utility example-* { + margin: --spacing(--value(number)); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot(` + ":root { + --spacing: 4px; + } + + .example-12 { + margin: calc(var(--spacing) * 12); + }" + `) + }) + + test('using `--spacing(…)` shorthand (inline theme)', async () => { + let input = css` + @theme inline reference { + --spacing: 4px; + } + + @utility example-* { + margin: --spacing(--value(number)); + } + + @tailwind utilities; + ` + + expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot(` + ".example-12 { + margin: 48px; + }" + `) + }) + test('modifiers', async () => { let input = css` @theme reference {