From 1a1d0dffb282a01c51b675be903b1367359c83e2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 24 Jul 2024 16:01:23 -0400 Subject: [PATCH 1/6] 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, ) { From 542a47401f751747c306ed0b5e95aac7b1f8314e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 13:08:56 -0400 Subject: [PATCH 2/6] wip --- packages/tailwindcss/src/index.ts | 166 ++++++++++++++------- packages/tailwindcss/src/utilities.test.ts | 153 ++++++++++++++++++- packages/tailwindcss/src/utilities.ts | 2 +- 3 files changed, 254 insertions(+), 67 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index e972201e0..684ffabbb 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -11,11 +11,12 @@ 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 { withNegative } from './utilities' +import { withAlpha, withNegative } from './utilities' import { inferDataType } from './utils/infer-data-type' import { segment } from './utils/segment' @@ -330,6 +331,62 @@ export function compile( }, 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( @@ -338,65 +395,66 @@ export function compile( } designSystem.utilities.functional(name, (candidate) => { - if (!options?.supportsNegativeValues) { - if (candidate.negative) return - } + // Any negative candiate without support is invalid + if (!options?.supportsNegativeValues && candidate.negative) return - if (candidate.modifier && !options?.modifiers) return + // If this utility supports color values — try resolving as a color + let modifiers = options?.modifiers ?? null - 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 + 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()) } } - // TODO: DEFAULT ?? - if (!candidate.value) { - let value = options?.values?.DEFAULT ?? null + if (candidate.modifier && !modifiers) return - if (!value) return + let modifier = resolve(candidate.modifier, modifiers, (value) => { + if (!types.includes('color')) return null + return `${value}%` + }) - if (candidate.negative) { - value = withNegative(value, candidate) - } + if (modifier === invalid) return - return objectToAst(fn(value, { modifier })) - } + let value = resolve( + candidate.value, + { + inherit: 'inherit', + transparent: 'transparent', + current: 'currentColor', + ...(options?.values ?? null), + }, + null, + ) - 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 (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) @@ -420,14 +478,6 @@ export function compile( 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()) } diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 6ca27d9bf..5b45c525e 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15440,7 +15440,7 @@ describe('legacy: matchUtilities', () => { `) }) - test('throws on custom static utilities with an invalid name', () => { + test('throws on custom utilities with an invalid name', () => { expect(() => { return compile( css` @@ -15493,7 +15493,7 @@ describe('legacy: matchUtilities', () => { }, }, { - type: ['color'], + type: ['color', 'any'], values: { black: 'black', }, @@ -15528,6 +15528,7 @@ describe('legacy: matchUtilities', () => { 'scrollbar-2', 'scrollbar-[#fff]', 'scrollbar-[2px]', + 'scrollbar-[var(--my-color)]', 'scrollbar-[color:var(--my-color)]', 'scrollbar-[length:var(--my-width)]', ]), @@ -15553,13 +15554,17 @@ describe('legacy: matchUtilities', () => { scrollbar-width: var(--my-width); } + .scrollbar-\\[var\\(--my-color\\)\\] { + scrollbar-color: var(--my-color); + } + .scrollbar-black { scrollbar-color: black; }" `) }) - test.skip('custom utility that reads from the theme', () => { + test('custom utility that reads from the theme', () => { function run(candidates: string[]) { return compile( css` @@ -15593,18 +15598,150 @@ describe('legacy: matchUtilities', () => { ).build(candidates) } - expect(optimizeCss(run(['scrollbar-big'])).trim()).toMatchInlineSnapshot() + expect(optimizeCss(run(['scrollbar-big'])).trim()).toMatchInlineSnapshot(` + ".scrollbar-big { + --my-modifier: none; + border-block-width: 20px; + }" + `) }) - test('custom utility that reads from the theme', () => { + 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"; - - @tailwind utilities; - @theme reference { --scrollbar-big: 20px; --scrollbar-big-properties: auto-hidden; diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 5a51a8b7f..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 From 0b69c4c9155057850c694019b2250ee3e1daa3b1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 15:46:28 -0400 Subject: [PATCH 3/6] =?UTF-8?q?Add=20support=20for=20square=20bracket=20sy?= =?UTF-8?q?ntax=20for=20`theme(=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 684ffabbb..3b536e085 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -467,6 +467,8 @@ export function compile( theme(path: string, fallback?: any) { path = path + // Escape dots used inside square brackets + .replace(/\[(.*?)\]/g, (_, value) => `-${value.replace('.', '_')}`) // Replace dots with dashes .replace(/\./g, '-') // Replace camelCase with dashes From f463878ecb913b0d1830cf0d4e1a067df681e791 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 16:02:21 -0400 Subject: [PATCH 4/6] =?UTF-8?q?Allow=20passing=20CSS=20properties=20to=20`?= =?UTF-8?q?theme(=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 3b536e085..a9f1b69d8 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -466,6 +466,14 @@ export function compile( }, 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('.', '_')}`) From fcee7e1eac3cc13b1ede0d3a77bad4b14d140f80 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 16:02:26 -0400 Subject: [PATCH 5/6] Add more tests --- packages/tailwindcss/src/utilities.test.ts | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 5b45c525e..627e2cb9d 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15264,7 +15264,13 @@ describe('legacy: matchUtilities', () => { expect( optimizeCss( - run(['border-block', 'border-block-2', 'border-block-[35px]', 'lg:border-block-2']), + run([ + 'border-block', + 'border-block-2', + 'border-block-[35px]', + 'border-block-[var(--foo)]', + 'lg:border-block-2', + ]), ).trim(), ).toMatchInlineSnapshot(` ".border-block { @@ -15279,6 +15285,10 @@ describe('legacy: matchUtilities', () => { border-block-width: 35px; } + .border-block-\\[var\\(--foo\\)\\] { + border-block-width: var(--foo); + } + @media (width >= 1024px) { .lg\\:border-block-2 { border-block-width: 2px; @@ -15743,20 +15753,48 @@ describe('legacy: matchUtilities', () => { 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 }) => { + expect(theme('size.2_5')).toEqual('2.5rem') + + // Square bracket syntax + expect(theme('size[2.5]')).toEqual('2.5rem') + + // 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('scrollbar')).toEqual({ big: '20px', 'big-properties': 'auto-hidden', + 'color-dark': 'black', + 'color-light': 'white', }) expect(theme('scrollbar.big')).toEqual('20px') expect(theme('scrollbar.big.properties')).toEqual('auto-hidden') + + expect(theme('scrollbar.color')).toEqual({ + light: 'white', + dark: 'black', + }) } }, }, From 81d2a7562ed7ecaeeb0de5f08d3fb0d23276259b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 16:35:33 -0400 Subject: [PATCH 6/6] Tweak theme function behavior --- packages/tailwindcss/src/index.ts | 25 +++++++++++++++++----- packages/tailwindcss/src/utilities.test.ts | 18 ++++++++++------ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index a9f1b69d8..3b5a2fb02 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -485,14 +485,29 @@ export function compile( // Prepend with `--` to match CSS variables path = `--${path}` - let value = - theme.resolveValue(null, [path] as any) ?? theme.namespace(path as any) ?? fallback + let map = theme.namespace(path as any) - if (value && typeof value === 'object' && value instanceof Map) { - return Object.fromEntries(value.entries()) + // 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) } - return value + // 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 }, } diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 627e2cb9d..7b19577c1 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15765,11 +15765,6 @@ describe('legacy: matchUtilities', () => { { loadPlugin() { return ({ theme }) => { - expect(theme('size.2_5')).toEqual('2.5rem') - - // Square bracket syntax - expect(theme('size[2.5]')).toEqual('2.5rem') - // Accessing w/ CSS property syntax expect(theme('--scrollbar')).toEqual(null) expect(theme('--scrollbar-*')).toEqual({ @@ -15778,9 +15773,11 @@ describe('legacy: matchUtilities', () => { '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', @@ -15788,13 +15785,22 @@ describe('legacy: matchUtilities', () => { 'color-light': 'white', }) - expect(theme('scrollbar.big')).toEqual('20px') + 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') } }, },