Add functional utility syntax (#15455)

This PR adds support for functional utilities constructed via CSS.

# Registering functional utilities in CSS

To register a functional utility in CSS, use the `@utility potato-*`
syntax, where the `-*` signals that this is a functional utility:

```css
@utility tab-* {
  tab-size: --value(--tab-size-*);
}
```

## Resolving values

The special `--value(…)` function is used to resolve the utility value.

### Resolving against `@theme` values

To resolve the value against a set of theme keys, use
`--value(--theme-key-*)`:

```css
@theme {
  --tab-size-1: 1;
  --tab-size-2: 2;
  --tab-size-4: 4;
  --tab-size-github: 8;
}

@utility tab-* {
  /* tab-1, tab-2, tab-4, tab-github */
  tab-size: --value(--tab-size-*);
}
```

### Bare values

To resolve the value as a bare value, use `--value({type})`, where
`{type}` is the data type you want to validate the bare value as:

```css
@utility tab-* {
  /* tab-1, tab-76, tab-971 */
  tab-size: --value(integer);
}
```

### Arbitrary values

To support arbitrary values, use `--value([{type}])` (notice the square
brackets) to tell Tailwind which types are supported as an arbitrary
value:

```css
@utility tab-* {
  /* tab-[1], tab-[76], tab-[971] */
  tab-size: --value([integer]);
}
```

### Supporting theme values, bare values, and arbitrary values together

All three forms of the `--value(…)` function can be used within a rule
as multiple declarations, and any declarations that fail to resolve will
be omitted in the output:

```css
@theme {
  --tab-size-github: 8;
}

@utility tab-* {
  tab-size: --value([integer]);
  tab-size: --value(integer);
  tab-size: --value(--tab-size-*);
}
```

This makes it possible to treat the value differently in each case if
necessary, for example translating a bare integer to a percentage:

```css
@utility opacity-* {
  opacity: --value([percentage]);
  opacity: calc(--value(integer) * 1%);
  opacity: --value(--opacity-*);
}
```

The `--value(…)` function can also take multiple arguments and resolve
them left to right if you don't need to treat the return value
differently in different cases:

```css
@theme {
  --tab-size-github: 8;
}

@utility tab-* {
  tab-size: --value(--tab-size-*, integer, [integer]);
}

@utility opacity-* {
  opacity: calc(--value(integer) * 1%);
  opacity: --value(--opacity-*, [percentage]);
}
```

### Negative values

To support negative values, register separate positive and negative
utilities into separate declarations:

```css
@utility inset-* {
  inset: calc(--var(--spacing) * --value([percentage], [length]));
}

@utility -inset-* {
  inset: calc(--var(--spacing) * --value([percentage], [length]) * -1);
}
```

## Modifiers

Modifiers are handled using the `--modifier(…)` function which works
exactly like the `--value(…)` function but operates on a modifier if
present:

```css
@utility text-* {
  font-size: --value(--font-size-*, [length]);
  line-height: --modifier(--line-height-*, [length], [*]);
}
```

If a modifier isn't present, any declaration depending on a modifier is
just not included in the output.

## Fractions

To handle fractions, we rely on the CSS `ratio` data type. If this is
used with `--value(…)`, it's a signal to Tailwind to treat the value +
modifier as a single value:

```css
/* The CSS `ratio` type is our signal to treat the value + modifier as a fraction */
@utility aspect-* {
  /* aspect-square, aspect-3/4, aspect-[7/9] */
  aspect-ratio: --value(--aspect-ratio-*, ratio, [ratio]);
}
```
This commit is contained in:
Robin Malfait 2025-01-08 16:04:28 +01:00 committed by GitHub
parent d6c4e72351
commit ee3add9d08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1213 additions and 17 deletions

View File

@ -11,6 +11,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))
### Fixed

View File

@ -26,12 +26,12 @@ import { substituteFunctions } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme, ThemeOptions } from './theme'
import { createCssUtility } from './utilities'
import { segment } from './utils/segment'
import { compoundsForSelectors } from './variants'
export type Config = UserConfig
const IS_VALID_PREFIX = /^[a-z]+$/
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type CompileOptions = {
base?: string
@ -174,25 +174,20 @@ async function parseCss(
throw new Error('`@utility` cannot be nested.')
}
let name = node.params
if (!IS_VALID_UTILITY_NAME.test(name)) {
throw new Error(
`\`@utility ${name}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
)
}
if (node.nodes.length === 0) {
throw new Error(
`\`@utility ${name}\` is empty. Utilities should include at least one property.`,
`\`@utility ${node.params}\` is empty. Utilities should include at least one property.`,
)
}
customUtilities.push((designSystem) => {
designSystem.utilities.static(name, () => structuredClone(node.nodes))
})
let utility = createCssUtility(node)
if (utility === null) {
throw new Error(
`\`@utility ${node.params}\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`,
)
}
return
customUtilities.push(utility)
}
// Collect paths from `@source` at-rules

View File

@ -437,3 +437,69 @@ test('Custom at-rule variants do not show up as a value under `group`', async ()
expect(not.values).toContain('variant-3')
expect(not.values).toContain('variant-4')
})
test('Custom functional @utility', async () => {
let input = css`
@import 'tailwindcss/utilities';
@theme reference {
--tab-size-1: 1;
--tab-size-2: 2;
--tab-size-4: 4;
--tab-size-github: 8;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--leading-foo: 1.5;
--leading-bar: 2;
}
@utility tab-* {
tab-size: --value(--tab-size);
}
@utility example-* {
font-size: --value(--text);
line-height: --value(--text- * --line-height);
line-height: --modifier(--leading);
}
@utility -negative-* {
margin: --value(--tab-size- *);
}
`
let design = await __unstable__loadDesignSystem(input, {
loadStylesheet: async (_, base) => ({
base,
content: '@tailwind utilities;',
}),
})
let classMap = new Map(design.getClassList())
let classNames = Array.from(classMap.keys())
expect(classNames).toContain('tab-1')
expect(classNames).toContain('tab-2')
expect(classNames).toContain('tab-4')
expect(classNames).toContain('tab-github')
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('-negative-1')
expect(classNames).toContain('-negative-2')
expect(classNames).toContain('-negative-4')
expect(classNames).toContain('-negative-github')
expect(classNames).not.toContain('--negative-1')
expect(classNames).not.toContain('--negative-2')
expect(classNames).not.toContain('--negative-4')
expect(classNames).not.toContain('--negative-github')
expect(classNames).toContain('example-xs')
expect(classMap.get('example-xs')?.modifiers).toEqual(['foo', 'bar'])
})

View File

@ -68,7 +68,7 @@ export class Theme {
}
}
keysInNamespaces(themeKeys: ThemeKey[]): string[] {
keysInNamespaces(themeKeys: Iterable<ThemeKey>): string[] {
let keys: string[] = []
for (let namespace of themeKeys) {

View File

@ -17259,7 +17259,7 @@ describe('custom utilities', () => {
test('custom utilities must use a valid name definitions', async () => {
await expect(() =>
compile(css`
@utility push-* {
@utility push-| {
right: 100%;
}
`),
@ -17438,4 +17438,754 @@ describe('custom utilities', () => {
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
)
})
describe('functional utilities', () => {
test('resolving values from `@theme`', async () => {
let input = css`
@theme reference {
--tab-size-1: 1;
--tab-size-2: 2;
--tab-size-4: 4;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']))
.toMatchInlineSnapshot(`
".tab-1 {
tab-size: 1;
}
.tab-2 {
tab-size: 2;
}
.tab-4 {
tab-size: 4;
}
.tab-github {
tab-size: 8;
}"
`)
expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('')
})
test('resolving values from `@theme`, with `--tab-size-*` syntax', async () => {
let input =
// Explicitly not using the css tagged template literal so that
// Prettier doesn't format the `value(--tab-size-*)` as
// `value(--tab-size- *)`
`
@theme reference {
--tab-size-1: 1;
--tab-size-2: 2;
--tab-size-4: 4;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size-*);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']))
.toMatchInlineSnapshot(`
".tab-1 {
tab-size: 1;
}
.tab-2 {
tab-size: 2;
}
.tab-4 {
tab-size: 4;
}
.tab-github {
tab-size: 8;
}"
`)
expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('')
})
test('resolving values from `@theme`, with `--tab-size-\\*` syntax (prettier friendly)', async () => {
let input = css`
@theme reference {
--tab-size-1: 1;
--tab-size-2: 2;
--tab-size-4: 4;
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value(--tab-size-\*);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-1', 'tab-2', 'tab-4', 'tab-github']))
.toMatchInlineSnapshot(`
".tab-1 {
tab-size: 1;
}
.tab-2 {
tab-size: 2;
}
.tab-4 {
tab-size: 4;
}
.tab-github {
tab-size: 8;
}"
`)
expect(await compileCss(input, ['tab-3', 'tab-gitlab'])).toEqual('')
})
test('resolving bare values', async () => {
let input = css`
@utility tab-* {
tab-size: --value(integer);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-1', 'tab-76', 'tab-971'])).toMatchInlineSnapshot(`
".tab-1 {
tab-size: 1;
}
.tab-76 {
tab-size: 76;
}
.tab-971 {
tab-size: 971;
}"
`)
expect(await compileCss(input, ['tab-foo'])).toEqual('')
})
test('resolving bare values with constraints for integer, percentage, and ratio', async () => {
let input = css`
@utility example-* {
--value-as-number: --value(number);
--value-as-percentage: --value(percentage);
--value-as-ratio: --value(ratio);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-1', 'example-0.5', 'example-20%', 'example-2/3']))
.toMatchInlineSnapshot(`
".example-0\\.5 {
--value-as-number: .5;
}
.example-1 {
--value-as-number: 1;
}
.example-2\\/3 {
--value-as-ratio: 2 / 3;
}
.example-20\\% {
--value-as-percentage: 20%;
}"
`)
expect(
await compileCss(input, [
'example-1.23',
'example-12.34%',
'example-1.2/3',
'example-1/2.3',
'example-1.2/3.4',
]),
).toEqual('')
})
test('resolving unsupported bare values', async () => {
let input = css`
@utility tab-* {
tab-size: --value(color);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-#0088cc', 'tab-foo'])).toEqual('')
})
test('resolving arbitrary values', async () => {
let input = css`
@utility tab-* {
tab-size: --value([integer]);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'tab-[1]',
'tab-[76]',
'tab-[971]',
'tab-[integer:var(--my-value)]',
'tab-(integer:my-value)',
]),
).toMatchInlineSnapshot(`
".tab-\\[1\\] {
tab-size: 1;
}
.tab-\\[76\\] {
tab-size: 76;
}
.tab-\\[971\\] {
tab-size: 971;
}
.tab-\\[integer\\:var\\(--my-value\\)\\] {
tab-size: var(--my-value);
}"
`)
expect(
await compileCss(input, [
'tab-[#0088cc]',
'tab-[1px]',
'tab-[var(--my-value)]',
'tab-(--my-value)',
'tab-[color:var(--my-value)]',
'tab-(color:--my-value)',
]),
).toEqual('')
})
test('resolving any arbitrary values', async () => {
let input = css`
@utility tab-* {
tab-size: --value([ *]);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'tab-[1]',
'tab-[76]',
'tab-[971]',
'tab-[var(--my-value)]',
'tab-(--my-value)',
]),
).toMatchInlineSnapshot(`
".tab-\\(--my-value\\) {
tab-size: var(--my-value);
}
.tab-\\[1\\] {
tab-size: 1;
}
.tab-\\[76\\] {
tab-size: 76;
}
.tab-\\[971\\] {
tab-size: 971;
}
.tab-\\[var\\(--my-value\\)\\] {
tab-size: var(--my-value);
}"
`)
})
test('resolving any arbitrary values (without space)', async () => {
let input = `
@utility tab-* {
tab-size: --value([*]);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'tab-[1]',
'tab-[76]',
'tab-[971]',
'tab-[var(--my-value)]',
'tab-(--my-value)',
]),
).toMatchInlineSnapshot(`
".tab-\\(--my-value\\) {
tab-size: var(--my-value);
}
.tab-\\[1\\] {
tab-size: 1;
}
.tab-\\[76\\] {
tab-size: 76;
}
.tab-\\[971\\] {
tab-size: 971;
}
.tab-\\[var\\(--my-value\\)\\] {
tab-size: var(--my-value);
}"
`)
})
test('resolving any arbitrary values (with escaped `*`)', async () => {
let input = css`
@utility tab-* {
tab-size: --value([\*]);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'tab-[1]',
'tab-[76]',
'tab-[971]',
'tab-[var(--my-value)]',
'tab-(--my-value)',
]),
).toMatchInlineSnapshot(`
".tab-\\(--my-value\\) {
tab-size: var(--my-value);
}
.tab-\\[1\\] {
tab-size: 1;
}
.tab-\\[76\\] {
tab-size: 76;
}
.tab-\\[971\\] {
tab-size: 971;
}
.tab-\\[var\\(--my-value\\)\\] {
tab-size: var(--my-value);
}"
`)
})
test('resolving theme, bare and arbitrary values all at once', async () => {
let input = css`
@theme reference {
--tab-size-github: 8;
}
@utility tab-* {
tab-size: --value([integer]);
tab-size: --value(integer);
tab-size: --value(--tab-size);
}
@tailwind utilities;
`
expect(await compileCss(input, ['tab-github', 'tab-76', 'tab-[123]'])).toMatchInlineSnapshot(`
".tab-76 {
tab-size: 76;
}
.tab-\\[123\\] {
tab-size: 123;
}
.tab-github {
tab-size: 8;
}"
`)
expect(await compileCss(input, ['tab-[#0088cc]', 'tab-[1px]'])).toEqual('')
})
test('in combination with calc to produce different data types of values', async () => {
let input = css`
@theme reference {
--example-full: 100%;
}
@utility example-* {
--value: --value([percentage]);
--value: calc(--value(integer) * 1%);
--value: --value(--example);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-full', 'example-12', 'example-[20%]']))
.toMatchInlineSnapshot(`
".example-12 {
--value: calc(12 * 1%);
}
.example-\\[20\\%\\] {
--value: 20%;
}
.example-full {
--value: 100%;
}"
`)
expect(await compileCss(input, ['example-half', 'example-[#0088cc]'])).toEqual('')
})
test('shorthand if resulting values are of the same type', async () => {
let input = css`
@theme reference {
--tab-size-github: 8;
--example-full: 100%;
}
@utility tab-* {
tab-size: --value(--tab-size, integer, [integer]);
}
@utility example-* {
--value: calc(--value(integer) * 1%);
--value: --value(--example, [percentage]);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'tab-github',
'tab-76',
'tab-[123]',
'example-37',
'example-[50%]',
'example-full',
]),
).toMatchInlineSnapshot(`
".example-37 {
--value: calc(37 * 1%);
}
.example-\\[50\\%\\] {
--value: 50%;
}
.example-full {
--value: 100%;
}
.tab-76 {
tab-size: 76;
}
.tab-\\[123\\] {
tab-size: 123;
}
.tab-github {
tab-size: 8;
}"
`)
expect(
await compileCss(input, ['tab-[#0088cc]', 'tab-[1px]', 'example-foo', 'example-[13px]']),
).toEqual('')
})
test('negative values', async () => {
let input = css`
@theme reference {
--example-full: 100%;
}
@utility example-* {
--value: --value(--example, [percentage], [length]);
}
@utility -example-* {
--value: calc(--value(--example, [percentage], [length]) * -1);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'example-full',
'-example-full',
'example-[10px]',
'-example-[10px]',
'example-[20%]',
'-example-[20%]',
]),
).toMatchInlineSnapshot(`
".-example-\\[10px\\] {
--value: calc(10px * -1);
}
.-example-\\[20\\%\\] {
--value: calc(20% * -1);
}
.-example-full {
--value: calc(100% * -1);
}
.example-\\[10px\\] {
--value: 10px;
}
.example-\\[20\\%\\] {
--value: 20%;
}
.example-full {
--value: 100%;
}"
`)
expect(await compileCss(input, ['example-10'])).toEqual('')
})
test('using the same value multiple times', async () => {
let input = css`
@utility example-* {
--value: calc(var(--spacing) * --value(number)) calc(var(--spacing) * --value(number));
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-12'])).toMatchInlineSnapshot(`
".example-12 {
--value: calc(var(--spacing) * 12) calc(var(--spacing) * 12);
}"
`)
})
test('modifiers', async () => {
let input = css`
@theme reference {
--value-sm: 14px;
--modifier-7: 28px;
}
@utility example-* {
--value: --value(--value, [length]);
--modifier: --modifier(--modifier, [length]);
--modifier-with-calc: calc(--modifier(--modifier, [length]) * 2);
}
@tailwind utilities;
`
expect(
await compileCss(input, [
'example-sm',
'example-sm/7',
'example-[12px]',
'example-[12px]/[16px]',
]),
).toMatchInlineSnapshot(`
".example-\\[12px\\] {
--value: 12px;
}
.example-\\[12px\\]\\/\\[16px\\] {
--value: 12px;
--modifier: 16px;
--modifier-with-calc: calc(16px * 2);
}
.example-sm {
--value: 14px;
}
.example-sm\\/7 {
--value: 14px;
--modifier: 28px;
--modifier-with-calc: calc(28px * 2);
}"
`)
expect(
await compileCss(input, ['example-foo', 'example-foo/[12px]', 'example-foo/12']),
).toEqual('')
})
test('fractions', async () => {
let input = css`
@theme reference {
--example-video: 16 / 9;
}
@utility example-* {
--value: --value(--example, ratio, [ratio]);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-video', 'example-1/1', 'example-[7/9]']))
.toMatchInlineSnapshot(`
".example-1\\/1 {
--value: 1 / 1;
}
.example-\\[7\\/9\\] {
--value: 7 / 9;
}
.example-video {
--value: 16 / 9;
}"
`)
expect(await compileCss(input, ['example-foo'])).toEqual('')
})
test('resolve theme values with sub-namespace (--text- * --line-height)', async () => {
let input = css`
@theme reference {
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
}
@utility example-* {
font-size: --value(--text);
line-height: --value(--text- * --line-height);
line-height: --modifier(number);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(`
".example-xs {
font-size: .75rem;
line-height: 1.33333;
}
.example-xs\\/6 {
font-size: .75rem;
line-height: 6;
}"
`)
expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('')
})
test('resolve theme values with sub-namespace (--text-\\* --line-height)', async () => {
let input = css`
@theme reference {
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
}
@utility example-* {
font-size: --value(--text);
line-height: --value(--text-\* --line-height);
line-height: --modifier(number);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(`
".example-xs {
font-size: .75rem;
line-height: 1.33333;
}
.example-xs\\/6 {
font-size: .75rem;
line-height: 6;
}"
`)
expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('')
})
test('resolve theme values with sub-namespace (--value(--text --line-height))', async () => {
let input = css`
@theme reference {
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
}
@utility example-* {
font-size: --value(--text);
line-height: --value(--text --line-height);
line-height: --modifier(number);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(`
".example-xs {
font-size: .75rem;
line-height: 1.33333;
}
.example-xs\\/6 {
font-size: .75rem;
line-height: 6;
}"
`)
expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('')
})
test('resolve theme values with sub-namespace (--value(--text-*--line-height))', async () => {
let input = `
@theme reference {
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
}
@utility example-* {
font-size: --value(--text);
line-height: --value(--text-*--line-height);
line-height: --modifier(number);
}
@tailwind utilities;
`
expect(await compileCss(input, ['example-xs', 'example-xs/6'])).toMatchInlineSnapshot(`
".example-xs {
font-size: .75rem;
line-height: 1.33333;
}
.example-xs\\/6 {
font-size: .75rem;
line-height: 6;
}"
`)
expect(await compileCss(input, ['example-foo', 'example-xs/foo'])).toEqual('')
})
})
})

View File

@ -1,5 +1,16 @@
import { atRoot, atRule, decl, styleRule, type AstNode } from './ast'
import {
atRoot,
atRule,
decl,
styleRule,
walk,
type AstNode,
type AtRule,
type Declaration,
type Rule,
} from './ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate'
import type { DesignSystem } from './design-system'
import type { Theme, ThemeKey } from './theme'
import { compareBreakpoints } from './utils/compare-breakpoints'
import { DefaultMap } from './utils/default-map'
@ -11,6 +22,10 @@ import {
} from './utils/infer-data-type'
import { replaceShadowColors } from './utils/replace-shadow-colors'
import { segment } from './utils/segment'
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/%._-]*-\*$/
type CompileFn<T extends Candidate['kind']> = (
value: Extract<Candidate, { kind: T }>,
@ -4569,3 +4584,360 @@ export function createUtilities(theme: Theme) {
return utilities
}
export function createCssUtility(node: AtRule) {
let name = node.params
// Functional utilities. E.g.: `tab-size-*`
if (IS_VALID_FUNCTIONAL_UTILITY_NAME.test(name)) {
// API:
//
// - `--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
// - `--value(number, [number])` resolves a bare value of type number or an
// arbitrary value of type number in order.
//
// Rules:
//
// - If `--value(…)` does not resolve to a valid value, the declaration is
// removed.
// - If a `--value(ratio)` resolves, the `--modifier(…)` cannot be used.
// - If a candidate looks like `foo-2/3`, then the `--value(ratio)` should
// be used OR the `--value(…)` and `--modifier(…)` must be used. But not
// both.
// - All parts of the candidate must resolve, otherwise it's not a valid
// utility. E.g.:`
// ```
// @utility foo-* {
// test: --value(number)
// }
// ```
// 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}`>()
// Pre-process the AST to make it easier to work with.
//
// - Normalize theme values used in `--value(…)` and `--modifier(…)`
// functions.
// - Track information for suggestions
walk(node.nodes, (child) => {
if (child.kind !== 'declaration') return
if (!child.value) return
if (!child.value.includes('--value(') && !child.value.includes('--modifier(')) return
let declarationValueAst = ValueParser.parse(child.value)
// 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([*])`
//
// 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
if (fn.value !== '--value' && fn.value !== '--modifier') return
let args = segment(ValueParser.toCss(fn.nodes), ',')
for (let [idx, arg] of args.entries()) {
// Transform escaped `\\*` -> `*`
arg = arg.replace(/\\\*/g, '*')
// Ensure `--value(--foo --bar)` becomes `--value(--foo-*--bar)`
arg = arg.replace(/--(.*?)\s--(.*?)/g, '--$1-*--$2')
// Remove whitespace, e.g.: `--value([ *])` -> `--value([*])`
arg = arg.replace(/\s+/g, '')
// Ensure multiple `-*` becomes a single `-*`
arg = arg.replace(/(-\*){2,}/g, '-*')
// Ensure trailing `-*` exists if `-*` isn't present yet
if (arg[0] === '-' && arg[1] === '-' && !arg.includes('-*')) {
arg += '-*'
}
args[idx] = arg
}
fn.nodes = ValueParser.parse(args.join(','))
// Track the theme keys 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}`
if (fn.value === '--value') {
valueThemeKeys.add(value)
} else if (fn.value === '--modifier') {
modifierThemeKeys.add(value)
}
}
}
})
child.value = ValueParser.toCss(declarationValueAst)
})
designSystem.utilities.functional(name.slice(0, -2), (candidate) => {
let atRule = structuredClone(node)
let value = candidate.value
let modifier = candidate.modifier
// A value is required for functional utilities, if you want to accept
// just `tab-size`, you'd have to use a static utility.
if (value === null) return
// Whether `--value(…)` was used
let usedValueFn = false
// Whether any of the declarations successfully resolved a `--value(…)`.
// E.g:
// ```css
// @utility tab-size-* {
// tab-size: --value(integer);
// tab-size: --value(--tab-size);
// tab-size: --value([integer]);
// }
// ```
// Any of these `tab-size` declarations have to resolve to a valid in
// order to make the utility valid.
let resolvedValueFn = false
// Whether `--modifier(…)` was used
let usedModifierFn = false
// Whether any of the declarations successfully resolved a `--modifier(…)`
let resolvedModifierFn = false
// A map of all declarations we replaced and their parent rules. We
// might need to remove some later on. E.g.: remove declarations that
// used `--value(number)` when `--value(ratio)` was resolved.
let resolvedDeclarations = new Map<Declaration, AtRule | Rule>()
// Whether `--value(ratio)` was resolved
let resolvedRatioValue = false
walk([atRule], (node, { parent, replaceWith: replaceDeclarationWith }) => {
if (parent?.kind !== 'rule' && parent?.kind !== 'at-rule') return
if (node.kind !== 'declaration') return
if (!node.value) return
let valueAst = ValueParser.parse(node.value)
let result =
ValueParser.walk(valueAst, (valueNode, { replaceWith }) => {
if (valueNode.kind !== 'function') return
// Value function, e.g.: `--value(integer)`
if (valueNode.value === '--value') {
usedValueFn = true
let resolved = resolveValueFunction(value, valueNode, designSystem)
if (resolved) {
resolvedValueFn = true
if (resolved.ratio) {
resolvedRatioValue = true
} else {
resolvedDeclarations.set(node, parent)
}
replaceWith(resolved.nodes)
return ValueParser.ValueWalkAction.Skip
}
// Drop the declaration in case we couldn't resolve the value
usedValueFn ||= false
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
}
// Modifier function, e.g.: `--modifier(integer)`
else if (valueNode.value === '--modifier') {
// If there is no modifier present in the candidate, then the
// declaration can be removed.
if (modifier === null) {
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Skip
}
usedModifierFn = true
let replacement = resolveValueFunction(modifier, valueNode, designSystem)
if (replacement) {
resolvedModifierFn = true
replaceWith(replacement.nodes)
return ValueParser.ValueWalkAction.Skip
}
// Drop the declaration in case we couldn't resolve the value
usedModifierFn ||= false
replaceDeclarationWith([])
return ValueParser.ValueWalkAction.Stop
}
}) ?? ValueParser.ValueWalkAction.Continue
if (result === ValueParser.ValueWalkAction.Continue) {
node.value = ValueParser.toCss(valueAst)
}
})
// Used `--value(…)` but nothing resolved
if (usedValueFn && !resolvedValueFn) return null
// Used `--modifier(…)` but nothing resolved
if (usedModifierFn && !resolvedModifierFn) return null
// Resolved `--value(ratio)` and `--modifier(…)`, which is invalid
if (resolvedRatioValue && resolvedModifierFn) return null
// When a candidate has a modifier, then the `--modifier(…)` must
// resolve correctly or the `--value(ratio)` must resolve correctly.
if (modifier && !resolvedRatioValue && !resolvedModifierFn) return null
// Resolved `--value(ratio)`, so all other declarations that didn't use
// `--value(ratio)` should be removed. E.g.: `--value(number)` would
// otherwise resolve for `foo-1/2`.
if (resolvedRatioValue) {
for (let [declaration, parent] of resolvedDeclarations) {
let idx = parent.nodes.indexOf(declaration)
if (idx !== -1) parent.nodes.splice(idx, 1)
}
}
return atRule.nodes
})
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[]
})
}
}
if (IS_VALID_STATIC_UTILITY_NAME.test(name)) {
return (designSystem: DesignSystem) => {
designSystem.utilities.static(name, () => structuredClone(node.nodes))
}
}
return null
}
function resolveValueFunction(
value: NonNullable<
| Extract<Candidate, { kind: 'functional' }>['value']
| Extract<Candidate, { kind: 'functional' }>['modifier']
>,
fn: ValueParser.ValueFunctionNode,
designSystem: DesignSystem,
): { nodes: ValueParser.ValueAstNode[]; ratio?: boolean } | undefined {
for (let arg of fn.nodes) {
// Resolving theme value, e.g.: `--value(--color)`
if (
value.kind === 'named' &&
arg.kind === 'word' &&
arg.value[0] === '-' &&
arg.value[1] === '-'
) {
let resolved = designSystem.resolveThemeValue(arg.value.replace('*', value.value))
if (resolved) return { nodes: ValueParser.parse(resolved) }
}
// Bare value, e.g.: `--value(integer)`
else if (value.kind === 'named' && arg.kind === 'word') {
// Limit the bare value types, to prevent new syntax that we
// don't want to support. E.g.: `text-#000` is something we
// don't want to support, but could be built this way.
if (
arg.value !== 'number' &&
arg.value !== 'integer' &&
arg.value !== 'ratio' &&
arg.value !== 'percentage'
) {
continue
}
let resolved = arg.value === 'ratio' && 'fraction' in value ? value.fraction : value.value
if (!resolved) continue
let type = inferDataType(resolved, [arg.value as any])
if (type === null) continue
// Ratio must be a valid fraction, e.g.: <integer>/<integer>
if (type === 'ratio') {
let [lhs, rhs] = segment(resolved, '/')
if (!isPositiveInteger(lhs) || !isPositiveInteger(rhs)) continue
}
// Non-integer numbers should be a valid multiplier,
// e.g.: `1.5`
else if (type === 'number' && !isValidSpacingMultiplier(resolved)) {
continue
}
// Percentages must be an integer, e.g.: `50%`
else if (type === 'percentage' && !isPositiveInteger(resolved.slice(0, -1))) {
continue
}
return { nodes: ValueParser.parse(resolved), ratio: type === 'ratio' }
}
// Arbitrary value, e.g.: `--value([integer])`
else if (
value.kind === 'arbitrary' &&
arg.kind === 'word' &&
arg.value[0] === '[' &&
arg.value[arg.value.length - 1] === ']'
) {
let dataType = arg.value.slice(1, -1)
// Allow any data type, e.g.: `--value([*])`
if (dataType === '*') {
return { nodes: ValueParser.parse(value.value) }
}
// The forced arbitrary value hint must match the expected
// data type.
//
// ```css
// @utility tab-* {
// tab-size: --value([integer]);
// }
// ```
//
// Given a candidate like `tab-(color:var(--my-value))`,
// should not match because `color` and `integer` don't
// match.
if ('dataType' in value && value.dataType && value.dataType !== dataType) {
continue
}
// Use the provided data type hint
if ('dataType' in value && value.dataType) {
return { nodes: ValueParser.parse(value.value) }
}
// No data type hint provided, so we have to infer it
let type = inferDataType(value.value, [dataType as any])
if (type !== null) {
return { nodes: ValueParser.parse(value.value) }
}
}
}
}

View File

@ -6,7 +6,9 @@ type DataType =
| 'color'
| 'length'
| 'percentage'
| 'ratio'
| 'number'
| 'integer'
| 'url'
| 'position'
| 'bg-size'
@ -23,7 +25,9 @@ const checks: Record<DataType, (value: string) => boolean> = {
color: isColor,
length: isLength,
percentage: isPercentage,
ratio: isFraction,
number: isNumber,
integer: isPositiveInteger,
url: isUrl,
position: isBackgroundPosition,
'bg-size': isBackgroundSize,
@ -173,6 +177,14 @@ function isPercentage(value: string): boolean {
/* -------------------------------------------------------------------------- */
const IS_FRACTION = new RegExp(`^${HAS_NUMBER.source}\s*/\s*${HAS_NUMBER.source}$`)
function isFraction(value: string): boolean {
return IS_FRACTION.test(value) || hasMathFn(value)
}
/* -------------------------------------------------------------------------- */
/**
* Please refer to MDN when updating this list:
* @see https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/Values_and_units