Skip to content

Commit daf2f15

Browse files
committed
Add CSS color bridge helpers
1 parent 12fabc3 commit daf2f15

15 files changed

Lines changed: 352 additions & 8 deletions

File tree

packages/css-to-rn/src/colors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export function evaluateCssColors (input: string): string {
3838
return output
3939
}
4040

41+
export function isCssColor (input: string): boolean {
42+
const color = colordx(evaluateCssColors(input.trim()))
43+
return color.isValid()
44+
}
45+
4146
function evaluateColorMix (body: string): string | null {
4247
const parts = splitTopLevelComma(body).map(part => part.trim()).filter(Boolean)
4348
if (parts.length !== 3) return null

packages/css-to-rn/src/compiler.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,21 @@ function validateBuildDeclaration (
413413
dimensions: {
414414
width: 100,
415415
height: 100
416-
}
416+
},
417+
deprecateUUnits: true
417418
})
418419

420+
if (resolved.valid) {
421+
for (const item of resolved.diagnostics) {
422+
addDiagnostic(state, diagnostic(
423+
item.code,
424+
item.message,
425+
item.level,
426+
position
427+
))
428+
}
429+
}
430+
419431
if (!resolved.valid) {
420432
for (const item of resolved.diagnostics) {
421433
addDiagnostic(state, diagnostic(

packages/css-to-rn/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export {
1111
export {
1212
resolveCssValue
1313
} from './values.ts'
14+
export {
15+
u
16+
} from './units.ts'
1417
export {
1518
createCssxCache,
1619
cssx,

packages/css-to-rn/src/react-native.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export {
77
export {
88
resolveCssValue
99
} from './values.ts'
10+
export {
11+
u
12+
} from './units.ts'
13+
import {
14+
resetUWarningForTests
15+
} from './units.ts'
1016
import {
1117
cssx as baseCssx,
1218
clearRawCssCacheForTests
@@ -73,12 +79,17 @@ export {
7379
useCssxRuntimeContext
7480
} from './react/config.ts'
7581
export {
82+
getCssColor,
7683
getCssVariable,
7784
getCssVariableRaw,
7885
useMedia,
86+
useCssColor,
7987
useCssVariable,
8088
useCssVariableRaw
8189
} from './react/hooks.ts'
90+
export type {
91+
CssColorMixInput
92+
} from './react/hooks.ts'
8293
export {
8394
TrackedCssxSheet,
8495
isTrackedCssxSheet
@@ -151,6 +162,7 @@ export const __cssxInternals = {
151162
flushMicrotasksForTests,
152163
getRuntimeSubscriberCountForTests,
153164
resetStoreForTests,
165+
resetUWarningForTests,
154166
setColorSchemeForTests,
155167
setDimensionsForTests,
156168
subscribeVariablesForTests

packages/css-to-rn/src/react/hooks.ts

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useSyncExternalStore
77
} from 'react'
88
import { compileCss } from '../compiler.ts'
9+
import { isCssColor } from '../colors.ts'
910
import { evaluateCssxMediaQuery } from '../resolve.ts'
1011
import type { CompiledCssSheet } from '../types.ts'
1112
import {
@@ -15,7 +16,8 @@ import {
1516
} from './config.ts'
1617
import {
1718
coerceCssValue,
18-
resolveCssValue
19+
resolveCssValue,
20+
type ResolveCssValueResult
1921
} from '../values.ts'
2022
import {
2123
createDependencySnapshot,
@@ -36,6 +38,8 @@ const useCommitEffect = typeof window === 'undefined'
3638
? useEffect
3739
: useLayoutEffect
3840
const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/
41+
const CSS_COLOR_FUNCTION_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i
42+
const CSS_COLOR_TOKEN_RE = /^[A-Za-z][A-Za-z0-9_-]*$/
3943
const DEFAULT_CUSTOM_MEDIA: Record<string, string> = {
4044
'--breakpoint-mobile': '(width < 48rem)',
4145
'--breakpoint-tablet': '(width >= 48rem)',
@@ -87,6 +91,14 @@ export type CssxLayerHookOutput =
8791
| undefined
8892
| false
8993

94+
export type CssColorMixInput =
95+
| number
96+
| string
97+
| {
98+
mix?: number | string
99+
with?: string
100+
}
101+
90102
export function useCssxSheet (
91103
sheet: CompiledCssSheet,
92104
options: CssxReactConfig = {}
@@ -192,7 +204,7 @@ export function useCssVariableRaw (
192204
const context = useCssxRuntimeContext()
193205
const committedDependenciesRef = useRef<CssxDependencySnapshot>(createDependencySnapshot())
194206
const result = resolveCssVariableRaw(name, fallback, context.scopedVariables)
195-
const renderDependencies = createVariableDependencySnapshot(result)
207+
const renderDependencies = createCssValueDependencySnapshot(result)
196208

197209
useSyncExternalStore(
198210
listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current),
@@ -215,6 +227,28 @@ export function useCssVariable (
215227
return value == null ? value : coerceCssValue(value)
216228
}
217229

230+
export function useCssColor (
231+
color: string,
232+
mix?: CssColorMixInput
233+
): string | undefined {
234+
const context = useCssxRuntimeContext()
235+
const committedDependenciesRef = useRef<CssxDependencySnapshot>(createDependencySnapshot())
236+
const result = resolveCssColor(color, mix, context.scopedVariables)
237+
const renderDependencies = createCssValueDependencySnapshot(result)
238+
239+
useSyncExternalStore(
240+
listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current),
241+
getRuntimeVersion,
242+
getRuntimeVersion
243+
)
244+
245+
useCommitEffect(() => {
246+
committedDependenciesRef.current = renderDependencies
247+
})
248+
249+
return result.value
250+
}
251+
218252
export function getCssVariableRaw (
219253
name: string,
220254
fallback?: unknown
@@ -231,6 +265,13 @@ export function getCssVariable (
231265
return value == null ? value : coerceCssValue(value)
232266
}
233267

268+
export function getCssColor (
269+
color: string,
270+
mix?: CssColorMixInput
271+
): string | undefined {
272+
return resolveCssColor(color, mix).value
273+
}
274+
234275
export function useMedia (): Record<string, boolean> {
235276
const context = useCssxRuntimeContext()
236277
const committedDependenciesRef = useRef<CssxDependencySnapshot>(createDependencySnapshot())
@@ -367,8 +408,21 @@ function resolveCssVariableRaw (
367408
})
368409
}
369410

370-
function createVariableDependencySnapshot (
371-
result: ReturnType<typeof resolveCssVariableRaw>
411+
function resolveCssColor (
412+
color: string,
413+
mix?: CssColorMixInput,
414+
scopedVariables?: readonly Record<string, unknown>[]
415+
): ResolveCssValueResult {
416+
return resolveCssValue(createCssColorExpression(color, mix), {
417+
variables: getVariableValues(),
418+
scopedVariables,
419+
defaultVariables: getDefaultVariableValues(),
420+
dimensions: getDimensions()
421+
})
422+
}
423+
424+
function createCssValueDependencySnapshot (
425+
result: ResolveCssValueResult
372426
): CssxDependencySnapshot {
373427
const dependencies = createDependencySnapshot()
374428
for (const name of result.dependencies.vars) {
@@ -380,6 +434,67 @@ function createVariableDependencySnapshot (
380434
return dependencies
381435
}
382436

437+
function createCssColorExpression (
438+
color: string,
439+
mix?: CssColorMixInput
440+
): string {
441+
const base = normalizeCssColorExpression(color)
442+
const mixOptions = normalizeColorMix(mix)
443+
if (mixOptions == null) return base
444+
445+
return `color-mix(in srgb, ${base} ${mixOptions.weight}, ${normalizeCssColorExpression(mixOptions.with)})`
446+
}
447+
448+
function normalizeCssColorExpression (input: string): string {
449+
const value = input.trim()
450+
if (value === '') return value
451+
if (/^var\(/i.test(value)) return value
452+
if (value.startsWith('--')) {
453+
throw new TypeError(`Ambiguous CSS color token "${input}". Use "var(${value})" or a semantic token such as "primary".`)
454+
}
455+
if (
456+
CSS_COLOR_FUNCTION_RE.test(value) ||
457+
isCssColor(value) ||
458+
!CSS_COLOR_TOKEN_RE.test(value)
459+
) {
460+
return value
461+
}
462+
463+
return `var(--color-${value})`
464+
}
465+
466+
function normalizeColorMix (
467+
input: CssColorMixInput | undefined
468+
): { weight: string, with: string } | null {
469+
if (input == null) return null
470+
if (typeof input === 'number' || typeof input === 'string') {
471+
return {
472+
weight: normalizeMixWeight(input),
473+
with: 'transparent'
474+
}
475+
}
476+
477+
if (input.mix == null) return null
478+
return {
479+
weight: normalizeMixWeight(input.mix),
480+
with: input.with ?? 'transparent'
481+
}
482+
}
483+
484+
function normalizeMixWeight (input: number | string): string {
485+
if (typeof input === 'string') {
486+
const value = input.trim()
487+
if (/^(?:\d+|\d*\.\d+)%$/.test(value)) return value
488+
throw new TypeError(`Invalid CSS color mix weight "${input}". Expected a percentage string such as "15%".`)
489+
}
490+
491+
if (!Number.isFinite(input) || input < 0 || input > 1) {
492+
throw new TypeError(`Invalid CSS color mix weight "${input}". Expected a number from 0 to 1.`)
493+
}
494+
495+
return `${input * 100}%`
496+
}
497+
383498
function resolveMedia (
384499
media: Record<string, string>,
385500
context: ReturnType<typeof useCssxRuntimeContext>

packages/css-to-rn/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type CssxDiagnosticCode =
1717
| 'UNSUPPORTED_BACKGROUND_SHORTHAND'
1818
| 'INVALID_THEME_BLOCK'
1919
| 'INVALID_CUSTOM_MEDIA'
20+
| 'DEPRECATED_UNIT'
2021

2122
export interface CssxDiagnostic {
2223
level: CssxDiagnosticLevel

packages/css-to-rn/src/units.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
let warnedAboutU = false
2+
3+
export function u (value: number): number {
4+
if (!warnedAboutU && process.env.NODE_ENV !== 'production') {
5+
warnedAboutU = true
6+
console.warn('[cssx] u() is deprecated. Use rem, var(--spacing), or CSS instead. 1u equals 0.5rem or 8px.')
7+
}
8+
9+
return value * 8
10+
}
11+
12+
export function resetUWarningForTests (): void {
13+
warnedAboutU = false
14+
}

packages/css-to-rn/src/values.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface ResolveCssValueOptions {
1414
height?: number
1515
}
1616
maxVarDepth?: number
17+
deprecateUUnits?: boolean
1718
}
1819

1920
export interface ResolveCssValueResult {
@@ -60,7 +61,7 @@ export function resolveCssValue (
6061
return invalid(diagnostics, dependencies)
6162
}
6263

63-
const units = resolveUnits(variableResolution.value, options, dependencies)
64+
const units = resolveUnits(variableResolution.value, options, dependencies, diagnostics)
6465
const calc = resolveCalcs(units.value, diagnostics)
6566
if (!calc.valid) {
6667
return invalid(diagnostics, dependencies)
@@ -213,8 +214,19 @@ function resolveVars (
213214
function resolveUnits (
214215
input: string,
215216
options: ResolveCssValueOptions,
216-
dependencies: { vars: Set<string>, dimensions: boolean }
217+
dependencies: { vars: Set<string>, dimensions: boolean },
218+
diagnostics: CssxDiagnostic[]
217219
): { value: string } {
220+
const warnUUnits = options.deprecateUUnits && U_UNIT_RE.test(input)
221+
U_UNIT_RE.lastIndex = 0
222+
if (warnUUnits) {
223+
diagnostics.push(diagnostic(
224+
'DEPRECATED_UNIT',
225+
'The CSSX "u" unit is deprecated. Use rem, var(--spacing), or calc(var(--spacing) * n).',
226+
'warning'
227+
))
228+
}
229+
218230
let value = input.replace(U_UNIT_RE, (_match, prefix: string, rawNumber: string) => {
219231
return `${prefix}${Number(rawNumber) * 8}px`
220232
})

packages/css-to-rn/src/web.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export {
77
export {
88
resolveCssValue
99
} from './values.ts'
10+
export {
11+
u
12+
} from './units.ts'
13+
import {
14+
resetUWarningForTests
15+
} from './units.ts'
1016
import {
1117
cssx as baseCssx,
1218
clearRawCssCacheForTests
@@ -69,12 +75,17 @@ export {
6975
useCssxRuntimeContext
7076
} from './react/config.ts'
7177
export {
78+
getCssColor,
7279
getCssVariable,
7380
getCssVariableRaw,
7481
useMedia,
82+
useCssColor,
7583
useCssVariable,
7684
useCssVariableRaw
7785
} from './react/hooks.ts'
86+
export type {
87+
CssColorMixInput
88+
} from './react/hooks.ts'
7889
export {
7990
TrackedCssxSheet,
8091
isTrackedCssxSheet
@@ -144,6 +155,7 @@ export const __cssxInternals = {
144155
flushMicrotasksForTests,
145156
getRuntimeSubscriberCountForTests,
146157
resetStoreForTests,
158+
resetUWarningForTests,
147159
setColorSchemeForTests,
148160
setDimensionsForTests,
149161
subscribeVariablesForTests

packages/css-to-rn/test/engine/compiler.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ describe('@cssxjs/css-to-rn compiler IR', () => {
182182
assert.equal(sheet.error, undefined)
183183
})
184184

185+
it('warns about deprecated u units in build mode', () => {
186+
const sheet = compileCss('.root { padding: 1u; }', { mode: 'build' })
187+
188+
assert.equal(sheet.error, undefined)
189+
assert.equal(sheet.diagnostics[0].code, 'DEPRECATED_UNIT')
190+
assert.equal(sheet.diagnostics[0].level, 'warning')
191+
})
192+
185193
it('warns and ignores unsupported selectors in runtime mode', () => {
186194
const sheet = compileCss(`
187195
.root .child { color: red; }

0 commit comments

Comments
 (0)