From 5ba43b5d9e66df79b3c61d94c2b62f28224f65d3 Mon Sep 17 00:00:00 2001 From: V1OL3TF0X Date: Wed, 7 May 2025 12:21:00 +0200 Subject: [PATCH 1/6] feat(color-picker): add oklab to color picker, fixup rtl and formatting --- .../color-picker/src/color-picker.connect.ts | 14 +-- .../color-picker/src/color-picker.machine.ts | 57 +++++---- packages/utilities/color-utils/package.json | 4 +- .../color-utils/src/area-gradient.ts | 33 ++--- .../color-utils/src/color-format-gradient.ts | 16 +++ .../utilities/color-utils/src/hsb-color.ts | 15 ++- .../utilities/color-utils/src/hsl-color.ts | 10 +- .../utilities/color-utils/src/oklab-color.ts | 116 ++++++++++++++++++ .../utilities/color-utils/src/parse-color.ts | 3 +- .../utilities/color-utils/src/rgb-color.ts | 19 +++ packages/utilities/color-utils/src/types.ts | 14 ++- pnpm-lock.yaml | 27 +++- shared/src/controls.ts | 6 + shared/src/css/color-picker.css | 3 + shared/src/define-controls.ts | 4 +- 15 files changed, 269 insertions(+), 72 deletions(-) create mode 100644 packages/utilities/color-utils/src/oklab-color.ts diff --git a/packages/machines/color-picker/src/color-picker.connect.ts b/packages/machines/color-picker/src/color-picker.connect.ts index f91f85af34..7b39a5ef3e 100644 --- a/packages/machines/color-picker/src/color-picker.connect.ts +++ b/packages/machines/color-picker/src/color-picker.connect.ts @@ -23,8 +23,8 @@ export function connect( ): ColorPickerApi { const { context, send, prop, computed, state, scope } = service - const value = context.get("value") const format = context.get("format") + const value = context.get("value").toFormat(format) const areaValue = computed("areaValue") const valueAsString = computed("valueAsString") @@ -262,6 +262,7 @@ export function connect( const { xChannel, yChannel } = getAreaChannels(props) const channel = { xChannel, yChannel } + const dir = prop("dir") === "rtl" ? "right" : "left" const xPercent = areaValue.getChannelValuePercent(xChannel) const yPercent = 1 - areaValue.getChannelValuePercent(yChannel) @@ -287,9 +288,8 @@ export function connect( "aria-valuetext": `${xChannel} ${xValue}, ${yChannel} ${yValue}`, style: { position: "absolute", - left: `${xPercent * 100}%`, + [dir]: `${xPercent * 100}%`, top: `${yPercent * 100}%`, - transform: "translate(-50%, -50%)", touchAction: "none", forcedColorAdjust: "none", "--color": color, @@ -443,11 +443,11 @@ export function connect( const channelValue = normalizedValue.getChannelValue(channel) const offset = (channelValue - channelRange.minValue) / (channelRange.maxValue - channelRange.minValue) - + const dir = prop("dir") === "rtl" ? "right" : "left" const placementStyles = orientation === "horizontal" - ? { left: `${offset * 100}%`, top: "50%" } - : { top: `${offset * 100}%`, left: "50%" } + ? { [dir]: `${offset * 100}%`, top: "50%" } + : { top: `${offset * 100}%`, [dir]: "50%" } return normalize.element({ ...parts.channelSliderThumb.attrs, @@ -700,7 +700,7 @@ export function connect( } } -const formats: ColorFormat[] = ["hsba", "hsla", "rgba"] +const formats: ColorFormat[] = ["hsba", "hsla", "rgba", "oklab"] const formatRegex = new RegExp(`^(${formats.join("|")})$`) function getNextFormat(format: ColorFormat) { diff --git a/packages/machines/color-picker/src/color-picker.machine.ts b/packages/machines/color-picker/src/color-picker.machine.ts index 77a9257c4b..d2ebcfe964 100644 --- a/packages/machines/color-picker/src/color-picker.machine.ts +++ b/packages/machines/color-picker/src/color-picker.machine.ts @@ -42,26 +42,33 @@ export const machine = createMachine({ context({ prop, bindable, getContext }) { return { - value: bindable(() => ({ - defaultValue: prop("defaultValue"), - value: prop("value"), - isEqual(a, b) { - return a.toString("css") === b?.toString("css") - }, - hash(a) { - return a.toString("css") - }, - onChange(value) { - const ctx = getContext() - const valueAsString = value.toString(ctx.get("format")) - prop("onValueChange")?.({ value, valueAsString }) - }, - })), + value: bindable(() => { + const fmt = prop("format") ?? prop("defaultFormat") + return { + defaultValue: prop("defaultValue").toFormat(fmt), + value: prop("value")?.toFormat(fmt), + isEqual(a, b) { + return a.toString("css") === b?.toString("css") + }, + hash(a) { + return a.toString("css") + }, + onChange(value) { + const ctx = getContext() + const valueAsString = value.toString(ctx.get("format")) + prop("onValueChange")?.({ value, valueAsString }) + }, + } + }), format: bindable(() => ({ defaultValue: prop("defaultFormat"), value: prop("format"), onChange(format) { prop("onFormatChange")?.({ format }) + const ctx = getContext() + const newValue = ctx.get("value").toFormat(ctx.get("format")) + if (newValue.isEqual(ctx.get("value"))) return + ctx.set("value", newValue) }, })), activeId: bindable(() => ({ defaultValue: null })), @@ -81,7 +88,10 @@ export const machine = createMachine({ interactive: ({ prop }) => !(prop("disabled") || prop("readOnly")), valueAsString: ({ context }) => context.get("value").toString(context.get("format")), areaValue: ({ context }) => { - const format = context.get("format").startsWith("hsl") ? "hsla" : "hsba" + const formatId = context.get("format") + let format: ColorFormat = "hsba" + if (formatId.startsWith("hsl")) format = "hsla" + else if (formatId === "oklab") format = "oklab" return context.get("value").toFormat(format) }, }, @@ -472,20 +482,22 @@ export const machine = createMachine({ context.set("activeId", null) context.set("activeOrientation", null) }, - setAreaColorFromPoint({ context, event, computed, scope }) { + setAreaColorFromPoint({ context, event, computed, scope, prop }) { const v = event.format ? context.get("value").toFormat(event.format) : computed("areaValue") const { xChannel, yChannel } = event.channel || context.get("activeChannel") const percent = dom.getAreaValueFromPoint(scope, event.point) if (!percent) return - + if (prop("dir") === "rtl") { + percent.x = 1 - percent.x + } const xValue = v.getChannelPercentValue(xChannel, percent.x) const yValue = v.getChannelPercentValue(yChannel, 1 - percent.y) const color = v.withChannelValue(xChannel, xValue).withChannelValue(yChannel, yValue) context.set("value", color) }, - setChannelColorFromPoint({ context, event, computed, scope }) { + setChannelColorFromPoint({ context, event, computed, scope, prop }) { const channel = event.channel || context.get("activeId") const normalizedValue = event.format ? context.get("value").toFormat(event.format) : computed("areaValue") @@ -493,8 +505,11 @@ export const machine = createMachine({ if (!percent) return const orientation = context.get("activeOrientation") || "horizontal" - const channelPercent = orientation === "horizontal" ? percent.x : percent.y + let channelPercent = orientation === "horizontal" ? percent.x : percent.y + if (prop("dir") === "rtl") { + channelPercent = 1 - channelPercent + } const value = normalizedValue.getChannelPercentValue(channel, channelPercent) const color = normalizedValue.withChannelValue(channel, value) context.set("value", color) @@ -534,7 +549,7 @@ export const machine = createMachine({ } else if (isTextField) { // color = tryCatch( - () => parse(value).withChannelValue("alpha", currentAlpha), + () => parse(value).withChannelValue("alpha", currentAlpha).toFormat(context.get("format")), () => context.get("value"), ) // diff --git a/packages/utilities/color-utils/package.json b/packages/utilities/color-utils/package.json index 564d5d9f26..f78ca3a975 100644 --- a/packages/utilities/color-utils/package.json +++ b/packages/utilities/color-utils/package.json @@ -31,9 +31,11 @@ }, "clean-package": "../../../clean-package.config.json", "dependencies": { - "@zag-js/utils": "workspace:*" + "@zag-js/utils": "workspace:*", + "culori": "^4.0.1" }, "devDependencies": { + "@types/culori": "^4.0.0", "clean-package": "2.2.0" } } diff --git a/packages/utilities/color-utils/src/area-gradient.ts b/packages/utilities/color-utils/src/area-gradient.ts index 649966d97e..5ef91b86e8 100644 --- a/packages/utilities/color-utils/src/area-gradient.ts +++ b/packages/utilities/color-utils/src/area-gradient.ts @@ -9,6 +9,7 @@ import { generateHSB_S, generateHSB_B, generateHSL_L, + generateOKLAB_L, } from "./color-format-gradient" import type { ColorChannel } from "./types" @@ -28,7 +29,6 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr const { zChannel } = color.getColorAxes({ xChannel, yChannel }) const zValue = color.getChannelValue(zChannel) - const { minValue: zMin, maxValue: zMax } = color.getChannelRange(zChannel) const orientation: [string, string] = ["top", dirProp === "rtl" ? "left" : "right"] @@ -42,54 +42,47 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr switch (zChannel) { case "red": { dir = xChannel === "green" - background = generateRGB_R(orientation, dir, zValue) - break + return generateRGB_R(orientation, dir, zValue) } case "green": { dir = xChannel === "red" - background = generateRGB_G(orientation, dir, zValue) - break + return generateRGB_G(orientation, dir, zValue) } case "blue": { dir = xChannel === "red" - background = generateRGB_B(orientation, dir, zValue) - break + return generateRGB_B(orientation, dir, zValue) } case "hue": { dir = xChannel !== "saturation" if (isHSL) { - background = generateHSL_H(orientation, dir, zValue) - } else { - background = generateHSB_H(orientation, dir, zValue) + return generateHSL_H(orientation, dir, zValue) } - break + return generateHSB_H(orientation, dir, zValue) } case "saturation": { dir = xChannel === "hue" if (isHSL) { - background = generateHSL_S(orientation, dir, alphaValue) - } else { - background = generateHSB_S(orientation, dir, alphaValue) + return generateHSL_S(orientation, dir, alphaValue) } - break + return generateHSB_S(orientation, dir, alphaValue) } case "brightness": { dir = xChannel === "hue" - background = generateHSB_B(orientation, dir, alphaValue) - break + return generateHSB_B(orientation, dir, alphaValue) } case "lightness": { + if (color.getFormat() === "oklab") { + return generateOKLAB_L(orientation, dir, zValue) + } dir = xChannel === "hue" - background = generateHSL_L(orientation, dir, zValue) - break + return generateHSL_L(orientation, dir, zValue) } } - return background } diff --git a/packages/utilities/color-utils/src/color-format-gradient.ts b/packages/utilities/color-utils/src/color-format-gradient.ts index 25974f3818..71886d37b6 100644 --- a/packages/utilities/color-utils/src/color-format-gradient.ts +++ b/packages/utilities/color-utils/src/color-format-gradient.ts @@ -59,6 +59,22 @@ export const generateHSL_H = (orientation: [string, string], dir: boolean, zValu return result } +export const generateOKLAB_L = (orientation: [string, string], dir: boolean, zValue: number) => { + const maskImage = "linear-gradient(to bottom, transparent, black)" + const l = zValue === 0 ? "none" : `${(zValue * 100).toFixed(2)}%` + const result = { + areaStyles: { + background: `linear-gradient(to ${orientation[1]} in oklab, oklab(${l} -100% 100%), oklab(${l} 0 100%) 50%, oklab(${l} 100% 100%))`, + }, + areaGradientStyles: { + background: `linear-gradient(to ${orientation[1]} in oklab, oklab(${l} -100% -100%), oklab(${l} 0 -100%) 50%, oklab(${l} 100% -100%))`, + maskImage, + WebkitMaskImage: maskImage, + }, + } + return result +} + export const generateHSL_S = (orientation: [string, string], dir: boolean, alphaValue: number) => { const result = { areaStyles: {}, diff --git a/packages/utilities/color-utils/src/hsb-color.ts b/packages/utilities/color-utils/src/hsb-color.ts index bc89aaeda2..5e880a3afb 100644 --- a/packages/utilities/color-utils/src/hsb-color.ts +++ b/packages/utilities/color-utils/src/hsb-color.ts @@ -28,21 +28,18 @@ export class HSBColor extends Color { toString(format: ColorStringFormat) { switch (format) { case "css": - return this.toHSL().toString("css") - case "hex": - return this.toRGB().toString("hex") - case "hexa": - return this.toRGB().toString("hexa") + case "hsl": + return this.toHSL().toString(format) case "hsb": return `hsb(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%)` case "hsba": return `hsba(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.brightness, 2)}%, ${ this.alpha })` - case "hsl": - return this.toHSL().toString("hsl") + case "hex": + case "hexa": case "rgb": - return this.toRGB().toString("rgb") + return this.toRGB().toString(format) default: return this.toFormat(format).toString(format) } @@ -56,6 +53,8 @@ export class HSBColor extends Color { return this.toHSL() case "rgba": return this.toRGB() + case "oklab": + return this.toRGB().toFormat(format) default: throw new Error("Unsupported color conversion: hsb -> " + format) } diff --git a/packages/utilities/color-utils/src/hsl-color.ts b/packages/utilities/color-utils/src/hsl-color.ts index 5d3ecfca97..04232d0655 100644 --- a/packages/utilities/color-utils/src/hsl-color.ts +++ b/packages/utilities/color-utils/src/hsl-color.ts @@ -27,10 +27,6 @@ export class HSLColor extends Color { toString(format: ColorStringFormat) { switch (format) { - case "hex": - return this.toRGB().toString("hex") - case "hexa": - return this.toRGB().toString("hexa") case "hsl": return `hsl(${this.hue}, ${toFixedNumber(this.saturation, 2)}%, ${toFixedNumber(this.lightness, 2)}%)` case "css": @@ -40,8 +36,10 @@ export class HSLColor extends Color { })` case "hsb": return this.toHSB().toString("hsb") + case "hex": + case "hexa": case "rgb": - return this.toRGB().toString("rgb") + return this.toRGB().toString(format) default: return this.toFormat(format).toString(format) } @@ -55,6 +53,8 @@ export class HSLColor extends Color { return this.toHSB() case "rgba": return this.toRGB() + case "oklab": + return this.toRGB().toFormat(format) default: throw new Error("Unsupported color conversion: hsl -> " + format) } diff --git a/packages/utilities/color-utils/src/oklab-color.ts b/packages/utilities/color-utils/src/oklab-color.ts new file mode 100644 index 0000000000..1cd4809f78 --- /dev/null +++ b/packages/utilities/color-utils/src/oklab-color.ts @@ -0,0 +1,116 @@ +import { toFixedNumber, roundValue } from "@zag-js/utils" +import { convertOklabToRgb, clampChroma, modeRgb, modeLrgb, modeOklab, useMode as bootstrapMode } from "culori/fn" +import { Color } from "./color" +import { RGBColor } from "./rgb-color" +import type { ColorChannel, ColorChannelRange, ColorFormat, ColorStringFormat, ColorType } from "./types" + +bootstrapMode(modeRgb) +export const oklab = bootstrapMode(modeOklab) +bootstrapMode(modeLrgb) + +export class OklabColor extends Color { + constructor( + private lightness: number, + private a: number, + private b: number, + private alpha: number, + ) { + super() + } + + static parse(value: string): OklabColor | void { + const parsed = oklab(value) + if (!parsed) return + const { l, a, b, alpha } = parsed + return new OklabColor(l, a, b, alpha ?? 1) + } + + toString(format: ColorStringFormat) { + switch (format) { + case "oklab": + case "css": + return `oklab(${this.lightness} ${this.a} ${this.b}${this.alpha < 1 ? ` / ${this.alpha}` : ""})` + default: + return this.toRGB().toString(format) + } + } + + toFormat(format: ColorFormat): ColorType { + switch (format) { + case "oklab": + return this + case "rgba": + case "hsla": + case "hsba": + return this.toRGB().toFormat(format) + default: + throw new Error("Unsupported color conversion: oklab -> " + format) + } + } + + /** + * Converts a Oolab color to RGB. + * Conversion adjusts for values that couldn't be displayed in RGB + * @returns RGBColor + */ + private toRGB(): RGBColor { + const clamped = clampChroma({ mode: "oklab", l: this.lightness, a: this.a, b: this.b }, "oklab") + const inRGb = convertOklabToRgb(clamped) + return new RGBColor( + roundValue(inRGb.r * 255, 0, 1), + roundValue(inRGb.g * 255, 0, 1), + roundValue(inRGb.b * 255, 0, 1), + toFixedNumber(inRGb.alpha ?? 1, 2), + ) + } + + clone(): OklabColor { + return new OklabColor(this.lightness, this.a, this.b, this.alpha) + } + + getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions { + switch (channel) { + case "lightness": + case "alpha": + return { style: "percent" } + case "a": + case "b": + return { style: "decimal" } + default: + throw new Error("Unknown color channel: " + channel) + } + } + + formatChannelValue(channel: ColorChannel, locale: string) { + let options = this.getChannelFormatOptions(channel) + let value = this.getChannelValue(channel) + return new Intl.NumberFormat(locale, options).format(value) + } + + getChannelRange(channel: ColorChannel): ColorChannelRange { + switch (channel) { + case "a": + case "b": + return { minValue: -0.4, maxValue: 0.4, step: 0.01, pageSize: 0.1 } + case "lightness": + case "alpha": + return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 } + default: + throw new Error("Unknown color channel: " + channel) + } + } + + toJSON(): Record<"l" | "a" | "b" | "alpha", number> { + return { l: this.lightness, a: this.a, b: this.b, alpha: this.alpha } + } + + getFormat(): ColorFormat { + return "oklab" + } + + private static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ["lightness", "a", "b"] + + getChannels(): [ColorChannel, ColorChannel, ColorChannel] { + return OklabColor.colorChannels + } +} diff --git a/packages/utilities/color-utils/src/parse-color.ts b/packages/utilities/color-utils/src/parse-color.ts index 8fa5b49d4e..4bf4e1288e 100644 --- a/packages/utilities/color-utils/src/parse-color.ts +++ b/packages/utilities/color-utils/src/parse-color.ts @@ -1,6 +1,7 @@ import { HSBColor } from "./hsb-color" import { HSLColor } from "./hsl-color" import { nativeColorMap } from "./native-color" +import { OklabColor } from "./oklab-color" import { RGBColor } from "./rgb-color" import type { ColorType } from "./types" @@ -9,7 +10,7 @@ export const parseColor = (value: string): ColorType => { return parseColor(nativeColorMap.get(value)!) } - const result = RGBColor.parse(value) || HSBColor.parse(value) || HSLColor.parse(value) + const result = RGBColor.parse(value) || HSBColor.parse(value) || HSLColor.parse(value) || OklabColor.parse(value) if (!result) { const error = new Error("Invalid color value: " + value) diff --git a/packages/utilities/color-utils/src/rgb-color.ts b/packages/utilities/color-utils/src/rgb-color.ts index 4eca312f40..115cf2580f 100644 --- a/packages/utilities/color-utils/src/rgb-color.ts +++ b/packages/utilities/color-utils/src/rgb-color.ts @@ -1,8 +1,10 @@ import { clampValue, toFixedNumber } from "@zag-js/utils" +import { convertRgbToOklab } from "culori/fn" import { Color } from "./color" import { HSBColor } from "./hsb-color" import { HSLColor } from "./hsl-color" import type { ColorChannel, ColorChannelRange, ColorFormat, ColorStringFormat, ColorType } from "./types" +import { OklabColor } from "./oklab-color" export class RGBColor extends Color { constructor( @@ -85,6 +87,8 @@ export class RGBColor extends Color { return this.toHSB() case "hsla": return this.toHSL() + case "oklab": + return this.toOklab() default: throw new Error("Unsupported color conversion: rgb -> " + format) } @@ -178,6 +182,21 @@ export class RGBColor extends Color { ) } + private toOklab(): ColorType { + const inOklab = convertRgbToOklab({ + r: this.red / 255, + g: this.green / 255, + b: this.blue / 255, + alpha: this.alpha, + }) + return new OklabColor( + toFixedNumber(inOklab.l, 3), + toFixedNumber(inOklab.a, 3), + toFixedNumber(inOklab.b, 3), + inOklab.alpha ?? 1, + ) + } + clone(): ColorType { return new RGBColor(this.red, this.green, this.blue, this.alpha) } diff --git a/packages/utilities/color-utils/src/types.ts b/packages/utilities/color-utils/src/types.ts index 383e896f21..5b64f282bf 100644 --- a/packages/utilities/color-utils/src/types.ts +++ b/packages/utilities/color-utils/src/types.ts @@ -1,10 +1,20 @@ export type ColorHexFormat = "hex" | "hexa" -export type ColorFormat = "rgba" | "hsla" | "hsba" +export type ColorFormat = "rgba" | "hsla" | "hsba" | "oklab" export type ColorStringFormat = ColorHexFormat | ColorFormat | "rgb" | "hsl" | "hsb" | "css" -export type ColorChannel = "hue" | "saturation" | "brightness" | "lightness" | "red" | "green" | "blue" | "alpha" +export type ColorChannel = + | "hue" + | "saturation" + | "brightness" + | "lightness" + | "red" + | "green" + | "blue" + | "alpha" + | "a" + | "b" export interface Color2DAxes { xChannel: ColorChannel diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81de219f0d..813394d717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3019,7 +3019,13 @@ importers: '@zag-js/utils': specifier: workspace:* version: link:../core + culori: + specifier: ^4.0.1 + version: 4.0.1 devDependencies: + '@types/culori': + specifier: ^4.0.0 + version: 4.0.0 clean-package: specifier: 2.2.0 version: 2.2.0 @@ -6420,6 +6426,9 @@ packages: '@types/conventional-commits-parser@5.0.1': resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==} + '@types/culori@4.0.0': + resolution: {integrity: sha512-aFljQwjb++sl6TAyEXeHTiK/fk9epZOQ+nMmadjnAvzZFIvNoQ0x8XQYfcOaRTBwmDUPUlghhZCJ66MTcqQAsg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -7829,6 +7838,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + culori@4.0.1: + resolution: {integrity: sha512-LSnjA6HuIUOlkfKVbzi2OlToZE8OjFi667JWN9qNymXVXzGDmvuP60SSgC+e92sd7B7158f7Fy3Mb6rXS5EDPw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -16341,6 +16354,8 @@ snapshots: dependencies: '@types/node': 22.14.1 + '@types/culori@4.0.0': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -18040,6 +18055,8 @@ snapshots: csstype@3.1.3: {} + culori@4.0.1: {} + damerau-levenshtein@1.0.8: {} dargs@8.1.0: {} @@ -18613,7 +18630,7 @@ snapshots: '@typescript-eslint/parser': 8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.24.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.24.0(jiti@2.4.2)) eslint-plugin-react: 7.37.5(eslint@9.24.0(jiti@2.4.2)) @@ -18650,7 +18667,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.0 @@ -18665,14 +18682,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3) eslint: 9.24.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)) transitivePeerDependencies: - supports-color @@ -18698,7 +18715,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.24.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.30.1(eslint@9.24.0(jiti@2.4.2))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.24.0(jiti@2.4.2)))(eslint@9.24.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/shared/src/controls.ts b/shared/src/controls.ts index 46fee46054..bc1e5ab3cc 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -216,6 +216,12 @@ export const colorPickerControls = defineControls({ disabled: { type: "boolean", defaultValue: false }, readOnly: { type: "boolean", defaultValue: false }, dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" }, + format: { + type: "select", + options: ["hsla", "hsba", "rgba", "oklab"] as const, + defaultValue: "hsla", + forceValue: true, + }, }) export const fileUploadControls = defineControls({ diff --git a/shared/src/css/color-picker.css b/shared/src/css/color-picker.css index e7c5b6a42c..30c0babd11 100644 --- a/shared/src/css/color-picker.css +++ b/shared/src/css/color-picker.css @@ -47,6 +47,9 @@ black 0px 0px 0px 1px inset; width: 16px; height: 16px; + [dir="rtl"] & { + transform: translate(50%, -50%); + } } [data-scope="color-picker"][data-part="channel-slider-track"] { diff --git a/shared/src/define-controls.ts b/shared/src/define-controls.ts index 4ae9ba6dce..68a7aa4b73 100644 --- a/shared/src/define-controls.ts +++ b/shared/src/define-controls.ts @@ -3,8 +3,8 @@ import { deepExpand } from "./deep-get-set" export type ControlProp = | { type: "boolean"; label?: string; defaultValue?: boolean } | { type: "string"; label?: string; defaultValue?: string; placeholder?: string } - | { type: "select"; options: readonly string[]; defaultValue?: string; label?: string } - | { type: "multiselect"; options: readonly string[]; defaultValue?: string[]; label?: string } + | { type: "select"; options: readonly string[]; defaultValue?: string; label?: string; forceValue?: boolean } + | { type: "multiselect"; options: readonly string[]; defaultValue?: string[]; label?: string; forceValue?: boolean } | { type: "number"; label?: string; defaultValue?: number; min?: number; max?: number } export type ControlRecord = Record From 807e2ed910e28887ddf5a143308264aa0035932c Mon Sep 17 00:00:00 2001 From: V1OL3TF0X Date: Wed, 7 May 2025 14:15:23 +0200 Subject: [PATCH 2/6] feat(color-picker): add oklch, adjust slider values and display --- .../color-picker/src/color-picker.connect.ts | 2 +- .../color-picker/src/color-picker.machine.ts | 7 +- .../src/utils/get-channel-display-color.ts | 7 +- .../src/utils/get-slider-background.ts | 19 ++- .../color-utils/src/area-gradient.ts | 9 +- .../color-utils/src/color-format-gradient.ts | 14 +++ .../utilities/color-utils/src/hsb-color.ts | 1 + .../utilities/color-utils/src/hsl-color.ts | 1 + .../utilities/color-utils/src/oklab-color.ts | 29 ++++- .../utilities/color-utils/src/oklch-color.ts | 112 ++++++++++++++++++ .../utilities/color-utils/src/parse-color.ts | 8 +- .../utilities/color-utils/src/rgb-color.ts | 3 +- packages/utilities/color-utils/src/types.ts | 3 +- shared/src/controls.ts | 2 +- 14 files changed, 195 insertions(+), 22 deletions(-) create mode 100644 packages/utilities/color-utils/src/oklch-color.ts diff --git a/packages/machines/color-picker/src/color-picker.connect.ts b/packages/machines/color-picker/src/color-picker.connect.ts index 7b39a5ef3e..de2d73a00d 100644 --- a/packages/machines/color-picker/src/color-picker.connect.ts +++ b/packages/machines/color-picker/src/color-picker.connect.ts @@ -700,7 +700,7 @@ export function connect( } } -const formats: ColorFormat[] = ["hsba", "hsla", "rgba", "oklab"] +const formats: ColorFormat[] = ["hsba", "hsla", "rgba", "oklab", "oklch"] const formatRegex = new RegExp(`^(${formats.join("|")})$`) function getNextFormat(format: ColorFormat) { diff --git a/packages/machines/color-picker/src/color-picker.machine.ts b/packages/machines/color-picker/src/color-picker.machine.ts index d2ebcfe964..0586c92295 100644 --- a/packages/machines/color-picker/src/color-picker.machine.ts +++ b/packages/machines/color-picker/src/color-picker.machine.ts @@ -88,11 +88,8 @@ export const machine = createMachine({ interactive: ({ prop }) => !(prop("disabled") || prop("readOnly")), valueAsString: ({ context }) => context.get("value").toString(context.get("format")), areaValue: ({ context }) => { - const formatId = context.get("format") - let format: ColorFormat = "hsba" - if (formatId.startsWith("hsl")) format = "hsla" - else if (formatId === "oklab") format = "oklab" - return context.get("value").toFormat(format) + const fmt = context.get("format") + return context.get("value").toFormat(fmt.startsWith("r") ? "hsba" : fmt) }, }, diff --git a/packages/machines/color-picker/src/utils/get-channel-display-color.ts b/packages/machines/color-picker/src/utils/get-channel-display-color.ts index acba501d7f..5ea5047cf3 100644 --- a/packages/machines/color-picker/src/utils/get-channel-display-color.ts +++ b/packages/machines/color-picker/src/utils/get-channel-display-color.ts @@ -3,6 +3,7 @@ import { parseColor, type Color, type ColorChannel } from "@zag-js/color-utils" export function getChannelDisplayColor(color: Color, channel: ColorChannel) { switch (channel) { case "hue": + if (color.getFormat() === "oklch") return color return parseColor(`hsl(${color.getChannelValue("hue")}, 100%, 50%)`) case "lightness": case "brightness": @@ -11,9 +12,11 @@ export function getChannelDisplayColor(color: Color, channel: ColorChannel) { case "green": case "blue": return color.withChannelValue("alpha", 1) - case "alpha": { + case "alpha": + case "a": + case "b": + case "chroma": return color - } default: throw new Error("Unknown color channel: " + channel) } diff --git a/packages/machines/color-picker/src/utils/get-slider-background.ts b/packages/machines/color-picker/src/utils/get-slider-background.ts index 9b170b1c39..e6734badbc 100644 --- a/packages/machines/color-picker/src/utils/get-slider-background.ts +++ b/packages/machines/color-picker/src/utils/get-slider-background.ts @@ -25,11 +25,14 @@ export const getSliderBackground = (props: SliderBackgroundProps) => { switch (channel) { case "hue": + if (value.getFormat() === "oklch") { + return `linear-gradient(to ${bgDirection} in oklch increasing hue, ${value.withChannelValue(channel, minValue).toString("css")}, ${value.withChannelValue(channel, maxValue - 0.01).toString("css")})` + } return `linear-gradient(to ${bgDirection}, rgb(255, 0, 0) 0%, rgb(255, 255, 0) 17%, rgb(0, 255, 0) 33%, rgb(0, 255, 255) 50%, rgb(0, 0, 255) 67%, rgb(255, 0, 255) 83%, rgb(255, 0, 0) 100%)` case "lightness": { - let start = value.withChannelValue(channel, minValue).toString("css") - let middle = value.withChannelValue(channel, (maxValue - minValue) / 2).toString("css") - let end = value.withChannelValue(channel, maxValue).toString("css") + const start = value.withChannelValue(channel, minValue).toString("css") + const middle = value.withChannelValue(channel, (maxValue - minValue) / 2).toString("css") + const end = value.withChannelValue(channel, maxValue).toString("css") return `linear-gradient(to ${bgDirection}, ${start}, ${middle}, ${end})` } case "saturation": @@ -38,9 +41,17 @@ export const getSliderBackground = (props: SliderBackgroundProps) => { case "green": case "blue": case "alpha": { + const start = value.withChannelValue(channel, minValue).toString("css") + const end = value.withChannelValue(channel, maxValue).toString("css") + return `linear-gradient(to ${bgDirection}, ${start}, ${end})` + } + case "a": + case "b": + case "chroma": { + const interpolationMethod = `in ${value.getFormat()}` let start = value.withChannelValue(channel, minValue).toString("css") let end = value.withChannelValue(channel, maxValue).toString("css") - return `linear-gradient(to ${bgDirection}, ${start}, ${end})` + return `linear-gradient(to ${bgDirection} ${interpolationMethod}, ${start}, ${end})` } default: throw new Error("Unknown color channel: " + channel) diff --git a/packages/utilities/color-utils/src/area-gradient.ts b/packages/utilities/color-utils/src/area-gradient.ts index 5ef91b86e8..848a575245 100644 --- a/packages/utilities/color-utils/src/area-gradient.ts +++ b/packages/utilities/color-utils/src/area-gradient.ts @@ -10,6 +10,7 @@ import { generateHSB_B, generateHSL_L, generateOKLAB_L, + generateOKLCH_H, } from "./color-format-gradient" import type { ColorChannel } from "./types" @@ -34,8 +35,6 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr let dir = false - let background = { areaStyles: {}, areaGradientStyles: {} } - let alphaValue = (zValue - zMin) / (zMax - zMin) let isHSL = color.getFormat() === "hsla" @@ -57,6 +56,9 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr case "hue": { dir = xChannel !== "saturation" + if (color.getFormat() === "oklch") { + return generateOKLCH_H(orientation, dir, zValue) + } if (isHSL) { return generateHSL_H(orientation, dir, zValue) } @@ -83,6 +85,7 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr dir = xChannel === "hue" return generateHSL_L(orientation, dir, zValue) } + default: + return { areaStyles: {}, areaGradientStyles: {} } } - return background } diff --git a/packages/utilities/color-utils/src/color-format-gradient.ts b/packages/utilities/color-utils/src/color-format-gradient.ts index 71886d37b6..0b9a326923 100644 --- a/packages/utilities/color-utils/src/color-format-gradient.ts +++ b/packages/utilities/color-utils/src/color-format-gradient.ts @@ -74,6 +74,20 @@ export const generateOKLAB_L = (orientation: [string, string], dir: boolean, zVa } return result } +export const generateOKLCH_H = (orientation: [string, string], dir: boolean, zValue: number) => { + const maskImage = "linear-gradient(to bottom, transparent, black)" + const result = { + areaStyles: { + background: `linear-gradient(to ${orientation[1]} in oklch, oklch(1 0% ${zValue}), oklch(1 100% ${zValue}))`, + }, + areaGradientStyles: { + background: `linear-gradient(to ${orientation[1]} in oklch, oklch(0 0% ${zValue}), oklch(0 100% ${zValue}))`, + maskImage, + WebkitMaskImage: maskImage, + }, + } + return result +} export const generateHSL_S = (orientation: [string, string], dir: boolean, alphaValue: number) => { const result = { diff --git a/packages/utilities/color-utils/src/hsb-color.ts b/packages/utilities/color-utils/src/hsb-color.ts index 5e880a3afb..7a0dc7e9e2 100644 --- a/packages/utilities/color-utils/src/hsb-color.ts +++ b/packages/utilities/color-utils/src/hsb-color.ts @@ -54,6 +54,7 @@ export class HSBColor extends Color { case "rgba": return this.toRGB() case "oklab": + case "oklch": return this.toRGB().toFormat(format) default: throw new Error("Unsupported color conversion: hsb -> " + format) diff --git a/packages/utilities/color-utils/src/hsl-color.ts b/packages/utilities/color-utils/src/hsl-color.ts index 04232d0655..c458f729a7 100644 --- a/packages/utilities/color-utils/src/hsl-color.ts +++ b/packages/utilities/color-utils/src/hsl-color.ts @@ -54,6 +54,7 @@ export class HSLColor extends Color { case "rgba": return this.toRGB() case "oklab": + case "oklch": return this.toRGB().toFormat(format) default: throw new Error("Unsupported color conversion: hsl -> " + format) diff --git a/packages/utilities/color-utils/src/oklab-color.ts b/packages/utilities/color-utils/src/oklab-color.ts index 1cd4809f78..a2fffb55a7 100644 --- a/packages/utilities/color-utils/src/oklab-color.ts +++ b/packages/utilities/color-utils/src/oklab-color.ts @@ -1,8 +1,17 @@ -import { toFixedNumber, roundValue } from "@zag-js/utils" -import { convertOklabToRgb, clampChroma, modeRgb, modeLrgb, modeOklab, useMode as bootstrapMode } from "culori/fn" +import { toFixedNumber, roundValue, clampValue } from "@zag-js/utils" +import { + convertOklabToRgb, + clampChroma, + modeRgb, + modeLrgb, + modeOklab, + useMode as bootstrapMode, + convertLabToLch, +} from "culori/fn" import { Color } from "./color" import { RGBColor } from "./rgb-color" import type { ColorChannel, ColorChannelRange, ColorFormat, ColorStringFormat, ColorType } from "./types" +import { OklchColor } from "./oklch-color" bootstrapMode(modeRgb) export const oklab = bootstrapMode(modeOklab) @@ -25,11 +34,13 @@ export class OklabColor extends Color { return new OklabColor(l, a, b, alpha ?? 1) } - toString(format: ColorStringFormat) { + toString(format: ColorStringFormat): string { switch (format) { case "oklab": case "css": return `oklab(${this.lightness} ${this.a} ${this.b}${this.alpha < 1 ? ` / ${this.alpha}` : ""})` + case "oklch": + return this.toOklch().toString("oklch") default: return this.toRGB().toString(format) } @@ -39,6 +50,8 @@ export class OklabColor extends Color { switch (format) { case "oklab": return this + case "oklch": + return this.toOklch() case "rgba": case "hsla": case "hsba": @@ -64,6 +77,16 @@ export class OklabColor extends Color { ) } + private toOklch(): OklchColor { + const { l, c, h, alpha } = convertLabToLch({ l: this.lightness, a: this.a, b: this.b, alpha: this.alpha }) + return new OklchColor( + toFixedNumber(l, 2), + clampValue(toFixedNumber(c, 2), 0, 0.5), + toFixedNumber(h ?? 0, 2), + alpha ?? 1, + ) + } + clone(): OklabColor { return new OklabColor(this.lightness, this.a, this.b, this.alpha) } diff --git a/packages/utilities/color-utils/src/oklch-color.ts b/packages/utilities/color-utils/src/oklch-color.ts new file mode 100644 index 0000000000..fb3f5229a9 --- /dev/null +++ b/packages/utilities/color-utils/src/oklch-color.ts @@ -0,0 +1,112 @@ +import { modeOklch, useMode as bootstrapMode, convertLchToLab } from "culori/fn" +import { Color } from "./color" +import type { ColorChannel, ColorChannelRange, ColorFormat, ColorStringFormat, ColorType } from "./types" +import { OklabColor } from "./oklab-color" +import { toFixedNumber } from "@zag-js/utils" + +export const oklch = bootstrapMode(modeOklch) + +export class OklchColor extends Color { + constructor( + private lightness: number, + private chroma: number, + private hue: number, + private alpha: number, + ) { + super() + } + + static parse(value: string): OklchColor | void { + const parsed = oklch(value) + if (!parsed) return + const { l, c, h, alpha } = parsed + return new OklchColor(toFixedNumber(l, 2), toFixedNumber(c, 2), toFixedNumber(h ?? 0, 2), alpha ?? 1) + } + + toString(format: ColorStringFormat) { + switch (format) { + case "oklch": + case "css": + return `oklch(${this.lightness} ${this.chroma} ${this.hue}deg${this.alpha < 1 ? ` / ${this.alpha}` : ""})` + default: + return this.toOklab().toString(format) + } + } + + toFormat(format: ColorFormat): ColorType { + switch (format) { + case "oklch": + return this + case "oklab": + return this.toOklab() + case "rgba": + case "hsla": + case "hsba": + return this.toOklab().toFormat(format) + default: + throw new Error("Unsupported color conversion: oklab -> " + format) + } + } + + /** + * Converts a Oolab color to RGB. + * Conversion adjusts for values that couldn't be displayed in RGB + * @returns RGBColor + */ + private toOklab(): OklabColor { + const { l, a, b, alpha } = convertLchToLab({ l: this.lightness, c: this.chroma, h: this.hue, alpha: this.alpha }) + return new OklabColor(toFixedNumber(l, 2), toFixedNumber(a, 2), toFixedNumber(b, 2), alpha ?? 1) + } + + clone(): OklchColor { + return new OklchColor(this.lightness, this.chroma, this.hue, this.alpha) + } + + getChannelFormatOptions(channel: ColorChannel): Intl.NumberFormatOptions { + switch (channel) { + case "lightness": + case "alpha": + return { style: "percent" } + case "hue": + return { style: "unit", unit: "deg" } + case "chroma": + return { style: "decimal" } + default: + throw new Error("Unknown color channel: " + channel) + } + } + + formatChannelValue(channel: ColorChannel, locale: string) { + let options = this.getChannelFormatOptions(channel) + let value = this.getChannelValue(channel) + return new Intl.NumberFormat(locale, options).format(value) + } + + getChannelRange(channel: ColorChannel): ColorChannelRange { + switch (channel) { + case "hue": + return { minValue: 0, maxValue: 360, step: 0.1, pageSize: 1 } + case "chroma": + return { minValue: 0, maxValue: 0.5, step: 0.01, pageSize: 0.1 } + case "lightness": + case "alpha": + return { minValue: 0, maxValue: 1, step: 0.01, pageSize: 0.1 } + default: + throw new Error("Unknown color channel: " + channel) + } + } + + toJSON(): Record<"l" | "c" | "h" | "a", number> { + return { l: this.lightness, c: this.chroma, h: this.hue, a: this.alpha } + } + + getFormat(): ColorFormat { + return "oklch" + } + + private static colorChannels: [ColorChannel, ColorChannel, ColorChannel] = ["hue", "chroma", "lightness"] + + getChannels(): [ColorChannel, ColorChannel, ColorChannel] { + return OklchColor.colorChannels + } +} diff --git a/packages/utilities/color-utils/src/parse-color.ts b/packages/utilities/color-utils/src/parse-color.ts index 4bf4e1288e..eec4ecb0e4 100644 --- a/packages/utilities/color-utils/src/parse-color.ts +++ b/packages/utilities/color-utils/src/parse-color.ts @@ -2,6 +2,7 @@ import { HSBColor } from "./hsb-color" import { HSLColor } from "./hsl-color" import { nativeColorMap } from "./native-color" import { OklabColor } from "./oklab-color" +import { OklchColor } from "./oklch-color" import { RGBColor } from "./rgb-color" import type { ColorType } from "./types" @@ -10,7 +11,12 @@ export const parseColor = (value: string): ColorType => { return parseColor(nativeColorMap.get(value)!) } - const result = RGBColor.parse(value) || HSBColor.parse(value) || HSLColor.parse(value) || OklabColor.parse(value) + const result = + RGBColor.parse(value) || + HSBColor.parse(value) || + HSLColor.parse(value) || + OklabColor.parse(value) || + OklchColor.parse(value) if (!result) { const error = new Error("Invalid color value: " + value) diff --git a/packages/utilities/color-utils/src/rgb-color.ts b/packages/utilities/color-utils/src/rgb-color.ts index 115cf2580f..038fd5e6f3 100644 --- a/packages/utilities/color-utils/src/rgb-color.ts +++ b/packages/utilities/color-utils/src/rgb-color.ts @@ -88,7 +88,8 @@ export class RGBColor extends Color { case "hsla": return this.toHSL() case "oklab": - return this.toOklab() + case "oklch": + return this.toOklab().toFormat(format) default: throw new Error("Unsupported color conversion: rgb -> " + format) } diff --git a/packages/utilities/color-utils/src/types.ts b/packages/utilities/color-utils/src/types.ts index 5b64f282bf..8fa607d6d1 100644 --- a/packages/utilities/color-utils/src/types.ts +++ b/packages/utilities/color-utils/src/types.ts @@ -1,6 +1,6 @@ export type ColorHexFormat = "hex" | "hexa" -export type ColorFormat = "rgba" | "hsla" | "hsba" | "oklab" +export type ColorFormat = "rgba" | "hsla" | "hsba" | "oklab" | "oklch" export type ColorStringFormat = ColorHexFormat | ColorFormat | "rgb" | "hsl" | "hsb" | "css" @@ -15,6 +15,7 @@ export type ColorChannel = | "alpha" | "a" | "b" + | "chroma" export interface Color2DAxes { xChannel: ColorChannel diff --git a/shared/src/controls.ts b/shared/src/controls.ts index bc1e5ab3cc..7e17eda584 100644 --- a/shared/src/controls.ts +++ b/shared/src/controls.ts @@ -218,7 +218,7 @@ export const colorPickerControls = defineControls({ dir: { type: "select", options: ["ltr", "rtl"] as const, defaultValue: "ltr" }, format: { type: "select", - options: ["hsla", "hsba", "rgba", "oklab"] as const, + options: ["hsla", "hsba", "rgba", "oklab", "oklch"] as const, defaultValue: "hsla", forceValue: true, }, From f100cd36f5e4242a4b7dc2dd08a6b89c5a544a98 Mon Sep 17 00:00:00 2001 From: V1OL3TF0X Date: Thu, 8 May 2025 21:08:42 +0200 Subject: [PATCH 3/6] feat(color-picker): add rest of the gradients to support any channel combination --- .../color-utils/src/area-gradient.ts | 41 ++++--- .../color-utils/src/color-format-gradient.ts | 104 +++++++++++++----- 2 files changed, 97 insertions(+), 48 deletions(-) diff --git a/packages/utilities/color-utils/src/area-gradient.ts b/packages/utilities/color-utils/src/area-gradient.ts index 848a575245..328e94b783 100644 --- a/packages/utilities/color-utils/src/area-gradient.ts +++ b/packages/utilities/color-utils/src/area-gradient.ts @@ -11,6 +11,10 @@ import { generateHSL_L, generateOKLAB_L, generateOKLCH_H, + generateOKLCH_L, + generateOKLCH_C, + generateOKLAB_A, + generateOKLAB_B, } from "./color-format-gradient" import type { ColorChannel } from "./types" @@ -33,32 +37,27 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr const { minValue: zMin, maxValue: zMax } = color.getChannelRange(zChannel) const orientation: [string, string] = ["top", dirProp === "rtl" ? "left" : "right"] - let dir = false - let alphaValue = (zValue - zMin) / (zMax - zMin) let isHSL = color.getFormat() === "hsla" switch (zChannel) { case "red": { - dir = xChannel === "green" - return generateRGB_R(orientation, dir, zValue) + return generateRGB_R(orientation, xChannel === "green", zValue) } case "green": { - dir = xChannel === "red" - return generateRGB_G(orientation, dir, zValue) + return generateRGB_G(orientation, xChannel === "red", zValue) } case "blue": { - dir = xChannel === "red" - return generateRGB_B(orientation, dir, zValue) + return generateRGB_B(orientation, xChannel === "red", zValue) } case "hue": { - dir = xChannel !== "saturation" if (color.getFormat() === "oklch") { - return generateOKLCH_H(orientation, dir, zValue) + return generateOKLCH_H(orientation, xChannel === "chroma", zValue) } + const dir = xChannel !== "saturation" if (isHSL) { return generateHSL_H(orientation, dir, zValue) } @@ -66,24 +65,30 @@ export function getColorAreaGradient(color: Color, options: GradientOptions): Gr } case "saturation": { - dir = xChannel === "hue" + const dir = xChannel === "hue" if (isHSL) { return generateHSL_S(orientation, dir, alphaValue) } return generateHSB_S(orientation, dir, alphaValue) } - case "brightness": { - dir = xChannel === "hue" - return generateHSB_B(orientation, dir, alphaValue) - } + case "a": + return generateOKLAB_A(orientation, xChannel === "lightness", zValue) + case "b": + return generateOKLAB_B(orientation, xChannel === "lightness", zValue) + case "brightness": + return generateHSB_B(orientation, xChannel === "hue", alphaValue) + case "chroma": + return generateOKLCH_C(orientation, xChannel === "lightness", zValue) case "lightness": { if (color.getFormat() === "oklab") { - return generateOKLAB_L(orientation, dir, zValue) + return generateOKLAB_L(orientation, xChannel === "a", zValue) + } + if (color.getFormat() === "oklch") { + return generateOKLCH_L(orientation, xChannel === "chroma", zValue) } - dir = xChannel === "hue" - return generateHSL_L(orientation, dir, zValue) + return generateHSL_L(orientation, xChannel === "hue", zValue) } default: return { areaStyles: {}, areaGradientStyles: {} } diff --git a/packages/utilities/color-utils/src/color-format-gradient.ts b/packages/utilities/color-utils/src/color-format-gradient.ts index 0b9a326923..d65e4f8793 100644 --- a/packages/utilities/color-utils/src/color-format-gradient.ts +++ b/packages/utilities/color-utils/src/color-format-gradient.ts @@ -1,6 +1,6 @@ export const generateRGB_R = (orientation: [string, string], dir: boolean, zValue: number) => { const maskImage = `linear-gradient(to ${orientation[Number(!dir)]}, transparent, #000)` - const result = { + return { areaStyles: { backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(${zValue},0,0),rgb(${zValue},255,0))`, }, @@ -10,12 +10,11 @@ export const generateRGB_R = (orientation: [string, string], dir: boolean, zValu maskImage, }, } - return result } export const generateRGB_G = (orientation: [string, string], dir: boolean, zValue: number) => { const maskImage = `linear-gradient(to ${orientation[Number(!dir)]}, transparent, #000)` - const result = { + return { areaStyles: { backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(0,${zValue},0),rgb(255,${zValue},0))`, }, @@ -25,12 +24,11 @@ export const generateRGB_G = (orientation: [string, string], dir: boolean, zValu maskImage, }, } - return result } export const generateRGB_B = (orientation: [string, string], dir: boolean, zValue: number) => { const maskImage = `linear-gradient(to ${orientation[Number(!dir)]}, transparent, #000)` - const result = { + return { areaStyles: { backgroundImage: `linear-gradient(to ${orientation[Number(dir)]},rgb(0,0,${zValue}),rgb(255,0,${zValue}))`, }, @@ -40,11 +38,10 @@ export const generateRGB_B = (orientation: [string, string], dir: boolean, zValu maskImage, }, } - return result } export const generateHSL_H = (orientation: [string, string], dir: boolean, zValue: number) => { - const result = { + return { areaStyles: {}, areaGradientStyles: { background: [ @@ -56,41 +53,93 @@ export const generateHSL_H = (orientation: [string, string], dir: boolean, zValu ].join(","), }, } - return result } -export const generateOKLAB_L = (orientation: [string, string], dir: boolean, zValue: number) => { - const maskImage = "linear-gradient(to bottom, transparent, black)" +export const generateOKLAB_L = (orientation: [string, string], isAX: boolean, zValue: number) => { + const maskImage = `linear-gradient(to ${orientation[Number(!isAX)]}, black, transparent)` const l = zValue === 0 ? "none" : `${(zValue * 100).toFixed(2)}%` - const result = { + return { areaStyles: { - background: `linear-gradient(to ${orientation[1]} in oklab, oklab(${l} -100% 100%), oklab(${l} 0 100%) 50%, oklab(${l} 100% 100%))`, + background: `linear-gradient(to ${orientation[Number(isAX)]} in oklab, oklab(${l} -100% 100%), oklab(${l} 100% 100%))`, }, areaGradientStyles: { - background: `linear-gradient(to ${orientation[1]} in oklab, oklab(${l} -100% -100%), oklab(${l} 0 -100%) 50%, oklab(${l} 100% -100%))`, + background: `linear-gradient(to ${orientation[Number(isAX)]} in oklab, oklab(${l} -100% -100%), oklab(${l} 100% -100%))`, maskImage, WebkitMaskImage: maskImage, }, } - return result } -export const generateOKLCH_H = (orientation: [string, string], dir: boolean, zValue: number) => { - const maskImage = "linear-gradient(to bottom, transparent, black)" - const result = { +export const generateOKLAB_A = (orientation: [string, string], isLightnessX: boolean, zValue: number) => { + const maskImage = `linear-gradient(to ${orientation[Number(isLightnessX)]}, black, transparent)` + return { areaStyles: { - background: `linear-gradient(to ${orientation[1]} in oklch, oklch(1 0% ${zValue}), oklch(1 100% ${zValue}))`, + background: `linear-gradient(to ${orientation[Number(!isLightnessX)]} in oklab, oklab(1 ${zValue} -100%), oklab(1 ${zValue} 100%))`, }, areaGradientStyles: { - background: `linear-gradient(to ${orientation[1]} in oklch, oklch(0 0% ${zValue}), oklch(0 100% ${zValue}))`, + background: `linear-gradient(to ${orientation[Number(!isLightnessX)]} in oklab, oklab(0 ${zValue} -100%), oklab(0 ${zValue} 100%))`, + maskImage, + WebkitMaskImage: maskImage, + }, + } +} +export const generateOKLAB_B = (orientation: [string, string], isLightnessX: boolean, zValue: number) => { + const maskImage = `linear-gradient(to ${orientation[Number(isLightnessX)]}, black, transparent)` + return { + areaStyles: { + background: `linear-gradient(to ${orientation[Number(!isLightnessX)]} in oklab, oklab(1 -100% ${zValue}), oklab(1 100% ${zValue}))`, + }, + areaGradientStyles: { + background: `linear-gradient(to ${orientation[Number(!isLightnessX)]} in oklab, oklab(0 -100% ${zValue}), oklab(0 100% ${zValue}))`, + maskImage, + WebkitMaskImage: maskImage, + }, + } +} + +export const generateOKLCH_H = (orientation: [string, string], isChromaX: boolean, zValue: number) => { + const maskImage = `linear-gradient(to ${orientation[Number(!isChromaX)]}, black, transparent)` + return { + areaStyles: { + background: `linear-gradient(to ${orientation[Number(isChromaX)]} in oklch, oklch(1 0% ${zValue}), oklch(1 100% ${zValue}))`, + }, + areaGradientStyles: { + background: `linear-gradient(to ${orientation[Number(isChromaX)]} in oklch, oklch(0 0% ${zValue}), oklch(0 100% ${zValue}))`, + maskImage, + WebkitMaskImage: maskImage, + }, + } +} + +export const generateOKLCH_L = (orientation: [string, string], isChromaX: boolean, zValue: number) => { + const maskImage = `linear-gradient(to ${orientation[Number(isChromaX)]}, transparent, black)` + return { + areaStyles: { + background: `linear-gradient(to ${orientation[Number(!isChromaX)]} in oklch increasing hue, oklch(${zValue} 0% 0deg), oklch(${zValue} 0% 359.9eg))`, + }, + areaGradientStyles: { + background: `linear-gradient(to ${orientation[Number(!isChromaX)]} in oklch increasing hue, oklch(${zValue} 100% 0deg), oklch(${zValue} 100% 359.9deg))`, + maskImage, + WebkitMaskImage: maskImage, + }, + } +} + +export const generateOKLCH_C = (orientation: [string, string], isLightnessX: boolean, zValue: number) => { + const maskImage = `linear-gradient(to ${orientation[Number(isLightnessX)]}, transparent, black)` + return { + areaStyles: { + background: `linear-gradient(to ${orientation[Number(!isLightnessX)]} in oklch increasing hue, oklch(0 ${zValue} 0deg), oklch(0 ${zValue} 359.9deg))`, + }, + areaGradientStyles: { + background: `linear-gradient(to ${orientation[Number(!isLightnessX)]} in oklch increasing hue, oklch(1 ${zValue} 0deg), oklch(1 ${zValue} 359.9deg))`, maskImage, WebkitMaskImage: maskImage, }, } - return result } export const generateHSL_S = (orientation: [string, string], dir: boolean, alphaValue: number) => { - const result = { + return { areaStyles: {}, areaGradientStyles: { background: [ @@ -104,11 +153,10 @@ export const generateHSL_S = (orientation: [string, string], dir: boolean, alpha ].join(","), }, } - return result } export const generateHSL_L = (orientation: [string, string], dir: boolean, zValue: number) => { - const result = { + return { areaStyles: {}, areaGradientStyles: { backgroundImage: [ @@ -119,11 +167,10 @@ export const generateHSL_L = (orientation: [string, string], dir: boolean, zValu ].join(","), }, } - return result } export const generateHSB_H = (orientation: [string, string], dir: boolean, zValue: number) => { - const result = { + return { areaStyles: {}, areaGradientStyles: { background: [ @@ -133,11 +180,10 @@ export const generateHSB_H = (orientation: [string, string], dir: boolean, zValu ].join(","), }, } - return result } export const generateHSB_S = (orientation: [string, string], dir: boolean, alphaValue: number) => { - const result = { + return { areaStyles: {}, areaGradientStyles: { background: [ @@ -149,11 +195,10 @@ export const generateHSB_S = (orientation: [string, string], dir: boolean, alpha ].join(","), }, } - return result } export const generateHSB_B = (orientation: [string, string], dir: boolean, alphaValue: number) => { - const result = { + return { areaStyles: {}, areaGradientStyles: { background: [ @@ -165,5 +210,4 @@ export const generateHSB_B = (orientation: [string, string], dir: boolean, alpha ].join(","), }, } - return result } From bf1fb50f37f8f7764d8214fca7f1e358f1086f7a Mon Sep 17 00:00:00 2001 From: V1OL3TF0X Date: Fri, 9 May 2025 14:33:22 +0200 Subject: [PATCH 4/6] feat(color-picker): update examples --- examples/next-ts/hooks/use-controls.tsx | 5 +- examples/next-ts/pages/color-picker.tsx | 101 ++++++++++++++--- examples/nuxt-ts/components/Controls.vue | 63 ++++------- examples/nuxt-ts/pages/color-picker.vue | 102 +++++++++++++----- examples/solid-ts/src/components/controls.tsx | 10 +- examples/solid-ts/src/routes/color-picker.tsx | 86 +++++++++++++-- .../src/lib/components/controls.svelte | 14 +-- .../svelte-ts/src/routes/color-picker.svelte | 71 ++++++++++-- .../color-picker/src/color-picker.connect.ts | 8 +- .../color-picker/src/color-picker.machine.ts | 6 +- .../src/utils/get-slider-background.ts | 4 +- .../color-utils/src/color-format-gradient.ts | 4 +- shared/src/controls.ts | 2 +- 13 files changed, 350 insertions(+), 126 deletions(-) diff --git a/examples/next-ts/hooks/use-controls.tsx b/examples/next-ts/hooks/use-controls.tsx index 2676abb246..df447ee6ee 100644 --- a/examples/next-ts/hooks/use-controls.tsx +++ b/examples/next-ts/hooks/use-controls.tsx @@ -1,4 +1,3 @@ -/* eslint-disable jsx-a11y/no-onchange */ import { ControlRecord, deepGet, deepSet, getControlDefaults } from "@zag-js/shared" import { useState } from "react" @@ -18,7 +17,7 @@ export function useControls(config: T) { ui: () => (
{Object.keys(config).map((key) => { - const { type, label = key, options, placeholder, min, max } = (config[key] ?? {}) as any + const { type, label = key, options, placeholder, min, max, forceValue } = (config[key] ?? {}) as any const value = deepGet(state, key) switch (type) { case "boolean": @@ -67,7 +66,7 @@ export function useControls(config: T) { setState(key, e.target.value) }} > - + {!forceValue && } {options.map((option) => (