From 542a47401f751747c306ed0b5e95aac7b1f8314e Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 26 Jul 2024 13:08:56 -0400 Subject: [PATCH] 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