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..73a46e751 --- /dev/null +++ b/apps/www/src/app/examples/color-picker/page.tsx @@ -0,0 +1,170 @@ +'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 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). + + + + + + + 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..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,45 @@ export const basicDemo = { ` }; +export const copyableDemo = { + type: 'code', + code: ` + + + + + + +` +}; + +export const oklchDemo = { + type: 'code', + code: ` + + + + + + + +` +}; + export const popoverDemo = { type: 'code', previewCode: false, @@ -68,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 9722535ce..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,8 @@ source: packages/raystack/components/color-picker import { preview, basicDemo, + copyableDemo, + oklchDemo, popoverDemo, } from "./demo.ts"; @@ -24,7 +26,7 @@ import { ColorPicker } from '@raystack/apsara' - + ``` @@ -38,7 +40,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 @@ -50,13 +52,13 @@ 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. ### 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. Pass `copyable` to render a copy-to-clipboard button in the input's trailing slot. ## Examples @@ -64,6 +66,18 @@ Displays the current color value in the selected color model and allows direct t +### 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. + + + ### 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..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(() => { @@ -66,17 +71,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(); }); }); @@ -202,6 +220,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', () => { @@ -258,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/__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-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 450db0599..e8eb1fb32 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,74 +11,210 @@ import { } from 'react'; import styles from './color-picker.module.css'; import { useColorPicker } from './color-picker-root'; +import { + CHROMA_MAX, + clamp01, + 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 color = Color.hsl(hue, saturation, lightness); + const { lightness, chroma, hue, setColor } = useColorPicker(); + // 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] + ); - 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%)`; + // 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; + 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; + } + } + ctx.putImageData(img, 0, 0); + }); + return () => { + cancelled = true; + cancelAnimationFrame(handle); + }; }, [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); + window.removeEventListener('pointercancel', handlePointerUp); + }, [handlePointerMove]); + + const handlePointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + isDragging.current = true; + 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] + ); + 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(() => { isDragging.current = false; window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); }, [handlePointerMove]); const handlePointerDown = useCallback( @@ -89,6 +224,10 @@ export const ColorPickerArea = ({ 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] ); @@ -98,21 +237,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..154497474 100644 --- a/packages/raystack/components/color-picker/color-picker-hue.tsx +++ b/packages/raystack/components/color-picker/color-picker-hue.tsx @@ -2,29 +2,62 @@ 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'; 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 = 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) => { + 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 })} - step={1} - value={hue} + onValueChange={value => handleValueChange(value as number)} + // 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} > - + - + diff --git a/packages/raystack/components/color-picker/color-picker-input.tsx b/packages/raystack/components/color-picker/color-picker-input.tsx index 79ba97e21..908699e48 100644 --- a/packages/raystack/components/color-picker/color-picker-input.tsx +++ b/packages/raystack/components/color-picker/color-picker-input.tsx @@ -1,16 +1,50 @@ 'use client'; -import Color from 'color'; -import { ComponentProps } from 'react'; +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) => { - const { hue, saturation, lightness, alpha, mode } = useColorPicker(); - const color = Color.hsl(hue, saturation, lightness, alpha ?? 1); +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; +} - return ; +export const ColorPickerInput = ({ + copyable = false, + trailingIcon, + ...props +}: ColorPickerInputProps) => { + const { lightness, chroma, hue, alpha, mode } = useColorPicker(); + const value = useMemo( + () => + getColorString( + { l: lightness, c: chroma, h: hue, alpha: alpha ?? 1 }, + mode + ), + [lightness, chroma, hue, alpha, mode] + ); + + // 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'; 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/color-picker-root.tsx b/packages/raystack/components/color-picker/color-picker-root.tsx index 97c59b4d2..5402add8b 100644 --- a/packages/raystack/components/color-picker/color-picker-root.tsx +++ b/packages/raystack/components/color-picker/color-picker-root.tsx @@ -1,20 +1,27 @@ 'use client'; -import Color, { type ColorLike } from 'color'; import { ComponentProps, createContext, useCallback, useContext, + useMemo, + useRef, useState } from 'react'; import { Flex } from '../flex'; -import { ColorObject, getColorString, ModeType } 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; @@ -35,13 +42,14 @@ 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; onModeChange?: (mode: ModeType) => void; } + export const ColorPickerRoot = ({ value, defaultValue = '#ffffff', @@ -51,39 +59,55 @@ export const ColorPickerRoot = ({ onModeChange, ...props }: ColorPickerProps) => { - const providedColor = value && (Color(value).hsl().object() as ColorObject); + const providedColor = useMemo( + () => (value ? parseColor(value) : undefined), + [value] + ); - 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; - 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 = 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 color = Color.hsl( - updatedColor.h, - updatedColor.s, - updatedColor.l, - updatedColor?.alpha ?? 1 - ); + const lightness = display.l; + const chroma = display.c; + const hue = display.h; + const alpha = display.alpha ?? 1; - onValueChange(getColorString(color, mode), mode); + // 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; - return updatedColor; - }); + const setColor = useCallback( + value => { + const next = { ...rawColorRef.current, ...value }; + rawColorRef.current = next; + setInternalColor(next); + onValueChange?.(getColorString(next, mode), mode); }, [mode, onValueChange] ); @@ -96,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 ( - + ); diff --git a/packages/raystack/components/color-picker/color-picker.module.css b/packages/raystack/components/color-picker/color-picker.module.css index 659100637..24606885d 100644 --- a/packages/raystack/components/color-picker/color-picker.module.css +++ b/packages/raystack/components/color-picker/color-picker.module.css @@ -29,14 +29,29 @@ } .hueTrack { - background: linear-gradient(90deg, - #ff0000, - #ffff00, - #00ff00, - #00ffff, - #0000ff, - #ff00ff, - #ff0000); + background: linear-gradient( + to right in oklch, + oklch(0.7 0.18 0), + oklch(0.7 0.18 60), + oklch(0.7 0.18 120), + oklch(0.7 0.18 180), + oklch(0.7 0.18 240), + oklch(0.7 0.18 300), + oklch(0.7 0.18 360) + ); +} + +.hueTrackHsl { + background: linear-gradient( + 90deg, + #ff0000, + #ffff00, + #00ff00, + #00ffff, + #0000ff, + #ff00ff, + #ff0000 + ); } .sliderThumb { @@ -46,7 +61,8 @@ width: var(--rs-space-4); border-radius: var(--rs-radius-full); border: 3px solid var(--rs-color-foreground-base-emphasis); - filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.02)) drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.06)); + filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.02)) + drop-shadow(0px 1px 1px rgba(0, 0, 0, 0.06)); } .sliderThumb:focus-visible { @@ -65,11 +81,13 @@ linear-gradient(-45deg, #ddd 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ddd 75%), linear-gradient(-45deg, transparent 75%, #ddd 75%); - background-size: var(--track-checker-size, 8px) var(--track-checker-size, 12px); + background-size: var(--track-checker-size, 8px) + var(--track-checker-size, 12px); background-position: 0 0, 0 calc(var(--track-checker-size, 12px) / 2), - calc(var(--track-checker-size, 12px) / 2) calc(var(--track-checker-size, 12px) / -2), + calc(var(--track-checker-size, 12px) / 2) + calc(var(--track-checker-size, 12px) / -2), calc(var(--track-checker-size, 12px) / -2) 0px; } @@ -77,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 { @@ -88,7 +110,19 @@ position: relative; width: 100%; height: 100%; + /* Fallback dimensions when the parent doesn't constrain height — the OKLCH + canvas used to anchor size via its intrinsic 96×96, but the HSL gradient + branch has no such element. aspect-ratio gives both branches a square + footprint derived from width. */ + aspect-ratio: 1 / 1; border-radius: var(--rs-radius-1); + overflow: hidden; +} + +.selectionCanvas { + display: block; + width: 100%; + height: 100%; } .selectionThumb { diff --git a/packages/raystack/components/color-picker/utils.ts b/packages/raystack/components/color-picker/utils.ts index fe7a6d696..dfaaa7595 100644 --- a/packages/raystack/components/color-picker/utils.ts +++ b/packages/raystack/components/color-picker/utils.ts @@ -1,23 +1,184 @@ -import { type ColorInstance } from 'color'; +import { + clampChroma, + clampRgb, + 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(); +export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch'] as const; - return string; +export type ModeType = (typeof SUPPORTED_MODES)[number]; + +export type ColorObject = { + l: number; + c: number; + h: number; + alpha?: number; }; -export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb']; +// Practical upper bound for the chroma axis. Covers Rec.2020; the displayable +// region for sRGB and Display-P3 falls inside this range. +export const CHROMA_MAX = 0.4; -export type ModeType = (typeof SUPPORTED_MODES)[number]; +const toOklch = converter('oklch'); +const toRgb = converter('rgb'); +const toHsl = converter('hsl'); -export type ColorObject = { +const FALLBACK: ColorObject = { l: 1, c: 0, h: 0, alpha: 1 }; + +const round = (n: number, p: number) => Number.parseFloat(n.toFixed(p)); + +export const clamp01 = (n: number) => Math.max(0, Math.min(1, n)); + +/** + * 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 oklch = toOklch(parsed); + if (!oklch) return FALLBACK; + const c = oklch.c ?? 0; + return { + 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 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 => { + if (mode === 'oklch') return formatOklchString(color); + + const rgb = toRgb({ + mode: 'oklch', + l: color.l, + c: color.c, + h: color.h, + alpha: color.alpha ?? 1 + }); + if (!rgb) return ''; + // clampRgb is culori's per-channel clip to [0, 1] — identical to the manual + // clamp it replaces, keeping non-oklch output a valid representable value. + const clipped = clampRgb(rgb); + + if (mode === 'hex') { + const hex = clipped.alpha === 1 ? formatHex(clipped) : formatHex8(clipped); + return hex.toUpperCase(); + } + 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 }); + +type HslView = { h: number; s: number; l: number; - alpha?: 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 + }; }; 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