From 39313bbb0577b58d23151bafd31656ae172d9b48 Mon Sep 17 00:00:00 2001 From: Mark Lawlor Date: Thu, 2 Oct 2025 23:12:54 +1000 Subject: [PATCH 1/3] feat: add runtime color-mix() --- src/__tests__/native/color-mix.test.tsx | 47 ++++++++++++ src/compiler/declarations.ts | 96 ++++++++++++++++++++++++ src/compiler/supports.ts | 2 + src/native/styles/functions/color-mix.ts | 65 ++++++++++++++++ src/native/styles/functions/index.ts | 1 + 5 files changed, 211 insertions(+) create mode 100644 src/__tests__/native/color-mix.test.tsx create mode 100644 src/native/styles/functions/color-mix.ts diff --git a/src/__tests__/native/color-mix.test.tsx b/src/__tests__/native/color-mix.test.tsx new file mode 100644 index 0000000..bed3cea --- /dev/null +++ b/src/__tests__/native/color-mix.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; + +test("color-mix() - keyword", () => { + registerCSS( + `.test { + --bg: red; + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--bg) 50%, transparent); + } + } + `, + { + inlineVariables: false, + }, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(255, 0, 0, 0.5)", + }); +}); + +test("color-mix() - oklch", () => { + registerCSS( + `.test { + --bg: oklch(0.577 0.245 27.325); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--bg) 50%, transparent); + } + } + `, + { + inlineVariables: false, + }, + ); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ + backgroundColor: "rgba(231, 0, 11, 0.5)", + }); +}); diff --git a/src/compiler/declarations.ts b/src/compiler/declarations.ts index ed6137c..df9e6d1 100644 --- a/src/compiler/declarations.ts +++ b/src/compiler/declarations.ts @@ -1228,6 +1228,8 @@ export function parseUnparsed( builder, property, ); + case "color-mix": + return parseColorMix(tokenOrValue.value.arguments, builder, property); default: { builder.addWarning("value", `${tokenOrValue.value.name}()`); return; @@ -2633,6 +2635,100 @@ export function parseCalcFn( return; } +export function parseColorMix( + tokens: TokenOrValue[], + builder: StylesheetBuilder, + property: string, +): StyleDescriptor { + const [inToken, whitespace, colorSpace, comma, ...rest] = tokens; + if ( + typeof inToken !== "object" || + inToken.type !== "token" || + inToken.value.type !== "ident" || + inToken.value.value !== "in" + ) { + return; + } + + if ( + typeof whitespace !== "object" || + whitespace.type !== "token" || + whitespace.value.type !== "white-space" + ) { + return; + } + + if ( + typeof comma !== "object" || + comma.type !== "token" || + comma.value.type !== "comma" + ) { + return; + } + + const colorSpaceArg = parseUnparsed(colorSpace, builder, property); + if (typeof colorSpaceArg !== "string") { + return; + } + + let nextToken = rest.shift(); + + const leftColorArg = parseUnparsed(nextToken, builder, property); + + if (!leftColorArg) { + return; + } + + nextToken = rest.shift(); + + let leftColorPercentage: StyleDescriptor | undefined; + if (nextToken?.type !== "token" || nextToken.value.type !== "comma") { + leftColorPercentage = parseUnparsed(nextToken, builder, property); + nextToken = rest.shift(); + } + + if ( + typeof nextToken !== "object" || + nextToken.type !== "token" || + nextToken.value.type !== "comma" + ) { + return; + } + + nextToken = rest.shift(); + + const rightColorArg = parseUnparsed(nextToken, builder, property); + + if (rightColorArg === "transparent") { + // Ignore the rest, treat as single color with alpha + return [{}, "colorMix", [colorSpaceArg, leftColorArg, leftColorPercentage]]; + } + + nextToken = rest.shift(); + let rightColorPercentage: StyleDescriptor | undefined; + if (nextToken?.type !== "token" || nextToken.value.type !== "comma") { + rightColorPercentage = parseUnparsed(nextToken, builder, property); + nextToken = rest.shift(); + } + + // We should have expired all tokens now + if (nextToken) { + return; + } + + return [ + {}, + "colorMix", + [ + colorSpaceArg, + leftColorArg, + leftColorPercentage, + rightColorArg, + rightColorPercentage, + ], + ]; +} + export function parseCalcArguments( [...args]: TokenOrValue[], builder: StylesheetBuilder, diff --git a/src/compiler/supports.ts b/src/compiler/supports.ts index 1558d61..cfa6c19 100644 --- a/src/compiler/supports.ts +++ b/src/compiler/supports.ts @@ -23,4 +23,6 @@ export function supportsConditionValid(condition: SupportsCondition): boolean { const declarations: Record = { // We don't actually support this, but its needed for Tailwind CSS "-moz-orient": ["inline"], + // Special text used by TailwindCSS. We should probably change this is all color-mix + "color": ["color-mix(in lab, red, red)"], }; diff --git a/src/native/styles/functions/color-mix.ts b/src/native/styles/functions/color-mix.ts new file mode 100644 index 0000000..f1d706a --- /dev/null +++ b/src/native/styles/functions/color-mix.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import type { PlainColorObject } from "colorjs.io"; +import { + ColorSpace, + to as convert, + mix, + OKLab, + P3, + parse, + sRGB, + type ColorConstructor, +} from "colorjs.io/fn"; + +import type { StyleFunctionResolver } from "../resolve"; + +ColorSpace.register(sRGB); +ColorSpace.register(P3); +ColorSpace.register(OKLab); + +export const colorMix: StyleFunctionResolver = (resolveValue, value) => { + const args = resolveValue(value[2]); + + if (!Array.isArray(args) || args.length < 3) { + return; + } + + try { + const space = args.shift(); + + let left: ColorConstructor | PlainColorObject = parse( + args.shift() as string, + ); + + let next = args.shift(); + + if (typeof next === "string" && next.endsWith("%")) { + left.alpha = parseFloat(next) / 100; + next = args.shift(); + } + + if (next === undefined) { + if (left.spaceId !== "srgb") { + left = convert(left, "srgb"); + } + + return `rgba(${(left.coords[0] ?? 0) * 255}, ${(left.coords[1] ?? 0) * 255}, ${(left.coords[2] ?? 0) * 255}, ${left.alpha})`; + } + + const right = parse(next as string); + + next = args.shift(); + if (next && typeof next === "string" && next.endsWith("%")) { + right.alpha = parseFloat(next) / 100; + } + + const result = mix(left, right, { + space, + outputSpace: "srgb", + }); + + return `rgba(${(result.coords[0] ?? 0) * 255}, ${(result.coords[1] ?? 0) * 255}, ${(result.coords[2] ?? 0) * 255}, ${result.alpha})`; + } catch { + return; + } +}; diff --git a/src/native/styles/functions/index.ts b/src/native/styles/functions/index.ts index 3e41961..02f9308 100644 --- a/src/native/styles/functions/index.ts +++ b/src/native/styles/functions/index.ts @@ -5,3 +5,4 @@ export * from "./numeric-functions"; export * from "./platform-functions"; export * from "./string-functions"; export * from "./transform-functions"; +export * from "./color-mix"; From 48b91621aba95ffcc6f41bb771ae9b4c43c22077 Mon Sep 17 00:00:00 2001 From: Mark Lawlor Date: Thu, 2 Oct 2025 23:15:30 +1000 Subject: [PATCH 2/3] Update src/compiler/supports.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/compiler/supports.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compiler/supports.ts b/src/compiler/supports.ts index cfa6c19..c3370dc 100644 --- a/src/compiler/supports.ts +++ b/src/compiler/supports.ts @@ -23,6 +23,6 @@ export function supportsConditionValid(condition: SupportsCondition): boolean { const declarations: Record = { // We don't actually support this, but its needed for Tailwind CSS "-moz-orient": ["inline"], - // Special text used by TailwindCSS. We should probably change this is all color-mix + // Special text used by TailwindCSS. We should probably change this to all color-mix "color": ["color-mix(in lab, red, red)"], }; From 85e0210efe07dfe2a135e7e010f412d054051861 Mon Sep 17 00:00:00 2001 From: Mark Lawlor Date: Thu, 2 Oct 2025 23:15:46 +1000 Subject: [PATCH 3/3] Update src/native/styles/functions/color-mix.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/native/styles/functions/color-mix.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/native/styles/functions/color-mix.ts b/src/native/styles/functions/color-mix.ts index f1d706a..27a95e0 100644 --- a/src/native/styles/functions/color-mix.ts +++ b/src/native/styles/functions/color-mix.ts @@ -46,7 +46,10 @@ export const colorMix: StyleFunctionResolver = (resolveValue, value) => { return `rgba(${(left.coords[0] ?? 0) * 255}, ${(left.coords[1] ?? 0) * 255}, ${(left.coords[2] ?? 0) * 255}, ${left.alpha})`; } - const right = parse(next as string); + if (typeof next !== "string") { + return; + } + const right = parse(next); next = args.shift(); if (next && typeof next === "string" && next.endsWith("%")) {