mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
Implement --spacing(…), --alpha(…) and --theme(…) CSS functions (#15572)
This PR implements new CSS functions that you can use in your CSS (or even in arbitrary value position). For starters, we renamed the `theme(…)` function to `--theme(…)`. The legacy `theme(…)` function is still available for backwards compatibility reasons, but this allows us to be future proof since `--foo(…)` is the syntax the CSS spec recommends. See: https://drafts.csswg.org/css-mixins/ In addition, this PR implements a new `--spacing(…)` function, this allows you to write: ```css @import "tailwindcss"; @theme { --spacing: 0.25rem; } .foo { margin: --spacing(4): } ``` This is syntax sugar over: ```css @import "tailwindcss"; @theme { --spacing: 0.25rem; } .foo { margin: calc(var(--spacing) * 4); } ``` If your `@theme` uses the `inline` keyword, we will also make sure to inline the value: ```css @import "tailwindcss"; @theme inline { --spacing: 0.25rem; } .foo { margin: --spacing(4): } ``` Boils down to: ```css @import "tailwindcss"; @theme { --spacing: 0.25rem; } .foo { margin: calc(0.25rem * 4); /* And will be optimised to just 1rem */ } ``` --- Another new function function we added is the `--alpha(…)` function that requires a value, and a number / percentage value. This allows you to apply an alpha value to any color, but with a much shorter syntax: ```css @import "tailwindcss"; .foo { color: --alpha(var(--color-red-500), 0.5); } ``` This is syntax sugar over: ```css @import "tailwindcss"; .foo { color: color-mix(in oklab, var(--color-red-500) 50%, transparent); } ``` --------- Co-authored-by: Philipp Spiess <hello@philippspiess.com> Co-authored-by: Jordan Pittman <jordan@cryptica.me> Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
This commit is contained in:
parent
ee3add9d08
commit
8d03db8178
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
},
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<string, (designSystem: DesignSystem, ...args: string[]) => 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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user