diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index f324dfad8..3b5a2fb02 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -11,16 +11,49 @@ import { type CssInJs, type Rule, } from './ast' +import type { Candidate } from './candidate' import { compileCandidates } from './compile' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme } from './theme' +import { withAlpha, withNegative } from './utilities' +import { inferDataType } from './utils/infer-data-type' import { segment } from './utils/segment' const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/ +const IS_VALID_UTILITY_SELECTOR = /^\.[a-z][a-zA-Z0-9/%._-]*$/ type PluginAPI = { addVariant(name: string, variant: string | string[] | CssInJs): void + addUtilities( + utilities: Record, + options?: Partial<{ + // todo: maybe not necessary any more + // respectPrefix: boolean + + // todo: needed?? + respectImportant: boolean + }>, + ): void + matchUtilities( + utilities: Record CssInJs>, + options?: Partial<{ + type: string | string[] + + // todo: maybe not necessary any more + // respectPrefix: boolean + + // todo: needed?? + respectImportant: boolean + + supportsNegativeValues: boolean + + values: Record + modifiers: 'any' | Record + }>, + ): void + + theme(path: string, fallback?: any): any } type Plugin = (api: PluginAPI) => void @@ -280,6 +313,202 @@ export function compile( designSystem.variants.fromAst(name, objectToAst(variant)) } }, + + addUtilities(utilities) { + for (let [name, css] of Object.entries(utilities)) { + if (!IS_VALID_UTILITY_SELECTOR.test(name)) { + throw new Error( + `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities are a single class that is alphanumeric and starts with a lowercase letter.`, + ) + } + + designSystem.utilities.static(name.slice(1), (candidate) => { + if (candidate.negative) return + + return objectToAst(css) + }) + } + }, + + matchUtilities(utilities, options) { + type Resolvable = + | Extract['value'] + | Extract['modifier'] + + let invalid = Symbol('invalid') + + let types = options?.type + ? Array.isArray(options?.type) + ? options.type + : [options.type] + : [] + + function resolve( + item: Resolvable, + list: 'any' | Record | null, + resolveBare: ((value: string) => string | null) | null, + ) { + if (!item) { + if (list && typeof list === 'object' && list.DEFAULT) { + return list.DEFAULT + } + + // Falsy values are invalid + return null + } + + // Arbitrary values and modifiers are also used as-is + if (item.kind === 'arbitrary') return item.value + + // In the case of modifiers: 'any' the value we're passed can be used as-is + if (list === 'any') return item.value + + // There's no list of valid, named values so this is invalid + if (!list) return null + + // If the value isn't in the list: + if (!(item.value in list)) { + // And bare "values" (modifiers?) are supported then try to use that + if (resolveBare) { + if (Number.isNaN(Number(item.value))) { + return invalid + } + + return resolveBare(item.value) ?? invalid + } + + // Otherwise it is invalid + return invalid + } + + // Otherwise we'll return the value supplied by list + // - `options.values` + // - `options.modifiers` + return list[item.value] + } + + for (let [name, fn] of Object.entries(utilities)) { + if (!IS_VALID_UTILITY_NAME.test(name)) { + throw new Error( + `\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter.`, + ) + } + + designSystem.utilities.functional(name, (candidate) => { + // Any negative candiate without support is invalid + if (!options?.supportsNegativeValues && candidate.negative) return + + // If this utility supports color values — try resolving as a color + let modifiers = options?.modifiers ?? null + + if (types.includes('color')) { + // Colors implicitly support modifiers when no modifiers are provided + // They're read from the opacity scale' + if (!modifiers) { + modifiers = Object.fromEntries(theme.namespace('--opacity').entries()) + } + } + + if (candidate.modifier && !modifiers) return + + let modifier = resolve(candidate.modifier, modifiers, (value) => { + if (!types.includes('color')) return null + return `${value}%` + }) + + if (modifier === invalid) return + + let value = resolve( + candidate.value, + { + inherit: 'inherit', + transparent: 'transparent', + current: 'currentColor', + ...(options?.values ?? null), + }, + null, + ) + + if (!value) return + if (value === invalid) return + + if (types.includes('color') && modifier) { + value = withAlpha(value, modifier) + } + + // Throw out any candidate whose value isn't not of a support type + if (candidate.value?.kind === 'arbitrary' && types.length > 0 && !types.includes('any')) { + // Bail when the candidate has an explicit data type but it's not in + // the list of supported types by this utility For example, given a + // `scrollbar` utility that is used to change its color: + // scrollbar-[length:var(--whatever)] + if (candidate.value.dataType && !types.includes(candidate.value.dataType)) { + return + } + + // We also need to bail when the candidate does not have an explicit + // type and we're not able to infer it as one of the supported types. + if ( + !candidate.value.dataType && + !inferDataType(candidate.value.value, types as any[]) + ) { + return + } + } + + if (candidate.negative) { + value = withNegative(value, candidate) + } + + return objectToAst(fn(value, { modifier })) + }) + } + }, + + theme(path: string, fallback?: any) { + if (path.startsWith('--')) { + if (path.endsWith('-*')) { + return Object.fromEntries(theme.namespace(path.slice(0, -2) as any).entries()) + } + + return theme.resolveValue(null, [path] as any) ?? fallback ?? null + } + + path = path + // Escape dots used inside square brackets + .replace(/\[(.*?)\]/g, (_, value) => `-${value.replace('.', '_')}`) + // Replace dots with dashes + .replace(/\./g, '-') + // Replace camelCase with dashes + .replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) + + // Prepend with `--` to match CSS variables + path = `--${path}` + + let map = theme.namespace(path as any) + + // Does the requested value exist in the theme + if (map.has(null)) { + // Yes, and there are multiple values in the requested theme namespace + if (map.size > 1) { + return { + DEFAULT: map.get(null), + ...Object.fromEntries(Array.from(map.entries()).filter(([key]) => key !== null)), + } + } + + // Nope, just the one + return map.get(null) + } + + // There is at least one value in the requested theme namespace + // but no default value + if (map.size > 0) { + return Object.fromEntries(map.entries()) + } + + return fallback ?? null + }, } for (let plugin of plugins) { diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 4135b1cdd..7b19577c1 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15150,3 +15150,660 @@ describe('custom utilities', () => { ).toThrowError(/should be alphanumeric/) }) }) + +describe('legacy: addUtilities', () => { + test('custom static utility', () => { + let compiled = compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ addUtilities }) => { + addUtilities({ + '.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('throws on custom static utilities with an invalid name', () => { + expect(() => { + return compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ addUtilities }) => { + addUtilities({ + '.text-trim > *': { + 'text-box-trim': 'both', + 'text-box-edge': 'cap alphabetic', + }, + }) + } + }, + }, + ) + }).toThrowError(/invalid utility selector/) + }) +}) + +describe('legacy: matchUtilities', () => { + test('custom functional utility', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities( + { + 'border-block': (value) => { + return { + 'border-block-width': value, + } + }, + }, + { + values: { + DEFAULT: '1px', + '2': '2px', + }, + }, + ) + } + }, + }, + ).build(candidates) + } + + expect( + optimizeCss( + run([ + 'border-block', + 'border-block-2', + 'border-block-[35px]', + 'border-block-[var(--foo)]', + 'lg:border-block-2', + ]), + ).trim(), + ).toMatchInlineSnapshot(` + ".border-block { + border-block-width: 1px; + } + + .border-block-2 { + border-block-width: 2px; + } + + .border-block-\\[35px\\] { + border-block-width: 35px; + } + + .border-block-\\[var\\(--foo\\)\\] { + border-block-width: var(--foo); + } + + @media (width >= 1024px) { + .lg\\:border-block-2 { + border-block-width: 2px; + } + }" + `) + + expect( + optimizeCss( + run([ + '-border-block', + '-border-block-2', + 'lg:-border-block-2', + 'border-block-unknown', + 'border-block/1', + ]), + ).trim(), + ).toEqual('') + }) + + test('custom functional utility with any modifier', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities( + { + 'border-block': (value, { modifier }) => { + return { + '--my-modifier': modifier ?? 'none', + 'border-block-width': value, + } + }, + }, + { + values: { + DEFAULT: '1px', + '2': '2px', + }, + + modifiers: 'any', + }, + ) + } + }, + }, + ).build(candidates) + } + + expect( + optimizeCss( + run(['border-block', 'border-block-2', 'border-block/foo', 'border-block-2/foo']), + ).trim(), + ).toMatchInlineSnapshot(` + ".border-block { + --my-modifier: none; + border-block-width: 1px; + } + + .border-block-2 { + --my-modifier: none; + border-block-width: 2px; + } + + .border-block-2\\/foo { + --my-modifier: foo; + border-block-width: 2px; + } + + .border-block\\/foo { + --my-modifier: foo; + border-block-width: 1px; + }" + `) + }) + + test('custom functional utility with known modifier', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities( + { + 'border-block': (value, { modifier }) => { + return { + '--my-modifier': modifier ?? 'none', + 'border-block-width': value, + } + }, + }, + { + values: { + DEFAULT: '1px', + '2': '2px', + }, + + modifiers: { + foo: 'foo', + }, + }, + ) + } + }, + }, + ).build(candidates) + } + + expect( + optimizeCss( + run([ + 'border-block', + 'border-block-2', + 'border-block/foo', + 'border-block-2/foo', + 'border-block/unknown', + 'border-block-2/unknown', + ]), + ).trim(), + ).toMatchInlineSnapshot(` + ".border-block { + --my-modifier: none; + border-block-width: 1px; + } + + .border-block-2 { + --my-modifier: none; + border-block-width: 2px; + } + + .border-block-2\\/foo { + --my-modifier: foo; + border-block-width: 2px; + } + + .border-block\\/foo { + --my-modifier: foo; + border-block-width: 1px; + }" + `) + }) + + test('throws on custom utilities with an invalid name', () => { + expect(() => { + return compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities({ + '.text-trim > *': () => ({ + 'text-box-trim': 'both', + 'text-box-edge': 'cap alphabetic', + }), + }) + } + }, + }, + ) + }).toThrowError(/invalid utility name/) + }) + + test('custom functional utilities with different types', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --breakpoint-lg: 1024px; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities( + { + scrollbar: (value) => { + return { + 'scrollbar-color': value, + } + }, + }, + { + type: ['color', 'any'], + values: { + black: 'black', + }, + }, + ) + + matchUtilities( + { + scrollbar: (value) => { + return { + 'scrollbar-width': value, + } + }, + }, + { + type: ['length'], + values: { + 2: '2px', + }, + }, + ) + } + }, + }, + ).build(candidates) + } + + expect( + optimizeCss( + run([ + 'scrollbar-black', + 'scrollbar-2', + 'scrollbar-[#fff]', + 'scrollbar-[2px]', + 'scrollbar-[var(--my-color)]', + 'scrollbar-[color:var(--my-color)]', + 'scrollbar-[length:var(--my-width)]', + ]), + ).trim(), + ).toMatchInlineSnapshot(` + ".scrollbar-2 { + scrollbar-width: 2px; + } + + .scrollbar-\\[\\#fff\\] { + scrollbar-color: #fff; + } + + .scrollbar-\\[2px\\] { + scrollbar-width: 2px; + } + + .scrollbar-\\[color\\:var\\(--my-color\\)\\] { + scrollbar-color: var(--my-color); + } + + .scrollbar-\\[length\\:var\\(--my-width\\)\\] { + scrollbar-width: var(--my-width); + } + + .scrollbar-\\[var\\(--my-color\\)\\] { + scrollbar-color: var(--my-color); + } + + .scrollbar-black { + scrollbar-color: black; + }" + `) + }) + + test('custom utility that reads from the theme', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --scrollbar-big: 20px; + }, + `, + { + loadPlugin() { + return ({ matchUtilities, theme }) => { + matchUtilities( + { + scrollbar: (value, { modifier }) => { + return { + '--my-modifier': modifier ?? 'none', + 'border-block-width': value, + } + }, + }, + { + values: theme('scrollbar'), + }, + ) + } + }, + }, + ).build(candidates) + } + + expect(optimizeCss(run(['scrollbar-big'])).trim()).toMatchInlineSnapshot(` + ".scrollbar-big { + --my-modifier: none; + border-block-width: 20px; + }" + `) + }) + + test('functional utilities with type: color automatically support opacity', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --breakpoint-lg: 1024px; + --opacity-my-opacity: 0.5; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities( + { + scrollbar: (value) => { + return { + 'scrollbar-color': value, + } + }, + }, + { + type: ['color', 'any'], + values: { + black: 'black', + }, + }, + ) + } + }, + }, + ).build(candidates) + } + + expect( + optimizeCss( + run([ + 'scrollbar-current', + 'scrollbar-current/45', + 'scrollbar-black', + 'scrollbar-black/my-opacity', + 'scrollbar-black/33', + 'scrollbar-black/[50%]', + 'scrollbar-[var(--my-color)]/[25%]', + ]), + ).trim(), + ).toMatchInlineSnapshot(` + ".scrollbar-\\[var\\(--my-color\\)\\]\\/\\[25\\%\\] { + scrollbar-color: color-mix(in srgb, var(--my-color) 25%, transparent); + } + + .scrollbar-black { + scrollbar-color: black; + } + + .scrollbar-black\\/33 { + scrollbar-color: #00000054; + } + + .scrollbar-black\\/\\[50\\%\\], .scrollbar-black\\/my-opacity { + scrollbar-color: #00000080; + } + + .scrollbar-current { + scrollbar-color: currentColor; + } + + .scrollbar-current\\/45 { + scrollbar-color: color-mix(in srgb, currentColor 45%, transparent); + }" + `) + }) + + test('functional utilities with type: color and explicit modifiers', () => { + function run(candidates: string[]) { + return compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --breakpoint-lg: 1024px; + --opacity-my-opacity: 0.5; + }, + `, + { + loadPlugin() { + return ({ matchUtilities }) => { + matchUtilities( + { + scrollbar: (value, { modifier }) => { + return { + '--modifier': modifier ?? 'none', + 'scrollbar-width': value, + } + }, + }, + { + type: ['any'], + values: {}, + modifiers: { + foo: 'foo', + }, + }, + ) + } + }, + }, + ).build(candidates) + } + + expect( + optimizeCss(run(['scrollbar-[12px]', 'scrollbar-[12px]/foo', 'scrollbar-[12px]/bar'])).trim(), + ).toMatchInlineSnapshot(` + ".scrollbar-\\[12px\\] { + --modifier: none; + scrollbar-width: 12px; + } + + .scrollbar-\\[12px\\]\\/foo { + --modifier: foo; + scrollbar-width: 12px; + }" + `) + }) + + test('reading from the theme', () => { + expect.hasAssertions() + + compile( + css` + @plugin "my-plugin"; + @theme reference { + --size-2_5: 2.5rem; + + --scrollbar-big: 20px; + --scrollbar-big-properties: auto-hidden; + + --scrollbar-color-light: white; + --scrollbar-color-dark: black; + }, + `, + { + loadPlugin() { + return ({ theme }) => { + // Accessing w/ CSS property syntax + expect(theme('--scrollbar')).toEqual(null) + expect(theme('--scrollbar-*')).toEqual({ + big: '20px', + 'big-properties': 'auto-hidden', + 'color-dark': 'black', + 'color-light': 'white', + }) + + expect(theme('--scrollbar-big')).toEqual('20px') + + // Accessing via legacy dot notation + expect(theme('size.2_5')).toEqual('2.5rem') + expect(theme('scrollbar')).toEqual({ + big: '20px', + 'big-properties': 'auto-hidden', + 'color-dark': 'black', + 'color-light': 'white', + }) + + expect(theme('scrollbar.big')).toEqual({ + DEFAULT: '20px', + properties: 'auto-hidden', + }) + expect(theme('scrollbar.big.properties')).toEqual('auto-hidden') + + expect(theme('scrollbar.color')).toEqual({ + light: 'white', + dark: 'black', + }) + + expect(theme('scrollbar.foo', 'nope')).toEqual('nope') + expect(theme('somekey', 'nope')).toEqual('nope') + + // Square bracket syntax + expect(theme('size[2.5]')).toEqual('2.5rem') + } + }, + }, + ) + }) +}) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 5a61869a0..f9ac90ee6 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -110,7 +110,7 @@ function property(ident: string, initialValue?: string, syntax?: string) { /** * Apply opacity to a color using `color-mix`. */ -function withAlpha(value: string, alpha: string): string { +export function withAlpha(value: string, alpha: string): string { if (alpha === null) return value // Convert numeric values (like `0.5`) to percentages (like `50%`) so they @@ -159,7 +159,7 @@ function asColor(value: string, modifier: CandidateModifier | null, theme: Theme /** * Negate a numeric value — literals get simplified by Lightning CSS. */ -function withNegative( +export function withNegative( value: string, candidate: Extract, ) {