From af695d2e438918d19e14045836f742a8ca13d6da Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 20 May 2026 07:44:11 +0530 Subject: [PATCH 1/7] feat(color-picker): migrate to culori and add OKLCH mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `color` dependency with `culori` so the picker can accept and emit `oklch()` values alongside hex/rgb/hsl. The picker's HSL state shape is unchanged, so sliders and the context contract are untouched. OKLCH input is parsed via culori; OKLCH output is formatted with 4-decimal L/C, 2-decimal H, hue pinned to 0 for achromatic colors. Hex output remains uppercase with alpha-aware width to match the previous behavior. Note: sliders still operate in HSL — OKLCH is an I/O format, not a perceptual editing mode, so OKLCH round-trips are not bit-identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/examples/color-picker/page.tsx | 169 ++++++++++++++++++ .../docs/components/color-picker/demo.ts | 22 +++ .../docs/components/color-picker/index.mdx | 11 +- .../docs/components/color-picker/props.ts | 12 +- .../__tests__/color-picker.test.tsx | 36 ++++ .../color-picker/color-picker-area.tsx | 9 +- .../color-picker/color-picker-input.tsx | 8 +- .../color-picker/color-picker-root.tsx | 22 +-- .../raystack/components/color-picker/utils.ts | 101 +++++++++-- packages/raystack/package.json | 3 +- pnpm-lock.yaml | 22 ++- 11 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 apps/www/src/app/examples/color-picker/page.tsx diff --git a/apps/www/src/app/examples/color-picker/page.tsx b/apps/www/src/app/examples/color-picker/page.tsx new file mode 100644 index 000000000..3c31891f0 --- /dev/null +++ b/apps/www/src/app/examples/color-picker/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { Button, ColorPicker, Flex, Popover, Text } from '@raystack/apsara'; +import { useState } from 'react'; + +const cardStyle = { + width: 280, + padding: 16, + borderRadius: 8, + background: 'var(--rs-color-background-base-primary)', + border: '1px solid var(--rs-color-border-base-primary)' +} as const; + +export default function ColorPickerExamplesPage() { + const [controlledValue, setControlledValue] = useState( + 'oklch(0.5438 0.191 267.01)' + ); + const [controlledMode, setControlledMode] = useState< + 'hex' | 'rgb' | 'hsl' | 'oklch' + >('oklch'); + const [popoverColor, setPopoverColor] = useState('#DA2929'); + + return ( + + + + ColorPicker + + + The picker supports hex, rgb, hsl, and oklch as input and output + formats. Internal sliders operate in HSL; oklch is a serialization + mode, not a perceptual editing mode. + + + + + + + Default (hex) + + + + + + + + + + + + + + + OKLCH mode + + + defaultValue='oklch(0.5438 0.191 267.01)'{' '} + with defaultMode='oklch'. + + + + + + + + + + + + + + + Controlled — emits live value + + { + setControlledValue(value); + setControlledMode(mode as typeof controlledMode); + }} + onModeChange={mode => + setControlledMode(mode as typeof controlledMode) + } + > + + + + + + + + + + + value: + + + {controlledValue} + + + mode: {controlledMode} + + + + + + + Popover trigger + + + + } + /> + + + + + + + + + + + + + + {popoverColor} + + + + + ); +} diff --git a/apps/www/src/content/docs/components/color-picker/demo.ts b/apps/www/src/content/docs/components/color-picker/demo.ts index 37e72e9bd..894c1e713 100644 --- a/apps/www/src/content/docs/components/color-picker/demo.ts +++ b/apps/www/src/content/docs/components/color-picker/demo.ts @@ -36,6 +36,28 @@ export const basicDemo = { ` }; +export const oklchDemo = { + type: 'code', + code: ` + + + + + + + +` +}; + export const popoverDemo = { type: 'code', previewCode: false, diff --git a/apps/www/src/content/docs/components/color-picker/index.mdx b/apps/www/src/content/docs/components/color-picker/index.mdx index 9722535ce..6e4a54551 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -7,6 +7,7 @@ source: packages/raystack/components/color-picker import { preview, basicDemo, + oklchDemo, popoverDemo, } from "./demo.ts"; @@ -50,7 +51,7 @@ Provides a slider for selecting the alpha value of the color. ### Mode -Lets users switch between different color models (e.g., HEX, RGB, HSL) via a dropdown menu. +Lets users switch between different color models (HEX, RGB, HSL, OKLCH) via a dropdown menu. @@ -64,6 +65,14 @@ Displays the current color value in the selected color model and allows direct t +### OKLCH Mode + +The picker accepts and emits `oklch()` values. Pass an `oklch(...)` string as `defaultValue` and set `defaultMode='oklch'` to have the input render and emit oklch. + +Note that the picker's sliders still operate in HSL — `oklch` here is an input/output format, not a perceptual editing mode. The emitted oklch value is the result of an HSL → sRGB → OKLCH conversion, so a value-in will not be bit-identical to the value-out. + + + ### Popover Integration The `ColorPicker` can be embedded within a `Popover` component to create a more interactive and space-efficient color selection experience. diff --git a/apps/www/src/content/docs/components/color-picker/props.ts b/apps/www/src/content/docs/components/color-picker/props.ts index 7a399ebde..f67fdcc4b 100644 --- a/apps/www/src/content/docs/components/color-picker/props.ts +++ b/apps/www/src/content/docs/components/color-picker/props.ts @@ -3,7 +3,7 @@ */ export interface ColorPickerProps { /** - * The controlled color value. Accepts hex, rgb, hsl, etc. + * The controlled color value. Accepts hex, rgb, hsl, oklch, etc. */ value?: string; /** @@ -16,14 +16,14 @@ export interface ColorPickerProps { */ onValueChange?: (value: string, mode: string) => void; /** - * The initial color mode (hex, rgb, hsl). + * The initial color mode (hex, rgb, hsl, oklch). * @default 'hex' */ - defaultMode?: 'hex' | 'rgb' | 'hsl'; + defaultMode?: 'hex' | 'rgb' | 'hsl' | 'oklch'; /** * The controlled color mode. */ - mode?: 'hex' | 'rgb' | 'hsl'; + mode?: 'hex' | 'rgb' | 'hsl' | 'oklch'; /** * Callback fired when the color mode changes. */ @@ -36,7 +36,7 @@ export interface ColorPickerProps { export interface ColorPickerModeProps { /** * Supported color modes for the picker. - * @default ['hex', 'rgb', 'hsl'] + * @default ['hex', 'rgb', 'hsl', 'oklch'] */ - options?: Array<'hex' | 'rgb' | 'hsl'>; + options?: Array<'hex' | 'rgb' | 'hsl' | 'oklch'>; } diff --git a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx index 5d685f2a5..a2c8020d3 100644 --- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx +++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx @@ -202,6 +202,42 @@ describe('ColorPicker', () => { input = screen.getByTestId('color-input'); expect(input).toHaveValue('#00FF00'); }); + + it('accepts oklch input', () => { + render( + + + + ); + const input = screen.getByTestId('color-input'); + // Should render *some* hex value without throwing; exact bytes depend on + // HSL round-trip so we only assert shape. + expect((input as HTMLInputElement).value).toMatch(/^#[0-9A-F]{6}$/); + }); + + it('emits oklch when mode is oklch', () => { + render( + + + + ); + const input = screen.getByTestId('color-input'); + expect((input as HTMLInputElement).value).toMatch( + /^oklch\([\d.]+ [\d.]+ [\d.]+\)$/ + ); + }); + + it('emits oklch with alpha tail when alpha < 1', () => { + render( + + + + ); + const input = screen.getByTestId('color-input'); + expect((input as HTMLInputElement).value).toMatch( + /^oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)$/ + ); + }); }); describe('ColorPicker.Mode', () => { diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx index 450db0599..e1c35c293 100644 --- a/packages/raystack/components/color-picker/color-picker-area.tsx +++ b/packages/raystack/components/color-picker/color-picker-area.tsx @@ -1,7 +1,6 @@ 'use client'; import { cx } from 'class-variance-authority'; -import Color from 'color'; import { ComponentProps, PointerEvent as ReactPointerEvent, @@ -12,6 +11,7 @@ import { } from 'react'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; +import { getColorString } from './utils'; export type ColorPickerAreaProps = ComponentProps<'div'>; @@ -25,7 +25,10 @@ export const ColorPickerArea = ({ const isThumbVisible = useRef(false); const { hue, saturation, lightness, setColor } = useColorPicker(); - const color = Color.hsl(hue, saturation, lightness); + const thumbColor = getColorString( + { h: hue, s: saturation, l: lightness, alpha: 1 }, + 'hex' + ); const backgroundGradient = useMemo(() => { return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), @@ -107,7 +110,7 @@ export const ColorPickerArea = ({ className={cx(styles.sliderThumb, styles.selectionThumb)} ref={thumbRef} style={{ - background: color.hex().toString(), + background: thumbColor, opacity: 0 }} /> diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx index 79ba97e21..4541e06f5 100644 --- a/packages/raystack/components/color-picker/color-picker-input.tsx +++ b/packages/raystack/components/color-picker/color-picker-input.tsx @@ -1,6 +1,5 @@ 'use client'; -import Color from 'color'; import { ComponentProps } from 'react'; import { Input } from '../input'; import { useColorPicker } from './color-picker-root'; @@ -8,9 +7,12 @@ import { getColorString } from './utils'; export const ColorPickerInput = (props: ComponentProps) => { const { hue, saturation, lightness, alpha, mode } = useColorPicker(); - const color = Color.hsl(hue, saturation, lightness, alpha ?? 1); + const value = getColorString( + { h: hue, s: saturation, l: lightness, alpha: alpha ?? 1 }, + mode + ); - return ; + return ; }; ColorPickerInput.displayName = 'ColorPicker.Input'; diff --git a/packages/raystack/components/color-picker/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx index 97c59b4d2..6cc9f2536 100644 --- a/packages/raystack/components/color-picker/color-picker-root.tsx +++ b/packages/raystack/components/color-picker/color-picker-root.tsx @@ -1,6 +1,5 @@ 'use client'; -import Color, { type ColorLike } from 'color'; import { ComponentProps, createContext, @@ -9,7 +8,7 @@ import { useState } from 'react'; import { Flex } from '../flex'; -import { ColorObject, getColorString, ModeType } from './utils'; +import { ColorObject, getColorString, ModeType, parseColor } from './utils'; type ColorPickerContextValue = { hue: number; @@ -35,8 +34,8 @@ export const useColorPicker = () => { export interface ColorPickerProps extends Omit, 'defaultValue'> { - value?: ColorLike; - defaultValue?: ColorLike; + value?: string; + defaultValue?: string; onValueChange?: (value: string, mode: string) => void; defaultMode?: ModeType; mode?: ModeType; @@ -51,11 +50,9 @@ export const ColorPickerRoot = ({ onModeChange, ...props }: ColorPickerProps) => { - const providedColor = value && (Color(value).hsl().object() as ColorObject); + const providedColor = value ? parseColor(value) : undefined; - const [internalColor, setInternalColor] = useState( - Color(defaultValue).hsl().object() as ColorObject - ); + const [internalColor, setInternalColor] = useState(parseColor(defaultValue)); const [internalMode, setInternalMode] = useState(defaultMode); const hue = providedColor ? providedColor.h : internalColor.h; @@ -73,14 +70,7 @@ export const ColorPickerRoot = ({ if (!onValueChange) return updatedColor; - const color = Color.hsl( - updatedColor.h, - updatedColor.s, - updatedColor.l, - updatedColor?.alpha ?? 1 - ); - - onValueChange(getColorString(color, mode), mode); + onValueChange(getColorString(updatedColor, mode), mode); return updatedColor; }); diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index fe7a6d696..d4e962471 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -1,17 +1,13 @@ -import { type ColorInstance } from 'color'; +import { + converter, + formatHex, + formatHex8, + formatHsl, + formatRgb, + parse +} from 'culori'; -export const getColorString = (color: ColorInstance, mode: string) => { - let string; - if (mode === 'hex') - string = - color.alpha() === 1 ? color.hex().toString() : color.hexa().toString(); - else if (mode === 'hsl') string = color.hsl().toString(); - else string = color.rgb().toString(); - - return string; -}; - -export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb']; +export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch']; export type ModeType = (typeof SUPPORTED_MODES)[number]; @@ -21,3 +17,82 @@ export type ColorObject = { l: number; alpha?: number; }; + +const toHsl = converter('hsl'); +const toOklch = converter('oklch'); + +// culori stores HSL with s/l in 0-1; the picker stores them in 0-100 to keep +// the existing context contract intact. +const HSL_PERCENT = 100; + +const FALLBACK: ColorObject = { h: 0, s: 0, l: 100, alpha: 1 }; + +const round = (n: number, p: number) => Number.parseFloat(n.toFixed(p)); + +/** + * Serializes a culori-shaped HSL color as oklch(L C H[ / A]). + * Matches the design system's token format: 4-decimal L/C, 2-decimal H, + * H pinned to 0 for achromatic colors (culori would emit `none` per CSS + * Color 4, which is correct but inconsistent with how tokens are written). + */ +const formatOklch = (hsl: { + mode: 'hsl'; + h: number; + s: number; + l: number; + alpha: number; +}): string => { + const oklch = toOklch(hsl); + if (!oklch) return ''; + const L = round(oklch.l ?? 0, 4); + const C = round(oklch.c ?? 0, 4); + const H = C === 0 || !Number.isFinite(oklch.h) ? 0 : round(oklch.h ?? 0, 2); + const body = `${L} ${C} ${H}`; + return hsl.alpha === 1 + ? `oklch(${body})` + : `oklch(${body} / ${round(hsl.alpha, 4)})`; +}; + +/** + * Parses any CSS color string into the picker's `{h, s, l, alpha}` shape. + * Returns a white fallback (matching the picker's previous default) when the + * input fails to parse, so the picker never throws on bad consumer input. + */ +export const parseColor = (value: string): ColorObject => { + const parsed = parse(value); + if (!parsed) return FALLBACK; + const hsl = toHsl(parsed); + if (!hsl) return FALLBACK; + return { + h: hsl.h ?? 0, + s: (hsl.s ?? 0) * HSL_PERCENT, + l: (hsl.l ?? 0) * HSL_PERCENT, + alpha: hsl.alpha ?? 1 + }; +}; + +/** + * Serializes `{h, s, l, alpha}` to a CSS string in the requested mode. + * Hex output is uppercase and uses 8-digit form only when alpha < 1, mirroring + * the previous `color@5` behavior the tests depend on. + */ +export const getColorString = (color: ColorObject, mode: ModeType): string => { + const culoriColor = { + mode: 'hsl' as const, + h: color.h, + s: color.s / HSL_PERCENT, + l: color.l / HSL_PERCENT, + alpha: color.alpha ?? 1 + }; + + if (mode === 'hex') { + const hex = + culoriColor.alpha === 1 + ? formatHex(culoriColor) + : formatHex8(culoriColor); + return hex.toUpperCase(); + } + if (mode === 'hsl') return formatHsl(culoriColor); + if (mode === 'oklch') return formatOklch(culoriColor); + return formatRgb(culoriColor); +}; diff --git a/packages/raystack/package.json b/packages/raystack/package.json index f26c641f4..f40d3f650 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -89,6 +89,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@testing-library/user-event": "^14.5.2", + "@types/culori": "^4.0.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitest/ui": "^3.2.4", @@ -121,7 +122,7 @@ "@tanstack/react-virtual": "^3.13.13", "@tanstack/table-core": "^8.9.2", "class-variance-authority": "^0.7.1", - "color": "^5.0.0", + "culori": "^4.0.2", "dayjs": "^1.11.11", "prism-react-renderer": "^2.4.1", "react-day-picker": "^9.6.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d56606f33..634274492 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,9 +199,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 - color: - specifier: ^5.0.0 - version: 5.0.0 + culori: + specifier: ^4.0.2 + version: 4.0.2 dayjs: specifier: ^1.11.11 version: 1.11.11 @@ -242,6 +242,9 @@ importers: '@testing-library/user-event': specifier: ^14.5.2 version: 14.5.2(@testing-library/dom@10.4.0) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/react': specifier: ^19.0.0 version: 19.1.9 @@ -3708,6 +3711,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/culori@4.0.1': + resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4598,6 +4604,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + culori@4.0.2: + resolution: {integrity: sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -12704,7 +12714,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -12829,6 +12839,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/culori@4.0.1': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -13810,6 +13822,8 @@ snapshots: csstype@3.1.3: {} + culori@4.0.2: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 From 5a23cd6be9cd9efc8675bead1321309e8e5b80cd Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Wed, 20 May 2026 11:19:39 +0530 Subject: [PATCH 2/7] chore(color-picker): narrow ModeType via `as const` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without `as const`, `SUPPORTED_MODES` is `string[]` so `ModeType` widens to `string` — invalid modes pass type-checking and silently fall back to RGB in `getColorString`. `options` on `ColorPickerMode` is widened to `readonly ModeType[]` to accept the const tuple as its default. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/raystack/components/color-picker/color-picker-mode.tsx | 2 +- packages/raystack/components/color-picker/utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/color-picker/color-picker-mode.tsx b/packages/raystack/components/color-picker/color-picker-mode.tsx index 4ba509ee5..b178c8faf 100644 --- a/packages/raystack/components/color-picker/color-picker-mode.tsx +++ b/packages/raystack/components/color-picker/color-picker-mode.tsx @@ -9,7 +9,7 @@ import { ModeType, SUPPORTED_MODES } from './utils'; export interface ColorPickerModeProps extends ComponentProps { - options?: ModeType[]; + options?: readonly ModeType[]; } export const ColorPickerMode = ({ diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index d4e962471..be88e9e38 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -7,7 +7,7 @@ import { parse } from 'culori'; -export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch']; +export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch'] as const; export type ModeType = (typeof SUPPORTED_MODES)[number]; From 59c24913314832b44440394a22923e36984ac478 Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Mon, 25 May 2026 09:11:50 +0530 Subject: [PATCH 3/7] feat(color-picker): enhance OKLCH mode functionality and documentation --- .../src/app/examples/color-picker/page.tsx | 7 +- .../docs/components/color-picker/index.mdx | 6 +- .../__tests__/color-picker.test.tsx | 21 +- .../color-picker/__tests__/utils.test.ts | 85 ++++++++ .../color-picker/color-picker-area.tsx | 191 +++++++++++++---- .../color-picker/color-picker-hue.tsx | 32 ++- .../color-picker/color-picker-input.tsx | 4 +- .../color-picker/color-picker-root.tsx | 52 +++-- .../color-picker/color-picker.module.css | 52 ++++- .../raystack/components/color-picker/utils.ts | 197 +++++++++++++----- 10 files changed, 506 insertions(+), 141 deletions(-) create mode 100644 packages/raystack/components/color-picker/__tests__/utils.test.ts diff --git a/apps/www/src/app/examples/color-picker/page.tsx b/apps/www/src/app/examples/color-picker/page.tsx index 3c31891f0..f9a12c192 100644 --- a/apps/www/src/app/examples/color-picker/page.tsx +++ b/apps/www/src/app/examples/color-picker/page.tsx @@ -35,9 +35,10 @@ export default function ColorPickerExamplesPage() { ColorPicker - The picker supports hex, rgb, hsl, and oklch as input and output - formats. Internal sliders operate in HSL; oklch is a serialization - mode, not a perceptual editing mode. + The picker edits internally in OKLCH for perceptual uniformity. The + area pad shows a chroma × lightness cross-section at the selected hue; + the mode prop selects the output format (hex / rgb / hsl + / oklch). diff --git a/apps/www/src/content/docs/components/color-picker/index.mdx b/apps/www/src/content/docs/components/color-picker/index.mdx index 6e4a54551..04dfdb044 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -39,7 +39,7 @@ The `ColorPicker` is composed of several subcomponents, each responsible for a s ### Area -Enables users to select a color from a palette. Typically used for choosing saturation and brightness. +Enables users to select a color from a 2D palette. The surface adapts to the active mode: in `hex`, `rgb`, and `hsl` modes it renders the classic saturation × brightness gradient square; in `oklch` mode it renders a chroma × lightness plane covering the full P3 gamut. ### Hue @@ -67,9 +67,7 @@ Displays the current color value in the selected color model and allows direct t ### OKLCH Mode -The picker accepts and emits `oklch()` values. Pass an `oklch(...)` string as `defaultValue` and set `defaultMode='oklch'` to have the input render and emit oklch. - -Note that the picker's sliders still operate in HSL — `oklch` here is an input/output format, not a perceptual editing mode. The emitted oklch value is the result of an HSL → sRGB → OKLCH conversion, so a value-in will not be bit-identical to the value-out. +The picker stores color internally in OKLCH. In `hex`, `rgb`, and `hsl` modes the area pad is the familiar HSL saturation × brightness square and the hue slider runs in HSL hue — so the picker behaves like a traditional color picker and only emits colors representable in the chosen format. Pass an `oklch(...)` string as `defaultValue` and set `defaultMode='oklch'` to switch to a chroma × lightness plane covering the full P3 gamut; the hue slider then runs in OKLCH hue, where equal steps correspond to equal perceptual changes. diff --git a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx index a2c8020d3..2b477326b 100644 --- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx +++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx @@ -66,17 +66,30 @@ describe('ColorPicker', () => { expect(area).toHaveClass('custom-area'); }); - it('renders with background gradient', () => { + it('renders the gradient canvas in oklch mode', () => { render( - + ); const area = screen.getByTestId('color-area'); - expect(area).toHaveStyle( - 'background: linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)), hsl(0, 100%, 50%)' + expect(area.querySelector('canvas')).toBeInTheDocument(); + }); + + it('renders the HSL gradient surface in non-oklch modes', () => { + render( + + + ); + + const area = screen.getByTestId('color-area'); + // Non-oklch modes use a CSS-gradient div, not the canvas-painted + // OKLCH plane — the absence of is what distinguishes them. + // (jsdom rejects `linear-gradient(...)` inline styles, so we can't + // assert the background string directly.) + expect(area.querySelector('canvas')).not.toBeInTheDocument(); }); }); diff --git a/packages/raystack/components/color-picker/__tests__/utils.test.ts b/packages/raystack/components/color-picker/__tests__/utils.test.ts new file mode 100644 index 000000000..26c9c52c6 --- /dev/null +++ b/packages/raystack/components/color-picker/__tests__/utils.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { + CHROMA_MAX, + clampToSrgb, + getColorString, + hslToOklch, + oklchToHsl, + parseColor +} from '../utils'; + +const closeTo = (a: number, b: number, tolerance = 0.5) => + Math.abs(a - b) <= tolerance; + +describe('color-picker utils', () => { + describe('parseColor', () => { + it('parses hex into OKLCH', () => { + const c = parseColor('#ff0000'); + expect(c.l).toBeGreaterThan(0); + expect(c.c).toBeGreaterThan(0); + expect(c.alpha).toBe(1); + }); + + it('pins hue to 0 for achromatic colors', () => { + const c = parseColor('#808080'); + expect(c.c).toBe(0); + expect(c.h).toBe(0); + }); + + it('falls back to white on unparseable input', () => { + const c = parseColor('not a color'); + expect(c).toEqual({ l: 1, c: 0, h: 0, alpha: 1 }); + }); + }); + + describe('oklchToHsl', () => { + it('round-trips a saturated red through HSL', () => { + const oklch = parseColor('#ff0000'); + const hsl = oklchToHsl(oklch); + expect(closeTo(hsl.h, 0, 1)).toBe(true); + expect(closeTo(hsl.s, 100, 1)).toBe(true); + expect(closeTo(hsl.l, 50, 1)).toBe(true); + }); + + it('preserves the input hue when the color is achromatic', () => { + // Internal state may carry a non-zero hue even when chroma is 0; HSL + // conversion would normally produce NaN — the helper falls back to the + // OKLCH hue so the user's last hue choice isn't lost at the s=0 axis. + const hsl = oklchToHsl({ l: 0.5, c: 0, h: 200 }); + expect(hsl.s).toBeCloseTo(0, 6); + expect(hsl.h).toBe(200); + }); + }); + + describe('hslToOklch', () => { + it('round-trips through OKLCH back to the same HSL', () => { + const oklch = hslToOklch(120, 50, 50); + const hsl = oklchToHsl(oklch); + expect(closeTo(hsl.h, 120)).toBe(true); + expect(closeTo(hsl.s, 50)).toBe(true); + expect(closeTo(hsl.l, 50)).toBe(true); + }); + + it('preserves the input hue when saturation is 0', () => { + const oklch = hslToOklch(200, 0, 50); + expect(oklch.c).toBe(0); + expect(oklch.h).toBe(200); + }); + + it('produces a color whose HSL serialization matches the input', () => { + const oklch = hslToOklch(45, 80, 60); + const out = getColorString({ ...oklch, alpha: 1 }, 'hsl'); + expect(out).toMatch(/^hsl\(/); + }); + }); + + describe('clampToSrgb', () => { + it('reduces chroma so the color fits the sRGB gamut', () => { + const wide = { l: 0.7, c: CHROMA_MAX, h: 30, alpha: 1 }; + const clamped = clampToSrgb(wide); + expect(clamped.c).toBeLessThan(wide.c); + expect(closeTo(clamped.l, wide.l, 0.01)).toBe(true); + expect(closeTo(clamped.h, wide.h, 0.01)).toBe(true); + }); + }); +}); diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx index e1c35c293..fc66d4014 100644 --- a/packages/raystack/components/color-picker/color-picker-area.tsx +++ b/packages/raystack/components/color-picker/color-picker-area.tsx @@ -11,72 +11,187 @@ import { } from 'react'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; -import { getColorString } from './utils'; +import { + CHROMA_MAX, + clamp01, + getColorString, + hslToOklch, + oklchToHsl, + oklchToRgb +} from './utils'; + +// Internal pixel resolution for the C × L plane. CSS upscales this to the +// container size; a 96² grid is the sweet spot between a smooth gradient and +// keeping the per-hue repaint comfortably inside one frame. +const CANVAS_RES = 96; export type ColorPickerAreaProps = ComponentProps<'div'>; -export const ColorPickerArea = ({ - className, - ...props -}: ColorPickerAreaProps) => { +export const ColorPickerArea = (props: ColorPickerAreaProps) => { + const { mode } = useColorPicker(); + return mode === 'oklch' ? : ; +}; + +ColorPickerArea.displayName = 'ColorPicker.Area'; + +// OKLCH mode: chroma × lightness plane covering the full P3 gamut. Channels +// outside sRGB are channel-clipped for display; the input remains true OKLCH. +const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => { const containerRef = useRef(null); + const canvasRef = useRef(null); const thumbRef = useRef(null); const isDragging = useRef(false); const isThumbVisible = useRef(false); - const { hue, saturation, lightness, setColor } = useColorPicker(); + const { lightness, chroma, hue, setColor } = useColorPicker(); const thumbColor = getColorString( - { h: hue, s: saturation, l: lightness, alpha: 1 }, + { l: lightness, c: chroma, h: hue, alpha: 1 }, 'hex' ); - const backgroundGradient = useMemo(() => { - return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), - linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)), - hsl(${hue}, 100%, 50%)`; + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = ctx.createImageData(CANVAS_RES, CANVAS_RES); + for (let y = 0; y < CANVAS_RES; y++) { + const L = 1 - y / (CANVAS_RES - 1); + for (let x = 0; x < CANVAS_RES; x++) { + const C = (x / (CANVAS_RES - 1)) * CHROMA_MAX; + const rgb = oklchToRgb(L, C, hue); + const idx = (y * CANVAS_RES + x) * 4; + if (!rgb) { + img.data[idx] = img.data[idx + 1] = img.data[idx + 2] = 128; + img.data[idx + 3] = 255; + continue; + } + img.data[idx] = Math.round(clamp01(rgb.r) * 255); + img.data[idx + 1] = Math.round(clamp01(rgb.g) * 255); + img.data[idx + 2] = Math.round(clamp01(rgb.b) * 255); + img.data[idx + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); }, [hue]); - // Need to use useEffect as color can change from outside, and we need to sync thumb position useEffect(() => { - if (!containerRef.current || !thumbRef.current) return; + if (!thumbRef.current) return; + const x = clamp01(chroma / CHROMA_MAX); + const y = clamp01(1 - lightness); + thumbRef.current.style.left = `${x * 100}%`; + thumbRef.current.style.top = `${y * 100}%`; + if (!isThumbVisible.current) { + isThumbVisible.current = true; + thumbRef.current.style.opacity = '1'; + } + }, [lightness, chroma]); - const clamp = (v: number) => Math.max(0, Math.min(1, v)); - const x = clamp(saturation / 100); - const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x); - const y = clamp(1 - lightness / topLightness); + const handlePointerMove = useCallback( + (event: PointerEvent) => { + if (!(isDragging.current && containerRef.current)) return; + event.preventDefault(); + event.stopPropagation(); + const rect = containerRef.current.getBoundingClientRect(); + const x = clamp01((event.clientX - rect.left) / rect.width); + const y = clamp01((event.clientY - rect.top) / rect.height); + setColor({ c: x * CHROMA_MAX, l: 1 - y }); + }, + [setColor] + ); + + const handlePointerUp = useCallback(() => { + isDragging.current = false; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }, [handlePointerMove]); + + const handlePointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + isDragging.current = true; + handlePointerMove(e.nativeEvent); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + }, + [handlePointerMove, handlePointerUp] + ); + return ( +
+ +
+
+ ); +}; + +// Non-OKLCH modes: classic HSL saturation × scaled-lightness square (pre-OKLCH +// behavior). State is still stored as OKLCH; we derive HSL for display and +// convert back on edit so the rest of the picker keeps a single source of +// truth. +const HslArea = ({ className, ...props }: ColorPickerAreaProps) => { + const containerRef = useRef(null); + const thumbRef = useRef(null); + const isDragging = useRef(false); + const isThumbVisible = useRef(false); + + const { lightness, chroma, hue, setColor } = useColorPicker(); + const hsl = useMemo( + () => oklchToHsl({ l: lightness, c: chroma, h: hue }), + [lightness, chroma, hue] + ); + + const background = useMemo( + () => + `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), + linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)), + hsl(${hsl.h}, 100%, 50%)`, + [hsl.h] + ); + + useEffect(() => { + if (!thumbRef.current) return; + const x = clamp01(hsl.s / 100); + const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x); + const y = clamp01(1 - hsl.l / topLightness); thumbRef.current.style.left = `${x * 100}%`; thumbRef.current.style.top = `${y * 100}%`; - - // This is needed to avoid flickering of the thumb on initial render if (!isThumbVisible.current) { isThumbVisible.current = true; thumbRef.current.style.opacity = '1'; } - }, [saturation, lightness]); + }, [hsl.s, hsl.l]); const handlePointerMove = useCallback( (event: PointerEvent) => { - if (!(isDragging.current && containerRef.current && thumbRef.current)) { - return; - } + if (!(isDragging.current && containerRef.current)) return; event.preventDefault(); event.stopPropagation(); const rect = containerRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (event.clientX - rect.left) / rect.width) - ); - const y = Math.max( - 0, - Math.min(1, (event.clientY - rect.top) / rect.height) - ); + const x = clamp01((event.clientX - rect.left) / rect.width); + const y = clamp01((event.clientY - rect.top) / rect.height); const saturation = x * 100; const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x); - const lightness = topLightness * (1 - y); - setColor({ s: saturation, l: lightness }); + const nextL = topLightness * (1 - y); + const next = hslToOklch(hsl.h, saturation, nextL); + setColor({ l: next.l, c: next.c, h: next.h }); }, - [setColor] + [hsl.h, setColor] ); const handlePointerUp = useCallback(() => { @@ -101,21 +216,17 @@ export const ColorPickerArea = ({ className={cx(styles.selectionRoot, className)} onPointerDown={handlePointerDown} ref={containerRef} - style={{ - background: backgroundGradient - }} + style={{ background }} {...props} >
); }; - -ColorPickerArea.displayName = 'ColorPicker.Area'; diff --git a/packages/raystack/components/color-picker/color-picker-hue.tsx b/packages/raystack/components/color-picker/color-picker-hue.tsx index bf2aa8fbc..441536a07 100644 --- a/packages/raystack/components/color-picker/color-picker-hue.tsx +++ b/packages/raystack/components/color-picker/color-picker-hue.tsx @@ -4,25 +4,49 @@ import { Slider } from '@base-ui/react/slider'; import { cx } from 'class-variance-authority'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; +import { hslToOklch, oklchToHsl } from './utils'; export type ColorPickerHueProps = Slider.Root.Props; export const ColorPickerHue = ({ className, ...props }: ColorPickerHueProps) => { - const { hue, setColor } = useColorPicker(); + const { lightness, chroma, hue, mode, setColor } = useColorPicker(); + const isOklch = mode === 'oklch'; + + // Non-oklch modes drive the slider in HSL hue space so the picker behaves + // like it did pre-OKLCH (red at 0°, green at 120°, etc.). State is still + // stored as OKLCH; we round-trip through HSL on read and write. + const hsl = isOklch ? null : oklchToHsl({ l: lightness, c: chroma, h: hue }); + const value = isOklch ? hue : (hsl?.h ?? 0); + + const handleValueChange = (next: number) => { + if (isOklch) { + setColor({ h: next }); + return; + } + if (!hsl) return; + const oklch = hslToOklch(next, hsl.s, hsl.l); + setColor({ l: oklch.l, c: oklch.c, h: oklch.h }); + }; + return ( setColor({ h: value as number })} + onValueChange={value => handleValueChange(value as number)} step={1} - value={hue} + value={value} thumbAlignment='edge' {...props} > - + diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx index 4541e06f5..e421bbf07 100644 --- a/packages/raystack/components/color-picker/color-picker-input.tsx +++ b/packages/raystack/components/color-picker/color-picker-input.tsx @@ -6,9 +6,9 @@ import { useColorPicker } from './color-picker-root'; import { getColorString } from './utils'; export const ColorPickerInput = (props: ComponentProps) => { - const { hue, saturation, lightness, alpha, mode } = useColorPicker(); + const { lightness, chroma, hue, alpha, mode } = useColorPicker(); const value = getColorString( - { h: hue, s: saturation, l: lightness, alpha: alpha ?? 1 }, + { l: lightness, c: chroma, h: hue, alpha: alpha ?? 1 }, mode ); diff --git a/packages/raystack/components/color-picker/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx index 6cc9f2536..41f5c44bf 100644 --- a/packages/raystack/components/color-picker/color-picker-root.tsx +++ b/packages/raystack/components/color-picker/color-picker-root.tsx @@ -8,12 +8,18 @@ import { useState } from 'react'; import { Flex } from '../flex'; -import { ColorObject, getColorString, ModeType, parseColor } from './utils'; +import { + ColorObject, + clampToSrgb, + getColorString, + ModeType, + parseColor +} from './utils'; type ColorPickerContextValue = { - hue: number; - saturation: number; lightness: number; + chroma: number; + hue: number; alpha: number; mode: ModeType; setColor: (color: Partial) => void; @@ -41,6 +47,7 @@ export interface ColorPickerProps mode?: ModeType; onModeChange?: (mode: ModeType) => void; } + export const ColorPickerRoot = ({ value, defaultValue = '#ffffff', @@ -55,24 +62,31 @@ export const ColorPickerRoot = ({ const [internalColor, setInternalColor] = useState(parseColor(defaultValue)); const [internalMode, setInternalMode] = useState(defaultMode); - const hue = providedColor ? providedColor.h : internalColor.h; - const saturation = providedColor ? providedColor.s : internalColor.s; - const lightness = providedColor ? providedColor.l : internalColor.l; - const alpha = - (providedColor ? providedColor?.alpha : internalColor?.alpha) ?? 1; - const mode = providedMode ?? internalMode; - const setColor = useCallback( - value => { - setInternalColor(_color => { - const updatedColor = { ..._color, ...value }; - - if (!onValueChange) return updatedColor; + // In non-oklch modes, derive a display value clamped to the sRGB gamut so + // the pad / thumb / input only reflect colors the active output mode can + // actually represent. Internal state stays raw so switching back to oklch + // restores any wide-gamut pick the user hadn't yet overwritten. + const rawColor: ColorObject = { + l: providedColor ? providedColor.l : internalColor.l, + c: providedColor ? providedColor.c : internalColor.c, + h: providedColor ? providedColor.h : internalColor.h, + alpha: (providedColor ? providedColor.alpha : internalColor.alpha) ?? 1 + }; + const display = mode === 'oklch' ? rawColor : clampToSrgb(rawColor); - onValueChange(getColorString(updatedColor, mode), mode); + const lightness = display.l; + const chroma = display.c; + const hue = display.h; + const alpha = display.alpha ?? 1; - return updatedColor; + const setColor = useCallback( + value => { + setInternalColor(prev => { + const next = { ...prev, ...value }; + onValueChange?.(getColorString(next, mode), mode); + return next; }); }, [mode, onValueChange] @@ -89,9 +103,9 @@ export const ColorPickerRoot = ({ return ( Number.parseFloat(n.toFixed(p)); -/** - * Serializes a culori-shaped HSL color as oklch(L C H[ / A]). - * Matches the design system's token format: 4-decimal L/C, 2-decimal H, - * H pinned to 0 for achromatic colors (culori would emit `none` per CSS - * Color 4, which is correct but inconsistent with how tokens are written). - */ -const formatOklch = (hsl: { - mode: 'hsl'; - h: number; - s: number; - l: number; - alpha: number; -}): string => { - const oklch = toOklch(hsl); - if (!oklch) return ''; - const L = round(oklch.l ?? 0, 4); - const C = round(oklch.c ?? 0, 4); - const H = C === 0 || !Number.isFinite(oklch.h) ? 0 : round(oklch.h ?? 0, 2); - const body = `${L} ${C} ${H}`; - return hsl.alpha === 1 - ? `oklch(${body})` - : `oklch(${body} / ${round(hsl.alpha, 4)})`; -}; +export const clamp01 = (n: number) => Math.max(0, Math.min(1, n)); /** - * Parses any CSS color string into the picker's `{h, s, l, alpha}` shape. - * Returns a white fallback (matching the picker's previous default) when the - * input fails to parse, so the picker never throws on bad consumer input. + * Parses any CSS color string into the picker's OKLCH `{l, c, h, alpha}` shape. + * Returns white when the input fails to parse so the picker never throws on + * bad consumer input. Hue is pinned to 0 for achromatic colors because culori + * may report it as NaN per CSS Color 4. */ export const parseColor = (value: string): ColorObject => { const parsed = parse(value); if (!parsed) return FALLBACK; - const hsl = toHsl(parsed); - if (!hsl) return FALLBACK; + const oklch = toOklch(parsed); + if (!oklch) return FALLBACK; + const c = oklch.c ?? 0; return { - h: hsl.h ?? 0, - s: (hsl.s ?? 0) * HSL_PERCENT, - l: (hsl.l ?? 0) * HSL_PERCENT, - alpha: hsl.alpha ?? 1 + l: oklch.l ?? 0, + c, + h: c === 0 || !Number.isFinite(oklch.h) ? 0 : (oklch.h as number), + alpha: oklch.alpha ?? 1 }; }; +const formatOklchString = (color: ColorObject): string => { + const L = round(color.l, 4); + const C = round(color.c, 4); + const H = C === 0 ? 0 : round(color.h, 2); + const alpha = color.alpha ?? 1; + const body = `${L} ${C} ${H}`; + return alpha === 1 ? `oklch(${body})` : `oklch(${body} / ${round(alpha, 4)})`; +}; + /** - * Serializes `{h, s, l, alpha}` to a CSS string in the requested mode. - * Hex output is uppercase and uses 8-digit form only when alpha < 1, mirroring - * the previous `color@5` behavior the tests depend on. + * Serializes the OKLCH color to a CSS string in the requested mode. Non-oklch + * modes clip out-of-gamut channels to sRGB so the output is always a valid + * representable value in that format. */ export const getColorString = (color: ColorObject, mode: ModeType): string => { - const culoriColor = { - mode: 'hsl' as const, + if (mode === 'oklch') return formatOklchString(color); + + const rgb = toRgb({ + mode: 'oklch', + l: color.l, + c: color.c, h: color.h, - s: color.s / HSL_PERCENT, - l: color.l / HSL_PERCENT, alpha: color.alpha ?? 1 + }); + if (!rgb) return ''; + const clipped = { + mode: 'rgb' as const, + r: clamp01(rgb.r), + g: clamp01(rgb.g), + b: clamp01(rgb.b), + alpha: rgb.alpha ?? 1 }; if (mode === 'hex') { - const hex = - culoriColor.alpha === 1 - ? formatHex(culoriColor) - : formatHex8(culoriColor); + const hex = clipped.alpha === 1 ? formatHex(clipped) : formatHex8(clipped); return hex.toUpperCase(); } - if (mode === 'hsl') return formatHsl(culoriColor); - if (mode === 'oklch') return formatOklch(culoriColor); - return formatRgb(culoriColor); + if (mode === 'hsl') return formatHsl(clipped); + return formatRgb(clipped); +}; + +/** + * Converts an OKLCH triple to a culori RGB object. The returned r/g/b channels + * may fall outside [0, 1] when the input is outside the sRGB gamut — callers + * use that signal to detect and mark the gamut boundary. + */ +export const oklchToRgb = (l: number, c: number, h: number) => + toRgb({ mode: 'oklch', l, c, h }); + +export type HslView = { + h: number; + s: number; + l: number; +}; + +/** + * Derives an HSL view (h: 0-360, s/l: 0-100) from the picker's OKLCH state. + * Falls back to the input hue when the color is achromatic so the user's last + * hue choice isn't lost at the s=0 axis. Used by the area + hue slider in + * non-oklch modes to drive the classic gradient square. + */ +export const oklchToHsl = (color: ColorObject): HslView => { + const hsl = toHsl({ + mode: 'oklch', + l: color.l, + c: color.c, + h: color.h, + alpha: color.alpha ?? 1 + }); + if (!hsl) return { h: color.h, s: 0, l: 100 }; + // At c=0 the color is achromatic and the HSL hue carries no information; + // culori may still return a finite hue from floating-point drift, so trust + // the input hue to keep the user's last choice intact at the s=0 axis. + const isAchromatic = color.c <= 1e-6; + return { + h: isAchromatic || !Number.isFinite(hsl.h) ? color.h : (hsl.h as number), + s: clamp01(hsl.s ?? 0) * 100, + l: clamp01(hsl.l ?? 0) * 100 + }; +}; + +/** + * Converts an HSL triple (h: 0-360, s/l: 0-100) back to the picker's OKLCH + * shape. Preserves the input hue when culori reports NaN (e.g. on grays) so + * the user's hue choice survives a round-trip through s=0. + */ +export const hslToOklch = ( + h: number, + s: number, + l: number, + alpha = 1 +): ColorObject => { + const oklch = toOklch({ + mode: 'hsl', + h, + s: clamp01(s / 100), + l: clamp01(l / 100), + alpha + }); + if (!oklch) return { l: 0, c: 0, h, alpha }; + return { + l: oklch.l ?? 0, + c: oklch.c ?? 0, + h: Number.isFinite(oklch.h) ? (oklch.h as number) : h, + alpha + }; +}; + +/** + * Reduces chroma until the OKLCH color is displayable in sRGB, preserving L + * and H. Used in non-oklch modes so the picker can only emit colors that the + * output format can actually represent. + */ +export const clampToSrgb = (color: ColorObject): ColorObject => { + const result = clampChroma( + { + mode: 'oklch', + l: color.l, + c: color.c, + h: color.h, + alpha: color.alpha ?? 1 + }, + 'oklch', + 'rgb' + ); + return { + l: result.l ?? color.l, + c: result.c ?? 0, + h: result.h ?? color.h, + alpha: result.alpha ?? color.alpha ?? 1 + }; }; From 6322ee5b0313d3ad9931edd890903a8877fa590c Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Mon, 25 May 2026 09:28:56 +0530 Subject: [PATCH 4/7] feat(color-picker): improve color picker functionality and performance - Updated color input to display the current color value as a read-only string, reflecting real-time changes. - Enhanced the alpha slider to show a gradient based on the current color, improving user experience. - Optimized rendering of color values using `useMemo` for better performance across components. - Adjusted hue slider to provide sub-degree precision in OKLCH mode, enhancing color selection accuracy. - Refactored color calculation logic to utilize native CSS for better compatibility with wide-gamut displays. --- .../docs/components/color-picker/index.mdx | 2 +- .../color-picker/color-picker-alpha.tsx | 15 ++++- .../color-picker/color-picker-area.tsx | 57 +++++++++++-------- .../color-picker/color-picker-hue.tsx | 15 ++++- .../color-picker/color-picker-input.tsx | 12 ++-- .../color-picker/color-picker-root.tsx | 48 +++++++++++----- .../color-picker/color-picker.module.css | 6 +- 7 files changed, 107 insertions(+), 48 deletions(-) diff --git a/apps/www/src/content/docs/components/color-picker/index.mdx b/apps/www/src/content/docs/components/color-picker/index.mdx index 04dfdb044..396225c1a 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -57,7 +57,7 @@ Lets users switch between different color models (HEX, RGB, HSL, OKLCH) via a dr ### Input -Displays the current color value in the selected color model and allows direct text input. +Displays the current color value in the selected color model as a read-only string. The value is updated automatically as the user interacts with the area, hue, or alpha controls. ## Examples diff --git a/packages/raystack/components/color-picker/color-picker-alpha.tsx b/packages/raystack/components/color-picker/color-picker-alpha.tsx index 4c1a9ced6..368a684f5 100644 --- a/packages/raystack/components/color-picker/color-picker-alpha.tsx +++ b/packages/raystack/components/color-picker/color-picker-alpha.tsx @@ -2,6 +2,7 @@ import { Slider } from '@base-ui/react/slider'; import { cx } from 'class-variance-authority'; +import { CSSProperties, useMemo } from 'react'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; @@ -11,7 +12,17 @@ export const ColorPickerAlpha = ({ className, ...props }: ColorPickerAlphaProps) => { - const { alpha = 1, setColor } = useColorPicker(); + const { alpha = 1, lightness, chroma, hue, setColor } = useColorPicker(); + // Drive the track gradient from the current color so the user can preview + // what the picked tone looks like at any alpha. The CSS reads + // --rs-color-picker-alpha-end from this style. + const trackStyle = useMemo( + () => + ({ + ['--rs-color-picker-alpha-end' as string]: `oklch(${lightness} ${chroma} ${hue})` + }) as CSSProperties, + [lightness, chroma, hue] + ); return ( -
+
diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx index fc66d4014..855c03247 100644 --- a/packages/raystack/components/color-picker/color-picker-area.tsx +++ b/packages/raystack/components/color-picker/color-picker-area.tsx @@ -14,7 +14,6 @@ import { useColorPicker } from './color-picker-root'; import { CHROMA_MAX, clamp01, - getColorString, hslToOklch, oklchToHsl, oklchToRgb @@ -44,36 +43,48 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => { const isThumbVisible = useRef(false); const { lightness, chroma, hue, setColor } = useColorPicker(); - const thumbColor = getColorString( - { l: lightness, c: chroma, h: hue, alpha: 1 }, - 'hex' + // Use the native CSS oklch() so the thumb renders the actual picked color on + // wide-gamut (P3) displays — hex would silently sRGB-clip wide-gamut picks. + const thumbColor = useMemo( + () => `oklch(${lightness} ${chroma} ${hue})`, + [lightness, chroma, hue] ); + // Coalesce hue-driven repaints into one per animation frame. A fast slider + // sweep would otherwise queue dozens of synchronous 96² repaints back-to-back. useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const img = ctx.createImageData(CANVAS_RES, CANVAS_RES); - for (let y = 0; y < CANVAS_RES; y++) { - const L = 1 - y / (CANVAS_RES - 1); - for (let x = 0; x < CANVAS_RES; x++) { - const C = (x / (CANVAS_RES - 1)) * CHROMA_MAX; - const rgb = oklchToRgb(L, C, hue); - const idx = (y * CANVAS_RES + x) * 4; - if (!rgb) { - img.data[idx] = img.data[idx + 1] = img.data[idx + 2] = 128; + let cancelled = false; + const handle = requestAnimationFrame(() => { + if (cancelled) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = ctx.createImageData(CANVAS_RES, CANVAS_RES); + for (let y = 0; y < CANVAS_RES; y++) { + const L = 1 - y / (CANVAS_RES - 1); + for (let x = 0; x < CANVAS_RES; x++) { + const C = (x / (CANVAS_RES - 1)) * CHROMA_MAX; + const rgb = oklchToRgb(L, C, hue); + const idx = (y * CANVAS_RES + x) * 4; + if (!rgb) { + img.data[idx] = img.data[idx + 1] = img.data[idx + 2] = 128; + img.data[idx + 3] = 255; + continue; + } + img.data[idx] = Math.round(clamp01(rgb.r) * 255); + img.data[idx + 1] = Math.round(clamp01(rgb.g) * 255); + img.data[idx + 2] = Math.round(clamp01(rgb.b) * 255); img.data[idx + 3] = 255; - continue; } - img.data[idx] = Math.round(clamp01(rgb.r) * 255); - img.data[idx + 1] = Math.round(clamp01(rgb.g) * 255); - img.data[idx + 2] = Math.round(clamp01(rgb.b) * 255); - img.data[idx + 3] = 255; } - } - ctx.putImageData(img, 0, 0); + ctx.putImageData(img, 0, 0); + }); + return () => { + cancelled = true; + cancelAnimationFrame(handle); + }; }, [hue]); useEffect(() => { diff --git a/packages/raystack/components/color-picker/color-picker-hue.tsx b/packages/raystack/components/color-picker/color-picker-hue.tsx index 441536a07..154497474 100644 --- a/packages/raystack/components/color-picker/color-picker-hue.tsx +++ b/packages/raystack/components/color-picker/color-picker-hue.tsx @@ -2,6 +2,7 @@ import { Slider } from '@base-ui/react/slider'; import { cx } from 'class-variance-authority'; +import { useMemo } from 'react'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; import { hslToOklch, oklchToHsl } from './utils'; @@ -17,7 +18,10 @@ export const ColorPickerHue = ({ // Non-oklch modes drive the slider in HSL hue space so the picker behaves // like it did pre-OKLCH (red at 0°, green at 120°, etc.). State is still // stored as OKLCH; we round-trip through HSL on read and write. - const hsl = isOklch ? null : oklchToHsl({ l: lightness, c: chroma, h: hue }); + const hsl = useMemo( + () => (isOklch ? null : oklchToHsl({ l: lightness, c: chroma, h: hue })), + [isOklch, lightness, chroma, hue] + ); const value = isOklch ? hue : (hsl?.h ?? 0); const handleValueChange = (next: number) => { @@ -35,7 +39,9 @@ export const ColorPickerHue = ({ className={cx(styles.sliderRoot, className)} max={360} onValueChange={value => handleValueChange(value as number)} - step={1} + // OKLCH hue is perceptually uniform — sub-degree precision is meaningful + // when fine-tuning a tone. HSL hue keeps the classic 1° granularity. + step={isOklch ? 0.1 : 1} value={value} thumbAlignment='edge' {...props} @@ -48,7 +54,10 @@ export const ColorPickerHue = ({ )} > - + diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx index e421bbf07..1c2ad5c9a 100644 --- a/packages/raystack/components/color-picker/color-picker-input.tsx +++ b/packages/raystack/components/color-picker/color-picker-input.tsx @@ -1,15 +1,19 @@ 'use client'; -import { ComponentProps } from 'react'; +import { ComponentProps, useMemo } from 'react'; import { Input } from '../input'; import { useColorPicker } from './color-picker-root'; import { getColorString } from './utils'; export const ColorPickerInput = (props: ComponentProps) => { const { lightness, chroma, hue, alpha, mode } = useColorPicker(); - const value = getColorString( - { l: lightness, c: chroma, h: hue, alpha: alpha ?? 1 }, - mode + const value = useMemo( + () => + getColorString( + { l: lightness, c: chroma, h: hue, alpha: alpha ?? 1 }, + mode + ), + [lightness, chroma, hue, alpha, mode] ); return ; diff --git a/packages/raystack/components/color-picker/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx index 41f5c44bf..185539363 100644 --- a/packages/raystack/components/color-picker/color-picker-root.tsx +++ b/packages/raystack/components/color-picker/color-picker-root.tsx @@ -5,6 +5,8 @@ import { createContext, useCallback, useContext, + useMemo, + useRef, useState } from 'react'; import { Flex } from '../flex'; @@ -57,9 +59,14 @@ export const ColorPickerRoot = ({ onModeChange, ...props }: ColorPickerProps) => { - const providedColor = value ? parseColor(value) : undefined; + const providedColor = useMemo( + () => (value ? parseColor(value) : undefined), + [value] + ); - const [internalColor, setInternalColor] = useState(parseColor(defaultValue)); + const [internalColor, setInternalColor] = useState(() => + parseColor(defaultValue) + ); const [internalMode, setInternalMode] = useState(defaultMode); const mode = providedMode ?? internalMode; @@ -68,26 +75,39 @@ export const ColorPickerRoot = ({ // the pad / thumb / input only reflect colors the active output mode can // actually represent. Internal state stays raw so switching back to oklch // restores any wide-gamut pick the user hadn't yet overwritten. - const rawColor: ColorObject = { - l: providedColor ? providedColor.l : internalColor.l, - c: providedColor ? providedColor.c : internalColor.c, - h: providedColor ? providedColor.h : internalColor.h, - alpha: (providedColor ? providedColor.alpha : internalColor.alpha) ?? 1 - }; - const display = mode === 'oklch' ? rawColor : clampToSrgb(rawColor); + const rawColor = useMemo( + () => ({ + l: providedColor ? providedColor.l : internalColor.l, + c: providedColor ? providedColor.c : internalColor.c, + h: providedColor ? providedColor.h : internalColor.h, + alpha: (providedColor ? providedColor.alpha : internalColor.alpha) ?? 1 + }), + [providedColor, internalColor] + ); + // clampToSrgb wraps culori's clampChroma, which is iterative — memoize so + // it doesn't re-run on unrelated re-renders during a drag. + const display = useMemo( + () => (mode === 'oklch' ? rawColor : clampToSrgb(rawColor)), + [mode, rawColor] + ); const lightness = display.l; const chroma = display.c; const hue = display.h; const alpha = display.alpha ?? 1; + // Mirror the current effective color in a ref so setColor can compute the + // next value synchronously, without putting onValueChange inside the + // setInternalColor updater (where StrictMode would fire it twice). + const rawColorRef = useRef(rawColor); + rawColorRef.current = rawColor; + const setColor = useCallback( value => { - setInternalColor(prev => { - const next = { ...prev, ...value }; - onValueChange?.(getColorString(next, mode), mode); - return next; - }); + const next = { ...rawColorRef.current, ...value }; + rawColorRef.current = next; + setInternalColor(next); + onValueChange?.(getColorString(next, mode), mode); }, [mode, onValueChange] ); diff --git a/packages/raystack/components/color-picker/color-picker.module.css b/packages/raystack/components/color-picker/color-picker.module.css index e0e652519..24606885d 100644 --- a/packages/raystack/components/color-picker/color-picker.module.css +++ b/packages/raystack/components/color-picker/color-picker.module.css @@ -95,7 +95,11 @@ position: absolute; inset: 0; border-radius: var(--rs-radius-full); - background: linear-gradient(to right, transparent, rgba(0, 0, 0, 1)); + background: linear-gradient( + to right, + transparent, + var(--rs-color-picker-alpha-end, rgba(0, 0, 0, 1)) + ); } .selectTrigger { From ed8bbca723170588a2aff5e70caca15cd00450bf Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Mon, 25 May 2026 11:16:49 +0530 Subject: [PATCH 5/7] feat(color-picker): add copyable functionality to ColorPicker.Input - Updated ColorPicker.Input to include a `copyable` prop, rendering a copy-to-clipboard button for the current color value. - Enhanced documentation to explain the new `copyable` feature and its usage in examples. - Added tests to verify the presence and functionality of the copy button in various scenarios. --- .../src/app/examples/color-picker/page.tsx | 8 +-- .../docs/components/color-picker/demo.ts | 23 ++++++- .../docs/components/color-picker/index.mdx | 11 +++- .../__tests__/color-picker.test.tsx | 60 ++++++++++++++++++- .../color-picker/color-picker-input.tsx | 32 +++++++++- 5 files changed, 121 insertions(+), 13 deletions(-) diff --git a/apps/www/src/app/examples/color-picker/page.tsx b/apps/www/src/app/examples/color-picker/page.tsx index f9a12c192..73a46e751 100644 --- a/apps/www/src/app/examples/color-picker/page.tsx +++ b/apps/www/src/app/examples/color-picker/page.tsx @@ -53,7 +53,7 @@ export default function ColorPickerExamplesPage() { - + @@ -75,7 +75,7 @@ export default function ColorPickerExamplesPage() { - + @@ -100,7 +100,7 @@ export default function ColorPickerExamplesPage() { - + @@ -149,7 +149,7 @@ export default function ColorPickerExamplesPage() { - + diff --git a/apps/www/src/content/docs/components/color-picker/demo.ts b/apps/www/src/content/docs/components/color-picker/demo.ts index 894c1e713..085855bde 100644 --- a/apps/www/src/content/docs/components/color-picker/demo.ts +++ b/apps/www/src/content/docs/components/color-picker/demo.ts @@ -16,7 +16,7 @@ style={{ - + ` }; @@ -36,6 +36,23 @@ export const basicDemo = { ` }; +export const copyableDemo = { + type: 'code', + code: ` + + + + + + +` +}; + export const oklchDemo = { type: 'code', code: ` - + ` }; @@ -90,7 +107,7 @@ export const popoverDemo = { - + diff --git a/apps/www/src/content/docs/components/color-picker/index.mdx b/apps/www/src/content/docs/components/color-picker/index.mdx index 396225c1a..793b63dd3 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -7,6 +7,7 @@ source: packages/raystack/components/color-picker import { preview, basicDemo, + copyableDemo, oklchDemo, popoverDemo, } from "./demo.ts"; @@ -25,7 +26,7 @@ import { ColorPicker } from '@raystack/apsara' - + ``` @@ -57,7 +58,7 @@ Lets users switch between different color models (HEX, RGB, HSL, OKLCH) via a dr ### Input -Displays the current color value in the selected color model as a read-only string. The value is updated automatically as the user interacts with the area, hue, or alpha controls. +Displays the current color value in the selected color model as a read-only string. The value is updated automatically as the user interacts with the area, hue, or alpha controls. Pass `copyable` to render a copy-to-clipboard button in the input's trailing slot. ## Examples @@ -65,6 +66,12 @@ Displays the current color value in the selected color model as a read-only stri +### Copy to Clipboard + +Pass the `copyable` prop on `ColorPicker.Input` to render a copy-to-clipboard button in the input's trailing slot. The button copies the formatted color string in the active mode (e.g. `#FF0000`, `rgb(...)`, or `oklch(...)`) and shows a brief confirmation icon after a successful copy. + + + ### OKLCH Mode The picker stores color internally in OKLCH. In `hex`, `rgb`, and `hsl` modes the area pad is the familiar HSL saturation × brightness square and the hue slider runs in HSL hue — so the picker behaves like a traditional color picker and only emits colors representable in the chosen format. Pass an `oklch(...)` string as `defaultValue` and set `defaultMode='oklch'` to switch to a chroma × lightness plane covering the full P3 gamut; the hue slider then runs in OKLCH hue, where equal steps correspond to equal perceptual changes. diff --git a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx index 2b477326b..033818d89 100644 --- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx +++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx @@ -1,7 +1,12 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ColorPicker } from '../color-picker'; +const mockCopy = vi.fn(); +vi.mock('~/hooks/useCopyToClipboard', () => ({ + useCopyToClipboard: () => ({ copy: mockCopy }) +})); + // // Mock ResizeObserver for tests // const originalResizeObserver = global.ResizeObserver; // beforeAll(() => { @@ -307,6 +312,57 @@ describe('ColorPicker', () => { }); }); + describe('ColorPicker.Input copyable', () => { + beforeEach(() => { + mockCopy.mockClear(); + mockCopy.mockResolvedValue(true); + }); + + it('does not render a copy button by default', () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-id="copy-button"]') + ).not.toBeInTheDocument(); + }); + + it('renders a copy button when copyable is true', () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-id="copy-button"]') + ).toBeInTheDocument(); + }); + + it('copies the formatted color string in hex mode', () => { + const { container } = render( + + + + ); + const btn = container.querySelector('[data-test-id="copy-button"]'); + fireEvent.click(btn!); + expect(mockCopy).toHaveBeenCalledWith('#FF0000'); + }); + + it('copies the oklch string when mode is oklch', () => { + const { container } = render( + + + + ); + const btn = container.querySelector('[data-test-id="copy-button"]'); + fireEvent.click(btn!); + expect(mockCopy).toHaveBeenCalledWith(expect.stringMatching(/^oklch\(/)); + }); + }); + describe('Component Composition', () => { it('supports complete component composition', () => { render( diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx index 1c2ad5c9a..908699e48 100644 --- a/packages/raystack/components/color-picker/color-picker-input.tsx +++ b/packages/raystack/components/color-picker/color-picker-input.tsx @@ -1,11 +1,25 @@ 'use client'; import { ComponentProps, useMemo } from 'react'; +import { CopyButton } from '../copy-button'; import { Input } from '../input'; import { useColorPicker } from './color-picker-root'; import { getColorString } from './utils'; -export const ColorPickerInput = (props: ComponentProps) => { +export interface ColorPickerInputProps extends ComponentProps { + /** + * Render a copy-to-clipboard button inside the input's trailing slot. + * The button copies the current formatted color string in the active mode. + * @default false + */ + copyable?: boolean; +} + +export const ColorPickerInput = ({ + copyable = false, + trailingIcon, + ...props +}: ColorPickerInputProps) => { const { lightness, chroma, hue, alpha, mode } = useColorPicker(); const value = useMemo( () => @@ -16,7 +30,21 @@ export const ColorPickerInput = (props: ComponentProps) => { [lightness, chroma, hue, alpha, mode] ); - return ; + // A consumer-supplied trailingIcon always wins; copyable only fills the slot + // when no trailingIcon was provided. size=2 matches the Input's trailing-icon + // wrapper width (--rs-space-5). + const resolvedTrailingIcon = + trailingIcon ?? + (copyable ? : undefined); + + return ( + + ); }; ColorPickerInput.displayName = 'ColorPicker.Input'; From 735a556d16096f6846f4ef5f830ae30cb5c5c2ec Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Mon, 25 May 2026 11:23:04 +0530 Subject: [PATCH 6/7] fix(color-picker): handle pointercancel event in color picker areas - Added event listener for `pointercancel` to ensure proper cleanup and prevent stranded listeners during drag operations. - Updated cleanup logic in both OklchArea and HslArea components to remove `pointercancel` listeners, maintaining consistent state management during user interactions. --- .../color-picker/color-picker-area.tsx | 10 +++++++++ .../color-picker/color-picker-root.tsx | 21 +++++++++---------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx index 855c03247..e8eb1fb32 100644 --- a/packages/raystack/components/color-picker/color-picker-area.tsx +++ b/packages/raystack/components/color-picker/color-picker-area.tsx @@ -116,6 +116,7 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => { isDragging.current = false; window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); }, [handlePointerMove]); const handlePointerDown = useCallback( @@ -125,6 +126,10 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => { handlePointerMove(e.nativeEvent); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); + // pointercancel fires instead of pointerup when the OS/browser preempts + // the gesture (system dialog, palm rejection, etc.). Handling it with the + // same cleanup prevents stranded listeners + isDragging stuck at true. + window.addEventListener('pointercancel', handlePointerUp); }, [handlePointerMove, handlePointerUp] ); @@ -209,6 +214,7 @@ const HslArea = ({ className, ...props }: ColorPickerAreaProps) => { isDragging.current = false; window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); }, [handlePointerMove]); const handlePointerDown = useCallback( @@ -218,6 +224,10 @@ const HslArea = ({ className, ...props }: ColorPickerAreaProps) => { handlePointerMove(e.nativeEvent); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); + // pointercancel fires instead of pointerup when the OS/browser preempts + // the gesture (system dialog, palm rejection, etc.). Handling it with the + // same cleanup prevents stranded listeners + isDragging stuck at true. + window.addEventListener('pointercancel', handlePointerUp); }, [handlePointerMove, handlePointerUp] ); diff --git a/packages/raystack/components/color-picker/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx index 185539363..5402add8b 100644 --- a/packages/raystack/components/color-picker/color-picker-root.tsx +++ b/packages/raystack/components/color-picker/color-picker-root.tsx @@ -120,18 +120,17 @@ export const ColorPickerRoot = ({ [onModeChange] ); + // Memoize the context value so consumers (Area, Hue, Alpha, Input, Mode) only + // re-render when a slice they read actually changes — without this, every + // pointermove during a drag would broadcast a fresh object identity to all + // subcomponents. + const contextValue = useMemo( + () => ({ lightness, chroma, hue, alpha, mode, setColor, setMode }), + [lightness, chroma, hue, alpha, mode, setColor, setMode] + ); + return ( - + ); From 51bbf6b72a2a9f31479b5d67d711b8b86ce8d8ab Mon Sep 17 00:00:00 2001 From: paanSinghCoder Date: Mon, 25 May 2026 11:58:27 +0530 Subject: [PATCH 7/7] refactor(color-picker): correct HslView type definition --- packages/raystack/components/color-picker/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index 2c429b74c..18f9813d7 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -102,7 +102,7 @@ export const getColorString = (color: ColorObject, mode: ModeType): string => { export const oklchToRgb = (l: number, c: number, h: number) => toRgb({ mode: 'oklch', l, c, h }); -export type HslView = { +type HslView = { h: number; s: number; l: number;