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
34 changes: 34 additions & 0 deletions apps/www/src/content/docs/components/color-picker/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,40 @@ The `ColorPicker` can be embedded within a `Popover` component to create a more

<Demo data={popoverDemo} />

## Utilities

### formatColor

Converts any CSS color string to the requested format. Useful when consuming apsara color tokens (which are authored in OKLCH) from APIs that need a specific format — chart libraries, SVG attributes, canvas fills, design exports, and similar.

```tsx
import { formatColor } from '@raystack/apsara'

formatColor('oklch(0.5438 0.191 267.01)', 'hex') // '#3E63DD'
formatColor('oklch(0.7 0.32 30)', 'hex') // '#FF5843' (gamut-mapped)
formatColor('red', 'rgb') // 'rgb(255, 0, 0)'
formatColor('rgba(255, 0, 0, 0.5)', 'hsl') // 'hsla(0, 100%, 50%, 0.5)'
formatColor('#FF0000', 'oklch') // 'oklch(0.6279 0.2577 29.23)'
formatColor('not a color', 'hex') // null
```

**Behavior**

- Accepts any CSS color string: `oklch()`, `rgb()`/`rgba()`, `hsl()`/`hsla()`, hex, named colors, `transparent`.
- For `hex`, `rgb`, and `hsl` outputs, wide-gamut OKLCH inputs are gamut-mapped into sRGB by **reducing chroma while preserving lightness and hue**, so the returned string is the closest sRGB representation rather than a per-channel-clipped one (which would distort hue).
- For `oklch` output, the full color is preserved — OKLCH can express the wide gamut natively. Output matches the design system's token format (4-decimal L/C, 2-decimal H, hue pinned to 0 when achromatic).
- Hex is uppercase; uses 8-digit form (`#rrggbbaa`) when alpha < 1. Translucent rgb/hsl/oklch produce `rgba()` / `hsla()` / `oklch(... / A)`.
- Returns `null` for unparseable input rather than throwing — safe to call on user-supplied strings.

**Signature**

```ts
function formatColor(
value: string,
format: 'hex' | 'rgb' | 'hsl' | 'oklch'
): string | null
```

## Accessibility

- Provides `aria-label` attributes for color areas and sliders
Expand Down
67 changes: 67 additions & 0 deletions packages/raystack/components/color-picker/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
CHROMA_MAX,
clampToSrgb,
formatColor,
getColorString,
hslToOklch,
oklchToHsl,
Expand Down Expand Up @@ -82,4 +83,70 @@ describe('color-picker utils', () => {
expect(closeTo(clamped.h, wide.h, 0.01)).toBe(true);
});
});

describe('formatColor', () => {
describe('hex', () => {
it('returns uppercase 6-digit hex for opaque inputs', () => {
expect(formatColor('#ff0000', 'hex')).toBe('#FF0000');
expect(formatColor('rgb(0, 255, 0)', 'hex')).toBe('#00FF00');
expect(formatColor('red', 'hex')).toBe('#FF0000');
});

it('returns uppercase 8-digit hex when alpha < 1', () => {
expect(formatColor('rgba(255, 0, 0, 0.5)', 'hex')).toBe('#FF000080');
expect(formatColor('oklch(0 0 0 / 0.5)', 'hex')).toBe('#00000080');
});

it('round-trips an in-gamut oklch to the same sRGB hex', () => {
expect(formatColor('oklch(0.6279 0.2576 29.23)', 'hex')).toBe(
'#FF0000'
);
});

it('gamut-maps wide-gamut OKLCH instead of per-channel clipping', () => {
// Per-channel clip would push this to #FF0000 (R channel saturates,
// hue lost). toGamut preserves hue by reducing chroma.
const hex = formatColor('oklch(0.7 0.32 30)', 'hex');
expect(hex).toMatch(/^#[0-9A-F]{6}$/);
expect(hex).not.toBe('#FF0000');
});
});

describe('rgb', () => {
it('returns rgb() for opaque and rgba() for translucent', () => {
expect(formatColor('#ff0000', 'rgb')).toBe('rgb(255, 0, 0)');
expect(formatColor('rgba(255, 0, 0, 0.5)', 'rgb')).toBe(
'rgba(255, 0, 0, 0.5)'
);
});
});

describe('hsl', () => {
it('returns hsl() for opaque and hsla() for translucent', () => {
expect(formatColor('#ff0000', 'hsl')).toMatch(/^hsl\(/);
expect(formatColor('rgba(255, 0, 0, 0.5)', 'hsl')).toMatch(/^hsla\(/);
});
});

describe('oklch', () => {
it('serializes to the design-system oklch() format', () => {
// hex → oklch should produce a parseable oklch with finite L/C/H.
const out = formatColor('#ff0000', 'oklch');
expect(out).toMatch(/^oklch\([\d.]+ [\d.]+ [\d.]+\)$/);
});

it('appends alpha tail when alpha < 1', () => {
expect(formatColor('rgba(255, 0, 0, 0.5)', 'oklch')).toMatch(
/^oklch\([\d.]+ [\d.]+ [\d.]+ \/ [\d.]+\)$/
);
});
});

it('returns null for unparseable input in every format', () => {
for (const fmt of ['hex', 'rgb', 'hsl', 'oklch'] as const) {
expect(formatColor('not a color', fmt)).toBeNull();
expect(formatColor('', fmt)).toBeNull();
}
});
});
});
1 change: 1 addition & 0 deletions packages/raystack/components/color-picker/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ColorPicker } from './color-picker';
export { formatColor } from './utils';
61 changes: 60 additions & 1 deletion packages/raystack/components/color-picker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
formatHex8,
formatHsl,
formatRgb,
parse
parse,
toGamut
} from 'culori';

export const SUPPORTED_MODES = ['hex', 'hsl', 'rgb', 'oklch'] as const;
Expand All @@ -28,6 +29,11 @@ const toOklch = converter('oklch');
const toRgb = converter('rgb');
const toHsl = converter('hsl');

// Reusable gamut-mapper: reduces chroma in OKLCH space until the color fits
// sRGB while preserving lightness and hue. Created once because `toGamut`
// returns a function and the inputs ('rgb' / 'oklch') are constant.
const toSrgb = toGamut('rgb', 'oklch');

const FALLBACK: ColorObject = { l: 1, c: 0, h: 0, alpha: 1 };

const round = (n: number, p: number) => Number.parseFloat(n.toFixed(p));
Expand Down Expand Up @@ -63,6 +69,59 @@ const formatOklchString = (color: ColorObject): string => {
return alpha === 1 ? `oklch(${body})` : `oklch(${body} / ${round(alpha, 4)})`;
};

/**
* Convert any CSS color string to the requested format.
*
* Wide-gamut OKLCH inputs are gamut-mapped into sRGB for `hex`/`rgb`/`hsl`
* outputs (chroma reduced, lightness and hue preserved), so the result is
* the closest sRGB representation of the requested color rather than a
* per-channel-clipped one that would distort hue. `oklch` output preserves
* the full color, since OKLCH can express the wide gamut natively.
*
* Hex is uppercase; uses 8-digit form when alpha < 1. RGB/HSL produce
* `rgb()`/`rgba()` and `hsl()`/`hsla()`. OKLCH matches the design system's
* token format (4-decimal L/C, 2-decimal H, hue pinned to 0 when achromatic).
*
* Returns `null` for unparseable input.
*
* @example
* formatColor('oklch(0.5438 0.191 267.01)', 'hex') // '#3E63DD'
* formatColor('oklch(0.7 0.32 30)', 'hex') // '#FF5843'
* formatColor('red', 'rgb') // 'rgb(255, 0, 0)'
* formatColor('rgba(255, 0, 0, 0.5)', 'hsl') // 'hsla(0, 100%, 50%, 0.5)'
* formatColor('#FF0000', 'oklch') // 'oklch(0.6279 0.2577 29.23)'
* formatColor('not a color', 'hex') // null
*/
export const formatColor = (
value: string,
format: 'hex' | 'rgb' | 'hsl' | 'oklch'
): string | null => {
const parsed = parse(value);
if (!parsed) return null;

if (format === 'oklch') {
const oklch = toOklch(parsed);
if (!oklch) return null;
return formatOklchString({
l: oklch.l ?? 0,
c: oklch.c ?? 0,
h: Number.isFinite(oklch.h) ? (oklch.h as number) : 0,
alpha: oklch.alpha ?? 1
});
}

// sRGB-bound formats: gamut-map first so wide-gamut OKLCH inputs land on
// the closest sRGB color (hue/lightness preserved) instead of producing
// per-channel-clipped hex/rgb/hsl that would shift the hue.
const safe = toSrgb(parsed);
if (format === 'hex') {
const hex = (safe.alpha ?? 1) === 1 ? formatHex(safe) : formatHex8(safe);
return hex.toUpperCase();
}
if (format === 'hsl') return formatHsl(safe);
return formatRgb(safe);
};

/**
* 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
Expand Down
Loading