Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/www/src/content/docs/components/color-picker/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ 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. Pass `copyable` to render a copy-to-clipboard button in the input's trailing slot.
Displays the current color value in the selected color model. 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.

Users can type any CSS color string, for example `#00FF00`, `rgb(0, 255, 0)`, or `green`. The picker applies it on Enter or blur and shows it in the active mode. Enter does not submit a surrounding form. Invalid text reverts to the current color. Pass `readOnly` to turn off editing.

### Slots

Expand All @@ -112,7 +114,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `color-picker-slider-track` | The slider track (shared by hue and alpha) |
| `color-picker-slider-range` | The filled range indicator inside the track (shared by hue and alpha) |
| `color-picker-slider-thumb` | The slider thumb (shared by hue and alpha) |
| `color-picker-input` | The read-only color value input |
| `color-picker-input` | The color value input |
| `color-picker-mode` | The mode select trigger |
| `color-picker-mode-content` | The mode select dropdown content (when open) |

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ColorPicker } from '../color-picker';

Expand Down Expand Up @@ -138,6 +139,39 @@ describe('ColorPicker', () => {

expect(onValueChange).toHaveBeenCalled();
});

it('updates the color on pointer drag when a ref and handlers are passed', () => {
const onValueChange = vi.fn();
const onPointerDown = vi.fn();
const onPointerMove = vi.fn();
const ref = createRef<HTMLDivElement>();
render(
<ColorPicker mode='oklch' onValueChange={onValueChange}>
<ColorPicker.Area
ref={ref}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
/>
</ColorPicker>
);

const area = screen.getByRole('slider', { name: /color area/i });
expect(ref.current).toBe(area);
area.getBoundingClientRect = () =>
({ left: 0, top: 0, width: 100, height: 100 }) as DOMRect;
area.setPointerCapture = vi.fn();
area.hasPointerCapture = vi.fn().mockReturnValue(true);

fireEvent.pointerDown(area, { pointerId: 1, clientX: 0, clientY: 0 });
expect(onValueChange).toHaveBeenLastCalledWith('oklch(1 0 0)', 'oklch');
fireEvent.pointerMove(area, { pointerId: 1, clientX: 50, clientY: 50 });
expect(onValueChange).toHaveBeenLastCalledWith(
'oklch(0.5 0.2 0)',
'oklch'
);
expect(onPointerDown).toHaveBeenCalledTimes(1);
expect(onPointerMove).toHaveBeenCalledTimes(1);
});
});

describe('ColorPicker.Hue', () => {
Expand Down Expand Up @@ -229,7 +263,64 @@ describe('ColorPicker', () => {

const input = screen.getByTestId('color-input');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('readonly');
expect(input).not.toHaveAttribute('readonly');
});

it('applies a typed color on Enter', () => {
const onValueChange = vi.fn();
render(
<ColorPicker onValueChange={onValueChange}>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);

const input = screen.getByTestId('color-input');
fireEvent.change(input, { target: { value: 'rgb(0, 255, 0)' } });
fireEvent.keyDown(input, { key: 'Enter', isComposing: true });
expect(onValueChange).not.toHaveBeenCalled();
// false means the keydown was default-prevented, so a form won't submit.
expect(fireEvent.keyDown(input, { key: 'Enter' })).toBe(false);

expect(onValueChange).toHaveBeenCalledWith('#00FF00', 'hex');
expect(input).toHaveValue('#00FF00');
});

it('drops typed text when the color changes elsewhere', () => {
const onValueChange = vi.fn();
const { rerender } = render(
<ColorPicker value='#ff0000' onValueChange={onValueChange}>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);

const input = screen.getByTestId('color-input');
fireEvent.change(input, { target: { value: '#112233' } });
rerender(
<ColorPicker value='#00ff00' onValueChange={onValueChange}>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);
expect(input).toHaveValue('#00FF00');
fireEvent.blur(input);

expect(onValueChange).not.toHaveBeenCalled();
});

it('reverts an invalid color on blur', () => {
const onValueChange = vi.fn();
render(
<ColorPicker defaultValue='#ff0000' onValueChange={onValueChange}>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);

const input = screen.getByTestId('color-input');
fireEvent.change(input, { target: { value: 'not a color' } });
expect(input).toHaveValue('not a color');
fireEvent.blur(input);

expect(onValueChange).not.toHaveBeenCalled();
expect(input).toHaveValue('#FF0000');
});

it('displays color value in default mode', () => {
Expand Down
129 changes: 42 additions & 87 deletions packages/raystack/components/color-picker/color-picker-area.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { mergeProps } from '@base-ui/react';
import { cx } from 'class-variance-authority';
import {
ComponentProps,
Expand Down Expand Up @@ -32,6 +33,28 @@ const STEP_LARGE = 0.1;

export type ColorPickerAreaProps = ComponentProps<'div'>;

// Pointer capture sends the drag's move events to the pad, so the pad needs
// no window listeners.
const getPointerHandlers = (applyPosition: (x: number, y: number) => void) => {
const apply = (e: ReactPointerEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
applyPosition(
(e.clientX - rect.left) / rect.width,
(e.clientY - rect.top) / rect.height
);
};
return {
onPointerDown: (e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
apply(e);
},
onPointerMove: (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) apply(e);
}
};
};

export const ColorPickerArea = (props: ColorPickerAreaProps) => {
const { mode } = useColorPicker();
return mode === 'oklch' ? <OklchArea {...props} /> : <HslArea {...props} />;
Expand All @@ -41,11 +64,9 @@ 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<HTMLDivElement>(null);
const OklchArea = (props: ColorPickerAreaProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const isThumbVisible = useRef(false);

const { lightness, chroma, hue, setColor } = useColorPicker();
Expand Down Expand Up @@ -115,41 +136,6 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => {
[setColor]
);

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);
applyPosition(x, y);
},
[applyPosition]
);

const handlePointerUp = useCallback(() => {
isDragging.current = false;
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
}, [handlePointerMove]);

const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
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]
);

const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
// Current thumb position, mirrored from the same math the thumb effect
Expand Down Expand Up @@ -197,10 +183,6 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => {

return (
<div
className={cx(styles.selectionRoot, className)}
onPointerDown={handlePointerDown}
onKeyDown={handleKeyDown}
ref={containerRef}
role='slider'
tabIndex={0}
aria-label='Color area, chroma and lightness'
Expand All @@ -209,7 +191,14 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => {
aria-valuemax={100}
aria-valuenow={Math.round((chroma / CHROMA_MAX) * 100)}
data-slot='color-picker-area'
{...props}
{...mergeProps<'div'>(
{
className: styles.selectionRoot,
onKeyDown: handleKeyDown,
...getPointerHandlers(applyPosition)
},
props
)}
>
<canvas
ref={canvasRef}
Expand All @@ -232,10 +221,8 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => {
// 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<HTMLDivElement>(null);
const HslArea = (props: ColorPickerAreaProps) => {
const thumbRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const isThumbVisible = useRef(false);

const { lightness, chroma, hue, setColor } = useColorPicker();
Expand Down Expand Up @@ -281,41 +268,6 @@ const HslArea = ({ className, ...props }: ColorPickerAreaProps) => {
[hsl.h, setColor]
);

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);
applyPosition(x, y);
},
[applyPosition]
);

const handlePointerUp = useCallback(() => {
isDragging.current = false;
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
}, [handlePointerMove]);

const handlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
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]
);

const handleKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLDivElement>) => {
// Current position mirrored from the same math the thumb effect uses.
Expand Down Expand Up @@ -365,20 +317,23 @@ const HslArea = ({ className, ...props }: ColorPickerAreaProps) => {

return (
<div
className={cx(styles.selectionRoot, className)}
onPointerDown={handlePointerDown}
onKeyDown={handleKeyDown}
ref={containerRef}
role='slider'
tabIndex={0}
aria-label='Color area, saturation and brightness'
aria-valuetext={valueText}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(hsl.s)}
style={{ background }}
data-slot='color-picker-area'
{...props}
{...mergeProps<'div'>(
{
className: styles.selectionRoot,
style: { background },
onKeyDown: handleKeyDown,
...getPointerHandlers(applyPosition)
},
props
)}
>
<div
className={cx(styles.sliderThumb, styles.selectionThumb)}
Expand Down
Loading
Loading