This commit is contained in:
Jordan Pittman 2024-07-26 13:08:56 -04:00
parent 1a1d0dffb2
commit 542a47401f
3 changed files with 254 additions and 67 deletions

View file

@ -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<Candidate, { kind: 'functional' }>['value']
| Extract<Candidate, { kind: 'functional' }>['modifier']
let invalid = Symbol('invalid')
let types = options?.type
? Array.isArray(options?.type)
? options.type
: [options.type]
: []
function resolve(
item: Resolvable,
list: 'any' | Record<string, any> | 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())
}

View file

@ -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;

View file

@ -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