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
7 changes: 7 additions & 0 deletions .changeset/add-color-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cube-dev/ui-kit': minor
---

Add `ColorPicker` — a form-attachable color input. The field shows the current color as a swatch, accepts hex, `rgb()`, `hsl()`, `okhsl()`, `okhst()` and `oklch()` text, and opens a popover where the color can be tuned on three axes: HST (OKHST hue/saturation/tone), LCH (OKLCH lightness/chroma/hue) or RGB. Every conversion runs through Glaze, so the value is always a real, in-gamut color.

`formatMode` controls how the text relates to the value: `forced` (default) rewrites the text in `format`, `derive` keeps the notation the user typed but normalizes the value, and `free` passes the text through verbatim after verifying it parses. Also adds a `PipetteIcon`.
432 changes: 0 additions & 432 deletions .claude/skills/ui-kit-verification/SKILL.md

This file was deleted.

18 changes: 11 additions & 7 deletions .size-limit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,24 @@ module.exports = [
}),
);
},
// 464.27 kB at the time of writing. Raised from 462 kB for Board selection
// and group movement: ~3.5 kB of engine (a rigid multi-item move primitive,
// selection state, marquee hit-testing, a live region) plus ~0.5 kB for the
// six `board.*` strings across twelve locales, which are all registered
// eagerly. Measured by building with and without the locale keys.
// 467.87 kB at the time of writing. Two features stack here:
//
// - Board selection and group movement raised it from 462 kB: ~3.5 kB of
// engine (a rigid multi-item move primitive, selection state, marquee
// hit-testing, a live region) plus ~0.5 kB for the six `board.*` strings
// across twelve locales, which are all registered eagerly. Measured by
// building with and without the locale keys.
// - `ColorPicker` adds ~3.8 kB of component, color model and channel
// definitions.
//
// The Button budget below is unchanged, which is the check that matters:
// none of this reaches a consumer who does not import `Board`.
// none of this reaches a consumer who imports neither.
//
// Headroom is deliberately small so real bloat still trips the budget.
//
// Note when checking locally: `size-limit` bundles the built `./dist`, it
// does not build. Run `pnpm build` first or you will measure a stale bundle.
limit: '466kB',
limit: '469kB',
},
{
name: 'Tree shaking (just a Button)',
Expand Down
283 changes: 283 additions & 0 deletions src/components/fields/ColorPicker/ColorPicker.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { Meta, Story } from '@storybook/addon-docs/blocks';

import * as ColorPickerStories from './ColorPicker.stories.tsx';

<Meta of={ColorPickerStories} />

# ColorPicker

An input for a single color. It reads and writes color **text** — hex, `rgb()`,
`hsl()`, `okhsl()`, `okhst()` and `oklch()` — shows the current color as a
swatch, and opens a popover where the color can be dialed in on three
perceptual axes.

Every conversion goes through [Glaze](https://github.com/tenphi/glaze), so the
canonical value the picker holds is OKHSL: bounded on every channel, and always
inside the sRGB gamut. That is what makes an out-of-gamut state impossible and
lets the same value be re-serialized into any of the supported notations without
drift.

## When to Use

- Theme and branding forms — a seed color, an accent, a chart series color
- Anywhere a color has to be typed *or* explored, rather than chosen from a
fixed palette
- When the stored format matters (a design token file wants `oklch()`, a legacy
API wants hex) and the input has to guarantee it

Reach for a `RadioGroup` or `Picker` of swatches instead when the choice is
limited to a handful of approved colors.

## Component

<Story of={ColorPickerStories.WithValue} />

---

### Properties

- **`value`** `string | null` — The selected color (controlled)
- **`defaultValue`** `string | null` — The selected color (uncontrolled)
- **`onChange`** `(value: string | null) => void` — Fired with the color string, or `null` when the field is cleared
- **`format`** `'hex' | 'rgb' | 'hsl' | 'okhsl' | 'okhst' | 'oklch'` (default: `hex`) — Notation the value is written in, and the one `forced` mode displays
- **`formatMode`** `'forced' | 'derive' | 'free'` (default: `forced`) — How strictly the input text is tied to `format`
- **`defaultSpace`** `'hst' | 'lch' | 'rgb'` (default: `hst`) — Color concept the popover opens with
- **`placeholder`** `string` (default: `Pick a color`) — Text shown while the field is empty
- **`size`** `'small' | 'medium' | 'large' | (string & {})` (default: `medium`) — Input size
- **`isOpen`** `boolean` — Whether the popover is open (controlled)
- **`defaultOpen`** `boolean` (default: `false`) — Whether the popover is open initially
- **`onOpenChange`** `(isOpen: boolean) => void` — Fired when the popover opens or closes
- **`shouldFlip`** `boolean` (default: `true`) — Whether the popover may flip to the other side of the input
- **`onFocus`** `(event: FocusEvent) => void` — Fired when the text input receives focus
- **`onBlur`** `(event: FocusEvent) => void` — Fired when the text input loses focus

Named CSS colors (`red`, `rebeccapurple`) are **not** accepted — they are not a
color space, and Glaze does not resolve them either. Alpha is parsed but
dropped: the value is always opaque.

Focusing the field selects the whole value, so a pasted color replaces it
outright. The string is the unit of editing here: a single channel is tuned with
the sliders rather than by hand-editing one number inside `oklch(…)`. Clicking a
field that already has focus positions the caret as usual, so the text stays
editable by hand.

### Base Properties

Supports [Base properties](/docs/getting-started-base-properties--docs)

### Field Properties

Supports all [Field properties](/docs/getting-started-field-properties--docs)

### Styling Properties

#### styles

Customizes the input wrapper — the element that owns the border, fill and size.

**Sub-elements:**

- `Prefix` — the container holding the color swatch
- `Suffix` — the container holding the validation state, then the popover trigger
- `InputIcon` — the swatch slot
- `State` — the validation / loading indicator

#### inputStyles

Customizes the `<input>` element itself.

#### triggerStyles

Customizes the popover trigger button.

#### swatchStyles

Customizes the color swatch shown inside the input.

### Style Properties

These properties allow direct style application without using the `styles` prop:

- **Base:** `display`, `font`, `preset`, `hide`, `whiteSpace`, `opacity`, `transition`
- **Position:** `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `scrollMargin`
- **Dimension:** `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex`
- **Block:** `border`, `radius`, `shadow`, `outline`, `padding`, `paddingInline`, `paddingBlock`, `overflow`, `scrollbar`, `textAlign`
- **Color:** `color`, `fill`, `fade`, `image`

### Modifiers

The `mods` property accepts the following modifiers you can override:

| Modifier | Type | Description |
| ---------- | --------- | ------------------------------------------------- |
| `focused` | `boolean` | The text input has focus |
| `hovered` | `boolean` | The pointer is over the input |
| `disabled` | `boolean` | The picker is disabled |
| `valid` | `boolean` | The field is in the valid state |
| `invalid` | `boolean` | The field is in the invalid state |
| `prefix` | `boolean` | A prefix (the swatch) is rendered |
| `suffix` | `boolean` | A suffix (the trigger) is rendered |

The swatch carries its own `empty` modifier while there is no color to show.

## Format modes

The three modes differ in how much freedom the *text* gets. The value is always
a real color in every one of them.

<Story of={ColorPickerStories.FormatModes} />

| Mode | Text | Value |
| -------- | ----------------------------------- | ---------------------------------------------------- |
| `forced` | Rewritten in `format` on blur/Enter | Always written in `format` |
| `derive` | Left exactly as typed | Normalized in the notation the text is written in |
| `free` | Left exactly as typed | The text itself, verbatim |

In every mode an entry that is not a color is rejected: the previous valid color
stays the value while it is being typed, and the text snaps back to it on blur.
Emptying the field is not an error — it commits `null`.

```jsx
// A design-token form that must store OKLCH
<ColorPicker label="Accent" format="oklch" onChange={setAccent} />

// A CSS editor: keep whatever the author wrote, but guarantee it parses
<ColorPicker label="Color" formatMode="free" onChange={setCss} />
```

## Color spaces

The popover edits one canonical color through three sets of axes:

| Space | Axes | Good for |
| ----- | ----------------------------- | ----------------------------------------------------- |
| `hst` | Hue, Saturation, Tone (OKHST) | Building ramps — equal tone steps read as equal steps |
| `lch` | Lightness, Chroma, Hue (OKLCH)| Matching a value from a design-token file |
| `rgb` | Red, Green, Blue | Matching a value from a legacy palette or a screenshot|

Each of these opens on its own space; the switcher inside the popover moves
between them at any time.

<Story of={ColorPickerStories.Spaces} />

`H` means the same angle in HST and LCH, so the hue strip is identical in both.
The chroma slider is bounded by the sRGB gamut at the current lightness and hue,
which is why its range changes as `L` moves.

## Examples

### Basic usage

```jsx
<ColorPicker label="Brand color" defaultValue="#7a4dbf" />
```

### Controlled

```jsx
const [color, setColor] = useState('#7a4dbf');

<ColorPicker label="Brand color" value={color} onChange={setColor} />;
```

### Inside a form

```jsx
<Form onSubmit={onSubmit}>
<ColorPicker
name="accent"
label="Accent color"
format="oklch"
rules={[{ required: true, message: 'Pick an accent color' }]}
/>
</Form>
```

### Opening on a specific space

```jsx
<ColorPicker label="Series color" defaultSpace="rgb" defaultValue="#26fcb2" />
```

### Using the value as a style

Because the emitted value is a plain color string, it can go straight into a
tasty style — including the `okhsl()` and `okhst()` notations, which tasty
parses natively.

```jsx
const [color, setColor] = useState('okhst(264 80% 60%)');

<>
<ColorPicker label="Fill" format="okhst" value={color} onChange={setColor} />
<Block fill={color} height="10x" radius="1r" />
</>;
```

## Accessibility

### Keyboard Navigation

- `Tab` — moves focus to the text input, then to the popover trigger
- `Enter` — commits and normalizes the typed color without leaving the field
- `Space` / `Enter` on the trigger — opens the popover
- `Escape` — closes the popover
- `Left` / `Right`, `Home` / `End` on a channel slider — moves that channel
- `Left` / `Right` on the space switcher — moves between HST, LCH and RGB

### Screen Reader Support

- The text input announces as a textbox with the field's label
- The trigger announces as "Open the color picker"
- The popover announces as a dialog named "Color picker"
- Each channel slider sits in a group named after its axis (`Hue`,
`Saturation`, `Tone`, `Lightness`, `Chroma`, `Red`, `Green`, `Blue`)
- The swatch is decorative and is not announced — the text carries the value

### ARIA Properties

- `aria-label` — names the field when there is no visible label
- `aria-describedby` — wired to the field description by the form system

## Best Practices

1. **Do**: name the field, so the text input is announced meaningfully

```jsx
<ColorPicker label="Brand color" />
```

2. **Don't**: rely on the swatch alone to convey the value

```jsx
<ColorPicker aria-label="" />
```

3. **Do**: pick the `format` your backend stores, and leave `formatMode` at
`forced` so the two can never disagree.

4. **Don't**: use `free` mode when the value is later parsed by something
stricter than a browser — the text is passed through as written.

## Integration with Forms

This component supports all
[Field properties](/docs/getting-started-field-properties--docs) when used
within a Form. The field value is the color string, and clearing the input
stores `null`, which makes `required` rules behave as expected.

## Suggested Improvements

- An eyedropper that samples a pixel from the page, where
[`EyeDropper`](https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper) is
available
- Optional alpha support, which the canonical value currently drops
- A row of recent or preset swatches inside the popover
- Editable numeric inputs next to each channel slider, for exact entry without
going through the text field

## Related Components

- [HueSlider](/docs/forms-hueslider--docs) — just the hue axis, when only a hue is needed
- [Slider](/docs/forms-slider--docs) — the primitive each channel is built from
- [TextInput](/docs/forms-textinput--docs) — the input chrome this component reuses
Loading
Loading