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 5b3d3f827..23ed9b584 100644 --- a/apps/www/src/content/docs/components/color-picker/index.mdx +++ b/apps/www/src/content/docs/components/color-picker/index.mdx @@ -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 @@ -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) | 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 78058a73d..02dddadfc 100644 --- a/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx +++ b/packages/raystack/components/color-picker/__tests__/color-picker.test.tsx @@ -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'; @@ -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(); + render( + + + + ); + + 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', () => { @@ -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( + + + + ); + + 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( + + + + ); + + const input = screen.getByTestId('color-input'); + fireEvent.change(input, { target: { value: '#112233' } }); + rerender( + + + + ); + expect(input).toHaveValue('#00FF00'); + fireEvent.blur(input); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('reverts an invalid color on blur', () => { + const onValueChange = vi.fn(); + render( + + + + ); + + 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', () => { diff --git a/packages/raystack/components/color-picker/color-picker-area.tsx b/packages/raystack/components/color-picker/color-picker-area.tsx index c90483096..1dc7c4a3f 100644 --- a/packages/raystack/components/color-picker/color-picker-area.tsx +++ b/packages/raystack/components/color-picker/color-picker-area.tsx @@ -1,5 +1,6 @@ 'use client'; +import { mergeProps } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ComponentProps, @@ -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) => { + const rect = e.currentTarget.getBoundingClientRect(); + applyPosition( + (e.clientX - rect.left) / rect.width, + (e.clientY - rect.top) / rect.height + ); + }; + return { + onPointerDown: (e: ReactPointerEvent) => { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + apply(e); + }, + onPointerMove: (e: ReactPointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) apply(e); + } + }; +}; + export const ColorPickerArea = (props: ColorPickerAreaProps) => { const { mode } = useColorPicker(); return mode === 'oklch' ? : ; @@ -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(null); +const OklchArea = (props: ColorPickerAreaProps) => { const canvasRef = useRef(null); const thumbRef = useRef(null); - const isDragging = useRef(false); const isThumbVisible = useRef(false); const { lightness, chroma, hue, setColor } = useColorPicker(); @@ -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) => { - 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) => { // Current thumb position, mirrored from the same math the thumb effect @@ -197,10 +183,6 @@ const OklchArea = ({ className, ...props }: ColorPickerAreaProps) => { return (
{ 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 + )} > { // 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 HslArea = (props: ColorPickerAreaProps) => { const thumbRef = useRef(null); - const isDragging = useRef(false); const isThumbVisible = useRef(false); const { lightness, chroma, hue, setColor } = useColorPicker(); @@ -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) => { - 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) => { // Current position mirrored from the same math the thumb effect uses. @@ -365,10 +317,6 @@ const HslArea = ({ className, ...props }: ColorPickerAreaProps) => { return (
{ 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 + )} >
{ +export interface ColorPickerInputProps + extends Omit, 'value' | 'defaultValue'> { /** * Render a copy-to-clipboard button inside the input's trailing slot. * The button copies the current formatted color string in the active mode. @@ -18,9 +20,12 @@ export interface ColorPickerInputProps extends ComponentProps { export const ColorPickerInput = ({ copyable = false, trailingIcon, + onChange, + onBlur, + onKeyDown, ...props }: ColorPickerInputProps) => { - const { lightness, chroma, hue, alpha, mode } = useColorPicker(); + const { lightness, chroma, hue, alpha, mode, setColor } = useColorPicker(); const value = useMemo( () => getColorString( @@ -29,6 +34,20 @@ export const ColorPickerInput = ({ ), [lightness, chroma, hue, alpha, mode] ); + // Typed text, held until Enter or blur applies it. A color change from + // another control drops it. + const [draft, setDraft] = useState(null); + const [prevValue, setPrevValue] = useState(value); + if (value !== prevValue) { + setPrevValue(value); + setDraft(null); + } + + const commit = () => { + if (draft === null) return; + setDraft(null); + if (draft !== value && parse(draft)) setColor(parseColor(draft)); + }; // A consumer-supplied trailingIcon always wins; copyable only fills the slot // when no trailingIcon was provided. size=2 matches the Input's trailing-icon @@ -39,8 +58,26 @@ export const ColorPickerInput = ({ return ( { + setDraft(event.target.value); + onChange?.(event); + }} + onBlur={event => { + commit(); + onBlur?.(event); + }} + onKeyDown={event => { + if ( + event.key === 'Enter' && + !event.nativeEvent.isComposing && + event.keyCode !== 229 + ) { + event.preventDefault(); + commit(); + } + onKeyDown?.(event); + }} trailingIcon={resolvedTrailingIcon} data-slot='color-picker-input' {...props}