From 1a1d0dffb282a01c51b675be903b1367359c83e2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 24 Jul 2024 16:01:23 -0400 Subject: [PATCH] Work on `matchUtilities` API --- packages/tailwindcss/src/index.ts | 154 +++++++ packages/tailwindcss/src/utilities.test.ts | 476 +++++++++++++++++++++ packages/tailwindcss/src/utilities.ts | 2 +- 3 files changed, 631 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index f324dfad8..e972201e0 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -15,12 +15,44 @@ import { compileCandidates } from './compile' import * as CSS from './css-parser' import { buildDesignSystem, type DesignSystem } from './design-system' import { Theme } from './theme' +import { 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 +312,128 @@ 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) { + 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) => { + if (!options?.supportsNegativeValues) { + if (candidate.negative) return + } + + if (candidate.modifier && !options?.modifiers) return + + let modifier: string | null = null + + if (options?.modifiers === 'any') { + modifier = candidate.modifier?.value ?? null + } else if (options?.modifiers && candidate.modifier) { + if (candidate.modifier.kind === 'arbitrary') { + modifier = candidate.modifier.value + } else { + modifier = options.modifiers[candidate.modifier.value] ?? null + if (modifier === null) return + } + } + + // TODO: DEFAULT ?? + if (!candidate.value) { + let value = options?.values?.DEFAULT ?? null + + if (!value) return + + if (candidate.negative) { + value = withNegative(value, candidate) + } + + return objectToAst(fn(value, { modifier })) + } + + if (candidate.value.kind === 'arbitrary') { + let value = candidate.value.value + let types = options?.type + ? Array.isArray(options?.type) + ? options.type + : [options.type] + : [] + + if (types.length > 0) { + if (candidate.value.dataType) { + if (!types.includes(candidate.value.dataType) && !types.includes('any')) return + } else { + let dataType = inferDataType(value, types as any[]) + if (!dataType) return + } + } + + if (candidate.negative) { + value = withNegative(value, candidate) + } + + return objectToAst(fn(value, { modifier })) + } + + // Look up in values: {…} + let value = options?.values?.[candidate.value.value] ?? null + if (!value) return + + if (candidate.negative) { + value = withNegative(value, candidate) + } + + return objectToAst(fn(value, { modifier })) + }) + } + }, + + theme(path: string, fallback?: any) { + path = path + // 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 value = + theme.resolveValue(null, [path] as any) ?? theme.namespace(path as any) ?? fallback + + console.log({ + path, + value, + ns: theme.namespace(path as any), + tg: theme.get(path as any), + trv: theme.resolveValue(null, [path] as any), + }) + + if (value && typeof value === 'object' && value instanceof Map) { + return Object.fromEntries(value.entries()) + } + + return value + }, } for (let plugin of plugins) { diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 4135b1cdd..6ca27d9bf 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15150,3 +15150,479 @@ 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]', '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; + } + + @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 static 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'], + 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-[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-black { + scrollbar-color: black; + }" + `) + }) + + test.skip('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() + }) + + test('custom utility that reads from the theme', () => { + expect.hasAssertions() + + compile( + css` + @plugin "my-plugin"; + + @tailwind utilities; + + @theme reference { + --scrollbar-big: 20px; + --scrollbar-big-properties: auto-hidden; + }, + `, + { + loadPlugin() { + return ({ theme }) => { + expect(theme('scrollbar')).toEqual({ + big: '20px', + 'big-properties': 'auto-hidden', + }) + + expect(theme('scrollbar.big')).toEqual('20px') + expect(theme('scrollbar.big.properties')).toEqual('auto-hidden') + } + }, + }, + ) + }) +}) diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 5a61869a0..5a51a8b7f 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -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, ) {