Add support for defining simple custom utilities in CSS via @utility (#14044)

This PR allows you to add custom utilities to your project via the new
`@utility` rule.

For example, given the following:
```css
@utility text-trim {
  text-box-trim: both;
  text-box-edge: cap alphabetic;
}
```

A new `text-trim` utility is available and can be used directly or with
variants:
```html
<div class="text-trim">...</div>
<div class="hover:text-trim">...</div>
<div class="lg:dark:text-trim">...</div>
```

If a utility is defined more than one time the latest definition will be
used:
```css
@utility text-trim {
  text-box-trim: both;
  text-box-edge: cap alphabetic;
}

@utility text-trim {
  text-box-trim: both;
  text-box-edge: cap ideographic;
}
```

Then using `text-trim` will produce the following CSS:
```css
.text-trim {
  text-box-trim: both;
  text-box-edge: cap ideographic;
}
```

You may also override specific existing utilities with this — for
example to add a `text-rendering` property to the `text-sm` utility:
```css
@utility text-sm {
  font-size: var(--font-size-sm, 0.875rem);
  line-height: var(--font-size-sm--line-height, 1.25rem);
  text-rendering: optimizeLegibility;
}
```

Though it's preferred, should you not need to add properties, to
override the theme instead.

Lastly, utilities with special characters do not need to be escaped like
you would for a class name in a selector:
```css
@utility push-1/2 {
  right: 50%;
}
```

We do however explicitly error on certain patterns that we want to
reserve for future use, for example `push-*` and `push-[15px]`.

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Jordan Pittman 2024-07-24 11:07:24 -04:00 committed by GitHub
parent 300524b893
commit f0e9343b5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 319 additions and 111 deletions

View File

@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for basic `addVariant` plugins with new `@plugin` directive ([#13982](https://github.com/tailwindlabs/tailwindcss/pull/13982), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008))
- Add `@variant` at-rule for defining custom variants in CSS ([#13992](https://github.com/tailwindlabs/tailwindcss/pull/13992), [#14008](https://github.com/tailwindlabs/tailwindcss/pull/14008))
- Add `@utility` at-rule for defining custom utilities in CSS ([#14044](https://github.com/tailwindlabs/tailwindcss/pull/14044))
## [4.0.0-alpha.17] - 2024-07-04

View File

@ -249,6 +249,25 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
base = base.slice(1)
}
// Candidates that start with a dash are the negative versions of another
// candidate, e.g. `-mx-4`.
if (base[0] === '-') {
negative = true
base = base.slice(1)
}
// Check for an exact match of a static utility first as long as it does not
// look like an arbitrary value.
if (designSystem.utilities.has(base, 'static') && !base.includes('[')) {
return {
kind: 'static',
root: base,
variants: parsedCandidateVariants,
negative,
important,
}
}
// Figure out the new base and the modifier segment if present.
//
// E.g.:
@ -307,13 +326,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
}
}
// Candidates that start with a dash are the negative versions of another
// candidate, e.g. `-mx-4`.
if (baseWithoutModifier[0] === '-') {
negative = true
baseWithoutModifier = baseWithoutModifier.slice(1)
}
// The root of the utility, e.g.: `bg-red-500`
// ^^
let root: string | null = null
@ -345,28 +357,16 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// The root of the utility should exist as-is in the utilities map. If not,
// it's an invalid utility and we can skip continue parsing.
if (!designSystem.utilities.has(root)) return null
if (!designSystem.utilities.has(root, 'functional')) return null
value = baseWithoutModifier.slice(idx + 1)
}
// Not an arbitrary value
else {
;[root, value] = findRoot(baseWithoutModifier, designSystem.utilities)
}
// If the root is null, but it contains a `/`, then it could be that we are
// dealing with a functional utility that contains a modifier but doesn't
// contain a value.
//
// E.g.: `@container/parent`
if (root === null && base.includes('/')) {
let [rootWithoutModifier, rootModifierSegment = null] = segment(base, '/')
modifierSegment = rootModifierSegment
// Try to find the root and value, without the modifier present
;[root, value] = findRoot(rootWithoutModifier, designSystem.utilities)
;[root, value] = findRoot(baseWithoutModifier, (root: string) => {
return designSystem.utilities.has(root, 'functional')
})
}
// If there's no root, the candidate isn't a valid class and can be discarded.
@ -377,24 +377,6 @@ export function parseCandidate(input: string, designSystem: DesignSystem): Candi
// can skip any further parsing.
if (value === '') return null
let kind = designSystem.utilities.kind(root)
if (kind === 'static') {
// Static utilities do not have a value
if (value !== null) return null
// Static utilities do not have a modifier
if (modifierSegment !== null) return null
return {
kind: 'static',
root,
variants: parsedCandidateVariants,
negative,
important,
}
}
let candidate: Candidate = {
kind: 'functional',
root,
@ -560,7 +542,9 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
// - `group-hover/foo/bar`
if (additionalModifier) return null
let [root, value] = findRoot(variantWithoutModifier, designSystem.variants)
let [root, value] = findRoot(variantWithoutModifier, (root) => {
return designSystem.variants.has(root)
})
// Variant is invalid, therefore the candidate is invalid and we can skip
// continue parsing it.
@ -629,10 +613,10 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
function findRoot(
input: string,
lookup: { has: (input: string) => boolean },
exists: (input: string) => boolean,
): [string | null, string | null] {
// If the lookup has an exact match, then that's the root.
if (lookup.has(input)) return [input, null]
// If there is an exact match, then that's the root.
if (exists(input)) return [input, null]
// Otherwise test every permutation of the input by iteratively removing
// everything after the last dash.
@ -640,15 +624,14 @@ function findRoot(
if (idx === -1) {
// Variants starting with `@` are special because they don't need a `-`
// after the `@` (E.g.: `@-lg` should be written as `@lg`).
if (input[0] === '@' && lookup.has('@')) {
if (input[0] === '@' && exists('@')) {
return ['@', input.slice(1)]
}
return [null, null]
}
// Determine the root and value by testing permutations of the incoming input
// against the lookup table.
// Determine the root and value by testing permutations of the incoming input.
//
// In case of a candidate like `bg-red-500`, this looks like:
//
@ -658,7 +641,7 @@ function findRoot(
do {
let maybeRoot = input.slice(0, idx)
if (lookup.has(maybeRoot)) {
if (exists(maybeRoot)) {
return [maybeRoot, input.slice(idx + 1)]
}

View File

@ -117,14 +117,20 @@ export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem
// Handle named utilities
else if (candidate.kind === 'static' || candidate.kind === 'functional') {
// Safety: At this point it is safe to use TypeScript's non-null assertion
// operator because if the `candidate.root` didn't exist, `parseCandidate`
// would have returned `null` and we would have returned early resulting
// in not hitting this code path.
let { compileFn } = designSystem.utilities.get(candidate.root)!
let fns = designSystem.utilities.get(candidate.root)
// Build the node
let compiledNodes = compileFn(candidate)
let compiledNodes: AstNode[] | undefined
for (let i = fns.length - 1; i >= 0; i--) {
let fn = fns[i]
if (candidate.kind !== fn.kind) continue
compiledNodes = fn.compileFn(candidate)
if (compiledNodes) break
}
if (compiledNodes === undefined) return null
nodes = compiledNodes

View File

@ -17,9 +17,12 @@ import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme } from './theme'
import { segment } from './utils/segment'
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
type PluginAPI = {
addVariant(name: string, variant: string | string[] | CssInJs): void
}
type Plugin = (api: PluginAPI) => void
type CompileOptions = {
@ -52,6 +55,7 @@ export function compile(
let theme = new Theme()
let plugins: Plugin[] = []
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule: Rule | null = null
let keyframesRules: Rule[] = []
@ -65,6 +69,33 @@ export function compile(
return
}
// Collect custom `@utility` at-rules
if (node.selector.startsWith('@utility ')) {
let name = node.selector.slice(9).trim()
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.`,
)
}
customUtilities.push((designSystem) => {
designSystem.utilities.static(name, (candidate) => {
if (candidate.negative) return
return structuredClone(node.nodes)
})
})
replaceWith([])
return
}
// Register custom variants from `@variant` at-rules
if (node.selector.startsWith('@variant ')) {
if (parent !== null) {
@ -224,6 +255,10 @@ export function compile(
customVariant(designSystem)
}
for (let customUtility of customUtilities) {
customUtility(designSystem)
}
let api: PluginAPI = {
addVariant(name, variant) {
// Single selector

View File

@ -11,18 +11,13 @@ export type ClassEntry = [string, ClassMetadata]
export function getClassList(design: DesignSystem): ClassEntry[] {
let list: [string, ClassMetadata][] = []
for (let [utility, fn] of design.utilities.entries()) {
if (typeof utility !== 'string') {
continue
}
// Static utilities only work as-is
for (let utility of design.utilities.keys('static')) {
list.push([utility, { modifiers: [] }])
}
// Static utilities only work as-is
if (fn.kind === 'static') {
list.push([utility, { modifiers: [] }])
continue
}
// Functional utilities have their own list of completions
// Functional utilities have their own list of completions
for (let utility of design.utilities.keys('functional')) {
let completions = design.utilities.getCompletions(utility)
for (let group of completions) {

View File

@ -1,5 +1,6 @@
import { expect, test } from 'vitest'
import { compileCss, run } from './test-utils/run'
import { describe, expect, test } from 'vitest'
import { compile } from '.'
import { compileCss, optimizeCss, run } from './test-utils/run'
const css = String.raw
@ -14946,3 +14947,206 @@ test('@container', () => {
]),
).toEqual('')
})
describe('custom utilities', () => {
test('custom static utility', () => {
let compiled = compile(css`
@layer utilities {
@tailwind utilities;
}
@theme reference {
--breakpoint-lg: 1024px;
}
@utility text-trim {
text-box-trim: both;
text-box-edge: cap alphabetic;
}
`).build(['text-trim', 'lg:text-trim'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.text-trim {
text-box-trim: both;
text-box-edge: cap alphabetic;
}
@media (width >= 1024px) {
.lg\\:text-trim {
text-box-trim: both;
text-box-edge: cap alphabetic;
}
}
}"
`)
})
test('The later version of a static utility is used', () => {
let compiled = compile(css`
@layer utilities {
@tailwind utilities;
}
@utility really-round {
--custom-prop: hi;
border-radius: 50rem;
}
@utility really-round {
border-radius: 30rem;
}
`).build(['really-round'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.really-round {
border-radius: 30rem;
}
}"
`)
})
test('custom utilities support some special chracters', () => {
let compiled = compile(css`
@layer utilities {
@tailwind utilities;
}
@utility push-1/2 {
right: 50%;
}
@utility push-50% {
right: 50%;
}
`).build(['push-1/2', 'push-50%'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.push-1\\/2, .push-50\\% {
right: 50%;
}
}"
`)
})
test('can override specific versions of a functional utility with a static utility', () => {
let compiled = compile(css`
@layer utilities {
@tailwind utilities;
}
@theme reference {
--font-size-sm: 0.875rem;
--font-size-sm--line-height: 1.25rem;
}
@utility text-sm {
font-size: var(--font-size-sm, 0.875rem);
line-height: var(--font-size-sm--line-height, 1.25rem);
text-rendering: optimizeLegibility;
}
`).build(['text-sm'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.text-sm {
font-size: var(--font-size-sm, .875rem);
line-height: var(--font-size-sm--line-height, 1.25rem);
text-rendering: optimizelegibility;
}
}"
`)
})
test('can override the default value of a functional utility', () => {
let compiled = compile(css`
@layer utilities {
@tailwind utilities;
}
@theme reference {
--radius-xl: 16px;
}
@utility rounded {
border-radius: 50rem;
}
`).build(['rounded', 'rounded-xl', 'rounded-[33px]'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.rounded {
border-radius: 50rem;
}
.rounded-\\[33px\\] {
border-radius: 33px;
}
.rounded-xl {
border-radius: var(--radius-xl, 16px);
}
}"
`)
})
test('custom utilities are sorted by used properties', () => {
let compiled = compile(css`
@layer utilities {
@tailwind utilities;
}
@utility push-left {
right: 100%;
}
`).build(['top-[100px]', 'push-left', 'right-[100px]', 'bottom-[100px]'])
expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(`
"@layer utilities {
.top-\\[100px\\] {
top: 100px;
}
.push-left {
right: 100%;
}
.right-\\[100px\\] {
right: 100px;
}
.bottom-\\[100px\\] {
bottom: 100px;
}
}"
`)
})
test('custom utilities must use a valid name definitions ', () => {
expect(() =>
compile(css`
@utility push-* {
right: 100%;
}
`),
).toThrowError(/should be alphanumeric/)
expect(() =>
compile(css`
@utility ~push {
right: 100%;
}
`),
).toThrowError(/should be alphanumeric/)
expect(() =>
compile(css`
@utility @push {
right: 100%;
}
`),
).toThrowError(/should be alphanumeric/)
})
})

View File

@ -1,12 +1,11 @@
import { decl, rule, type AstNode, type Rule } from './ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from './candidate'
import type { ColorThemeKey, Theme, ThemeKey } from './theme'
import { DefaultMap } from './utils/default-map'
import { inferDataType } from './utils/infer-data-type'
import { replaceShadowColors } from './utils/replace-shadow-colors'
import { segment } from './utils/segment'
const ARBITRARY_VARIANT = Symbol('ARBITRARY_VARIANT')
type CompileFn<T extends Candidate['kind']> = (
value: Extract<Candidate, { kind: T }>,
) => AstNode[] | undefined
@ -29,38 +28,35 @@ type SuggestionDefinition =
}
export class Utilities {
private utilities = new Map<
string | symbol,
private arbitraryFn!: CompileFn<'arbitrary'>
private utilities = new DefaultMap<
string,
{
kind: Candidate['kind']
kind: 'static' | 'functional'
compileFn: CompileFn<any>
}
>()
}[]
>(() => [])
private completions = new Map<string, () => SuggestionGroup[]>()
static(name: string, compileFn: CompileFn<'static'>) {
this.set(name, { kind: 'static', compileFn: compileFn })
this.utilities.get(name).push({ kind: 'static', compileFn })
}
functional(name: string, compileFn: CompileFn<'functional'>) {
this.set(name, { kind: 'functional', compileFn: compileFn })
this.utilities.get(name).push({ kind: 'functional', compileFn })
}
arbitrary(compileFn: CompileFn<'arbitrary'>) {
this.set(ARBITRARY_VARIANT, { kind: 'arbitrary', compileFn: compileFn })
this.arbitraryFn = compileFn
}
has(name: string) {
return this.utilities.has(name)
has(name: string, kind: 'static' | 'functional') {
return this.utilities.has(name) && this.utilities.get(name).some((fn) => fn.kind === kind)
}
get(name: string | symbol) {
return this.utilities.get(name)
}
kind(name: string) {
return this.utilities.get(name)!.kind
get(name: string) {
return this.utilities.has(name) ? this.utilities.get(name) : []
}
getCompletions(name: string): SuggestionGroup[] {
@ -73,35 +69,23 @@ export class Utilities {
this.completions.set(name, groups)
}
keys() {
return this.utilities.keys()
}
keys(kind: 'static' | 'functional') {
let keys: string[] = []
entries() {
return this.utilities.entries()
}
getArbitrary() {
return this.get(ARBITRARY_VARIANT)!.compileFn
}
private set<T extends Candidate['kind']>(
name: string | symbol,
{ kind, compileFn }: { kind: T; compileFn: CompileFn<T> },
) {
// In test mode, throw an error if we accidentally override another utility
// by mistake when implementing a new utility that shares the same root
// without realizing the definitions need to be merged.
if (process.env.NODE_ENV === 'test') {
if (this.utilities.has(name)) {
throw new Error(`Duplicate utility prefix [${name.toString()}]`)
for (let [key, fns] of this.utilities.entries()) {
for (let fn of fns) {
if (fn.kind === kind) {
keys.push(key)
break
}
}
}
this.utilities.set(name, {
kind,
compileFn: compileFn,
})
return keys
}
getArbitrary() {
return this.arbitraryFn
}
}