Skip to content
Open
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
169 changes: 169 additions & 0 deletions apps/www/src/app/examples/color-picker/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
direction='column'
gap={9}
style={{
padding: 32,
background: 'var(--rs-color-background-neutral-secondary)',
minHeight: '100vh'
}}
>
<Flex direction='column' gap={2}>
<Text size='large' weight='medium'>
ColorPicker
</Text>
<Text size='small' variant='secondary'>
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.
</Text>
</Flex>

<Flex gap={6} wrap='wrap'>
<Flex direction='column' gap={3} style={cardStyle}>
<Text size='small' weight='medium'>
Default (hex)
</Text>
<ColorPicker defaultValue='#DA2929'>
<ColorPicker.Area />
<ColorPicker.Hue />
<ColorPicker.Alpha />
<Flex direction='row' gap={2}>
<ColorPicker.Mode />
<ColorPicker.Input />
</Flex>
</ColorPicker>
</Flex>

<Flex direction='column' gap={3} style={cardStyle}>
<Text size='small' weight='medium'>
OKLCH mode
</Text>
<Text size='micro' variant='secondary'>
<code>defaultValue=&apos;oklch(0.5438 0.191 267.01)&apos;</code>{' '}
with <code>defaultMode=&apos;oklch&apos;</code>.
</Text>
<ColorPicker
defaultValue='oklch(0.5438 0.191 267.01)'
defaultMode='oklch'
>
<ColorPicker.Area />
<ColorPicker.Hue />
<ColorPicker.Alpha />
<Flex direction='row' gap={2}>
<ColorPicker.Mode />
<ColorPicker.Input />
</Flex>
</ColorPicker>
</Flex>

<Flex direction='column' gap={3} style={cardStyle}>
<Text size='small' weight='medium'>
Controlled — emits live value
</Text>
<ColorPicker
value={controlledValue}
mode={controlledMode}
onValueChange={(value, mode) => {
setControlledValue(value);
setControlledMode(mode as typeof controlledMode);
}}
onModeChange={mode =>
setControlledMode(mode as typeof controlledMode)
}
>
<ColorPicker.Area />
<ColorPicker.Hue />
<ColorPicker.Alpha />
<Flex direction='row' gap={2}>
<ColorPicker.Mode />
<ColorPicker.Input />
</Flex>
</ColorPicker>
<Flex direction='column' gap={1}>
<Text size='micro' variant='secondary'>
value:
</Text>
<Text
size='small'
style={{
fontFamily: 'monospace',
wordBreak: 'break-all'
}}
>
{controlledValue}
</Text>
<Text size='micro' variant='secondary' style={{ marginTop: 4 }}>
mode: <code>{controlledMode}</code>
</Text>
</Flex>
</Flex>

<Flex direction='column' gap={3} style={cardStyle}>
<Text size='small' weight='medium'>
Popover trigger
</Text>
<Popover>
<Popover.Trigger
render={
<Button
style={{
width: 60,
height: 60,
background: popoverColor
}}
/>
}
/>
<Popover.Content>
<ColorPicker
value={popoverColor}
onValueChange={setPopoverColor}
style={{ width: 240, height: 320 }}
>
<ColorPicker.Area />
<ColorPicker.Hue />
<ColorPicker.Alpha />
<Flex direction='row' gap={2}>
<ColorPicker.Mode />
<ColorPicker.Input />
</Flex>
</ColorPicker>
</Popover.Content>
</Popover>
<Text
size='small'
style={{
fontFamily: 'monospace',
wordBreak: 'break-all'
}}
>
{popoverColor}
</Text>
</Flex>
</Flex>
</Flex>
);
}
22 changes: 22 additions & 0 deletions apps/www/src/content/docs/components/color-picker/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ export const basicDemo = {
`
};

export const oklchDemo = {
type: 'code',
code: `<ColorPicker
defaultValue='oklch(0.5438 0.191 267.01)'
defaultMode='oklch'
style={{
width: '240px',
height: '320px',
padding: 12,
background: 'white'
}}
>
<ColorPicker.Area />
<ColorPicker.Hue />
<ColorPicker.Alpha />
<Flex direction="row" gap={2}>
<ColorPicker.Mode />
<ColorPicker.Input />
</Flex>
</ColorPicker>`
};

export const popoverDemo = {
type: 'code',
previewCode: false,
Expand Down
11 changes: 10 additions & 1 deletion apps/www/src/content/docs/components/color-picker/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ source: packages/raystack/components/color-picker
import {
preview,
basicDemo,
oklchDemo,
popoverDemo,
} from "./demo.ts";

Expand Down Expand Up @@ -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.

<auto-type-table path="./props.ts" name="ColorPickerModeProps" />

Expand All @@ -64,6 +65,14 @@ Displays the current color value in the selected color model and allows direct t

<Demo data={basicDemo} />

### 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.

<Demo data={oklchDemo} />

### Popover Integration

The `ColorPicker` can be embedded within a `Popover` component to create a more interactive and space-efficient color selection experience.
Expand Down
12 changes: 6 additions & 6 deletions apps/www/src/content/docs/components/color-picker/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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.
*/
Expand All @@ -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'>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,42 @@ describe('ColorPicker', () => {
input = screen.getByTestId('color-input');
expect(input).toHaveValue('#00FF00');
});

it('accepts oklch input', () => {
render(
<ColorPicker defaultValue='oklch(0.6279 0.2576 29.23)'>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);
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(
<ColorPicker defaultValue='#ff0000' mode='oklch'>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);
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(
<ColorPicker defaultValue='rgba(255, 0, 0, 0.5)' mode='oklch'>
<ColorPicker.Input data-testid='color-input' />
</ColorPicker>
);
const input = screen.getByTestId('color-input');
expect((input as HTMLInputElement).value).toMatch(
/^oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)$/
);
});
});

describe('ColorPicker.Mode', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use client';

import { cx } from 'class-variance-authority';
import Color from 'color';
import {
ComponentProps,
PointerEvent as ReactPointerEvent,
Expand All @@ -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'>;

Expand All @@ -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)),
Expand Down Expand Up @@ -107,7 +110,7 @@ export const ColorPickerArea = ({
className={cx(styles.sliderThumb, styles.selectionThumb)}
ref={thumbRef}
style={{
background: color.hex().toString(),
background: thumbColor,
opacity: 0
}}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
'use client';

import Color from 'color';
import { ComponentProps } from 'react';
import { Input } from '../input';
import { useColorPicker } from './color-picker-root';
import { getColorString } from './utils';

export const ColorPickerInput = (props: ComponentProps<typeof Input>) => {
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 <Input value={getColorString(color, mode)} readOnly {...props} />;
return <Input value={value} readOnly {...props} />;
};

ColorPickerInput.displayName = 'ColorPicker.Input';
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ModeType, SUPPORTED_MODES } from './utils';

export interface ColorPickerModeProps
extends ComponentProps<typeof Select.Trigger> {
options?: ModeType[];
options?: readonly ModeType[];
}

export const ColorPickerMode = ({
Expand Down
Loading
Loading