feat(primitives): media-editor components, category reorg, perf + type cleanup

Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,137 @@
<script lang="ts">
import type { AlphaSliderDirection, AlphaSliderOrientation } from './context';
import type { HSVA } from '../../internal/color';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A 1D slider for picking the alpha (opacity, `01`) of a colour. It works
* standalone — owning its own `HSVA` via `v-model` / `defaultValue` — or, when
* nested inside a `ColorFieldRoot`, reads and writes that shared colour so the
* whole picker cluster stays in sync. Mirrors the standard slider anatomy: the
* root owns the value, maps pointer drags along the track, handles arrow / Page
* / Home / End keys, and provides context to `AlphaSliderThumb`. The background
* should be a checkerboard overlaid with an opaque→transparent colour gradient;
* style it via the exposed slot/`data-*` hooks. Reach for it as the opacity rail
* of a colour picker.
*/
export interface AlphaSliderRootProps extends PrimitiveProps {
/** Uncontrolled initial colour. @default { h: 0, s: 1, v: 1, a: 1 } */
defaultValue?: HSVA;
/** Keyboard step in alpha units. @default 0.01 */
step?: number;
/** Large-step multiplier (Page keys / Shift+Arrow). @default 10 */
largeStep?: number;
/** Orientation. @default 'horizontal' */
orientation?: AlphaSliderOrientation;
/** Writing direction (inherited from `ConfigProvider` when omitted). */
dir?: AlphaSliderDirection;
/** Disable interaction. @default false */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { clampChannel } from '../../internal/color';
import { Primitive } from '../../internal/primitive';
import { provideAlphaSliderContext } from './context';
import { useColorState } from '../color-field/useColorState';
import { useDirection } from '../../utilities/config-provider';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useForwardExpose } from '@robonen/vue';
const {
defaultValue,
step = 0.01,
largeStep = 10,
orientation = 'horizontal',
dir,
disabled = false,
as = 'span',
} = defineProps<AlphaSliderRootProps>();
const direction = useDirection(() => dir);
// shallowRef: HSVA is replaced wholesale by the setters, never mutated in place.
const model = defineModel<HSVA | null>();
const standalone = shallowRef<HSVA>(model.value ?? defaultValue ?? { h: 0, s: 1, v: 1, a: 1 });
const standaloneState = computed<HSVA>({
get: () => standalone.value,
set: (v) => {
standalone.value = v;
model.value = v;
},
});
const colorState = useColorState(standaloneState, () => disabled);
const alpha = computed(() => colorState.hsva.value.a);
const trackRef = shallowRef<HTMLElement | null>(null);
function setAlpha(next: number): void {
if (colorState.disabled.value) return;
colorState.setAlpha(clampChannel(next, 1));
}
function alphaFromPointer(clientCoord: { x: number; y: number }): number {
const track = trackRef.value;
if (!track) return alpha.value;
const rect = track.getBoundingClientRect();
const horizontal = orientation === 'horizontal';
const size = horizontal ? rect.width : rect.height;
if (size === 0) return alpha.value;
let offset = horizontal ? clientCoord.x - rect.left : clientCoord.y - rect.top;
const flip = horizontal ? direction.value === 'rtl' : true;
if (flip) offset = size - offset;
return clampChannel(offset / size, 1);
}
usePointerDrag(trackRef, {
axis: orientation === 'horizontal' ? 'x' : 'y',
threshold: 0,
trackElementRect: true,
disabled: () => colorState.disabled.value,
onStart: (state) => {
setAlpha(alphaFromPointer({ x: state.point.x, y: state.point.y }));
},
onMove: (state) => {
setAlpha(alphaFromPointer({ x: state.point.x, y: state.point.y }));
},
});
provideAlphaSliderContext({
hsva: colorState.hsva,
alpha,
step: toRef(() => step),
largeStep: toRef(() => largeStep),
orientation: toRef(() => orientation),
direction,
disabled: colorState.disabled,
labelId: colorState.labelId,
trackRef,
setAlpha,
});
defineExpose({ alpha });
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (node) => {
trackRef.value = node ?? null;
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:aria-disabled="colorState.disabled.value || undefined"
:data-disabled="colorState.disabled.value ? '' : undefined"
:data-orientation="orientation"
>
<slot :alpha="alpha" />
</Primitive>
</template>
@@ -0,0 +1,126 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The draggable handle of an `AlphaSliderRoot`, rendered as `role="slider"`
* with full ARIA value attributes (`aria-valuemin="0"`, `aria-valuemax="1"`,
* `aria-valuenow` = current alpha, `aria-valuetext` = the alpha as a percent).
* It positions itself along the track by the alpha percentage and handles
* keyboard interaction (arrows step by `step`, Page Up/Down and Shift+Arrow by
* the large step, Home/End jump to `0`/`1`). Give it an `aria-label` or rely on
* the default `"Alpha"`. Exposes `alpha` and `percent` as slot props.
*/
export interface AlphaSliderThumbProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useAlphaSliderContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<AlphaSliderThumbProps>();
const ctx = useAlphaSliderContext();
const attrs = useAttrs();
const alpha = computed(() => ctx.alpha.value);
const percent = computed(() => alpha.value * 100);
const accessibleLabel = computed<string | undefined>(() => {
const hasLabel = attrs['aria-label'] !== undefined && attrs['aria-label'] !== null;
const hasLabelledBy = attrs['aria-labelledby'] !== undefined && attrs['aria-labelledby'] !== null;
if (hasLabel || hasLabelledBy) return undefined;
return ctx.labelId.value ? undefined : 'Alpha';
});
const valueText = computed(() => `${Math.round(alpha.value * 100)}%`);
const positionStyle = computed<{
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
}>(() => {
const pct = percent.value;
const horizontal = ctx.orientation.value === 'horizontal';
const rtl = ctx.direction.value === 'rtl';
if (horizontal) {
return {
left: rtl ? undefined : `${pct}%`,
right: rtl ? `${pct}%` : undefined,
top: undefined,
bottom: undefined,
};
}
return { left: undefined, right: undefined, top: undefined, bottom: `${pct}%` };
});
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value) return;
const horizontal = ctx.orientation.value === 'horizontal';
const rtl = ctx.direction.value === 'rtl';
const step = ctx.step.value;
const big = step * ctx.largeStep.value;
const unit = event.shiftKey ? big : step;
const current = ctx.alpha.value;
let delta: number;
switch (event.key) {
case 'ArrowRight':
delta = horizontal ? (rtl ? -unit : unit) : 0;
break;
case 'ArrowLeft':
delta = horizontal ? (rtl ? unit : -unit) : 0;
break;
case 'ArrowUp':
delta = horizontal ? 0 : unit;
break;
case 'ArrowDown':
delta = horizontal ? 0 : -unit;
break;
case 'PageUp':
delta = big;
break;
case 'PageDown':
delta = -big;
break;
case 'Home':
event.preventDefault();
ctx.setAlpha(0);
return;
case 'End':
event.preventDefault();
ctx.setAlpha(1);
return;
default:
return;
}
if (delta === 0) return;
event.preventDefault();
ctx.setAlpha(current + delta);
}
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="slider"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="accessibleLabel"
:aria-labelledby="!accessibleLabel ? ctx.labelId.value : undefined"
:aria-valuemin="0"
:aria-valuemax="1"
:aria-valuenow="alpha"
:aria-valuetext="valueText"
:aria-orientation="ctx.orientation.value"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:style="positionStyle"
@keydown="onKeyDown"
>
<slot :alpha="alpha" :percent="percent" />
</Primitive>
</template>
@@ -0,0 +1,110 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { HSVA } from '../../../internal/color';
import { AlphaSliderRoot, AlphaSliderThumb } from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }));
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
function mountAlpha(opts: Partial<{ defaultValue: HSVA; step: number; disabled: boolean }> = {}) {
const model = ref<HSVA | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(AlphaSliderRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null | undefined) => { model.value = v ?? undefined; },
...opts,
}, { default: () => h(AlphaSliderThumb) }),
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model };
}
describe('AlphaSlider', () => {
it('thumb is role=slider with alpha aria-value*', async () => {
mountAlpha({ defaultValue: { h: 0, s: 1, v: 1, a: 0.5 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb).toBeTruthy();
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
expect(thumb.getAttribute('aria-valuemax')).toBe('1');
expect(thumb.getAttribute('aria-valuenow')).toBe('0.5');
expect(thumb.getAttribute('aria-valuetext')).toBe('50%');
expect(thumb.getAttribute('aria-label')).toBe('Alpha');
expect(thumb.tabIndex).toBe(0);
});
it('ArrowRight / ArrowLeft step the alpha', async () => {
const { model } = mountAlpha({ defaultValue: { h: 0, s: 1, v: 1, a: 0.5 }, step: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(round(model.value!.a)).toBe(0.6);
keydown(thumb, 'ArrowLeft');
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(round(model.value!.a)).toBe(0.4);
});
it('Home / End clamp to 0 / 1', async () => {
const { model } = mountAlpha({ defaultValue: { h: 0, s: 1, v: 1, a: 0.5 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'Home');
await nextTick();
expect(model.value!.a).toBe(0);
keydown(thumb, 'End');
await nextTick();
expect(model.value!.a).toBe(1);
});
it('clamps within [0, 1]', async () => {
const { model } = mountAlpha({ defaultValue: { h: 0, s: 1, v: 1, a: 0.95 }, step: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value!.a).toBe(1);
});
it('preserves hue / saturation / value while only changing alpha', async () => {
const { model } = mountAlpha({ defaultValue: { h: 100, s: 0.4, v: 0.6, a: 0.5 }, step: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value!.h).toBe(100);
expect(model.value!.s).toBe(0.4);
expect(model.value!.v).toBe(0.6);
expect(round(model.value!.a)).toBe(0.6);
});
it('disabled: tabindex=-1 and keys do nothing', async () => {
const { model } = mountAlpha({ defaultValue: { h: 0, s: 1, v: 1, a: 0.5 }, disabled: true });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.tabIndex).toBe(-1);
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toBeUndefined();
});
});
@@ -0,0 +1,36 @@
import type { Ref } from 'vue';
import type { HSVA } from '../../internal/color';
import { useContextFactory } from '@robonen/vue';
export type AlphaSliderOrientation = 'horizontal' | 'vertical';
export type AlphaSliderDirection = 'ltr' | 'rtl';
/**
* Context shared between `AlphaSliderRoot` and `AlphaSliderThumb`.
*
* Scalar props are exposed as plain `Ref<T>` — `AlphaSliderRoot` builds them
* with `toRef(() => prop)` (a reactive getter ref without an extra effect).
*/
export interface AlphaSliderContext {
/** The canonical colour the slider reads its alpha from. */
hsva: Ref<HSVA>;
/** Current alpha (`01`). */
alpha: Ref<number>;
/** Step granularity for keyboard nudges. */
step: Ref<number>;
/** Large-step multiplier (Page keys / Shift+Arrow). */
largeStep: Ref<number>;
orientation: Ref<AlphaSliderOrientation>;
direction: Ref<AlphaSliderDirection>;
disabled: Ref<boolean>;
/** Accessible name id contributed by a `ColorFieldLabel`, if present. */
labelId: Ref<string | undefined>;
trackRef: Ref<HTMLElement | null>;
/** Set the alpha (`01`); clamped by the root. */
setAlpha: (alpha: number) => void;
}
const ctx = useContextFactory<AlphaSliderContext>('AlphaSliderContext');
export const provideAlphaSliderContext = ctx.provide;
export const useAlphaSliderContext = ctx.inject;
@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { HSVA } from '@robonen/primitives';
import { AlphaSliderRoot, AlphaSliderThumb, hsvToRgb } from '@robonen/primitives';
import { computed, ref } from 'vue';
const color = ref<HSVA>({ h: 145, s: 0.85, v: 0.8, a: 0.6 });
const rgb = computed(() => hsvToRgb(color.value));
// Opaque end of the gradient (alpha → 1) and the live colour at the current alpha.
const opaque = computed(() => `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`);
const transparent = computed(() => `rgba(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b}, 0)`);
const live = computed(() => `rgba(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b}, ${color.value.a})`);
const gradient = computed(() => `linear-gradient(to right, ${transparent.value}, ${opaque.value})`);
// Reusable checkerboard background for the rail and the preview chip.
const CHECKER = {
backgroundImage:
'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
backgroundSize: '10px 10px',
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0',
backgroundColor: '#fff',
} as const;
</script>
<template>
<div class="demo-card flex w-full max-w-sm flex-col gap-5 p-6 text-fg">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Alpha</span>
<span class="font-mono text-fg-muted">{{ Math.round(color.a * 100) }}%</span>
</div>
<!-- The alpha rail: checkerboard underlay, gradient overlay; the root span IS the track -->
<AlphaSliderRoot
v-model="color"
class="relative block h-4 w-full touch-none select-none rounded-full border border-border shadow-(--shadow-card)"
:style="CHECKER"
>
<div
class="pointer-events-none absolute inset-0 rounded-full"
:style="{ backgroundImage: gradient }"
/>
<AlphaSliderThumb
aria-label="Alpha"
class="absolute top-1/2 z-10 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
:style="{ backgroundColor: live }"
/>
</AlphaSliderRoot>
<div class="flex items-center gap-3 rounded-card bg-bg-inset p-3">
<span
class="size-9 shrink-0 overflow-hidden rounded-lg border border-border-strong"
:style="CHECKER"
>
<span class="block size-full" :style="{ backgroundColor: live }" />
</span>
<div class="flex flex-col text-sm leading-tight">
<span class="font-mono text-fg">{{ live }}</span>
<span class="font-mono text-xs text-fg-subtle">opacity {{ color.a.toFixed(2) }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,11 @@
export { default as AlphaSliderRoot } from './AlphaSliderRoot.vue';
export { default as AlphaSliderThumb } from './AlphaSliderThumb.vue';
export type { AlphaSliderRootProps } from './AlphaSliderRoot.vue';
export type { AlphaSliderThumbProps } from './AlphaSliderThumb.vue';
export {
type AlphaSliderContext,
type AlphaSliderDirection,
type AlphaSliderOrientation,
provideAlphaSliderContext,
useAlphaSliderContext,
} from './context';
@@ -0,0 +1,156 @@
<script lang="ts">
import type { ColorAreaDirection } from './context';
import type { HSVA } from '../../internal/color';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The 2D saturation/value square of a colour picker. The x-axis maps saturation
* (`0` at the left → `1` at the right) and the y-axis maps brightness/value
* (`1` at the top → `0` at the bottom). It works standalone — owning its own
* `HSVA` via `v-model` / `defaultValue` — or, nested inside a `ColorFieldRoot`,
* reads and writes that shared colour so the whole picker cluster stays in sync.
* A pointer press anywhere in the area sets saturation and brightness at once;
* the `--color-area-hue` CSS variable is exposed so the consumer can paint the
* full-saturation / full-value hue background. Provides context to
* `ColorAreaThumb`. Reach for it as the main square of a colour picker.
*/
export interface ColorAreaRootProps extends PrimitiveProps {
/** Uncontrolled initial colour. @default { h: 0, s: 1, v: 1, a: 1 } */
defaultValue?: HSVA;
/** Keyboard step for saturation/value nudges (`01`). @default 0.01 */
step?: number;
/** Large keyboard step (Shift+Arrow / Page keys, `01`). @default 0.1 */
largeStep?: number;
/** Writing direction (inherited from `ConfigProvider` when omitted). */
dir?: ColorAreaDirection;
/** Disable interaction. @default false */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { clampChannel, hsvToRgb } from '../../internal/color';
import { Primitive } from '../../internal/primitive';
import { provideColorAreaContext } from './context';
import { useColorState } from '../color-field/useColorState';
import { useDirection } from '../../utilities/config-provider';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useForwardExpose } from '@robonen/vue';
const {
defaultValue,
step = 0.01,
largeStep = 0.1,
dir,
disabled = false,
as = 'div',
} = defineProps<ColorAreaRootProps>();
const direction = useDirection(() => dir);
const model = defineModel<HSVA | null>();
// shallowRef: the HSVA object is always replaced wholesale (setters build a fresh
// `{ ...cur }`), never mutated channel-by-channel, so deep proxying {h,s,v,a} only
// adds per-pointer-move proxy-get/track cost. Identity replacement still triggers.
const standalone = shallowRef<HSVA>(model.value ?? defaultValue ?? { h: 0, s: 1, v: 1, a: 1 });
const standaloneState = computed<HSVA>({
get: () => standalone.value,
set: (v) => {
standalone.value = v;
model.value = v;
},
});
const colorState = useColorState(standaloneState, () => disabled);
const saturation = computed(() => colorState.hsva.value.s);
const value = computed(() => colorState.hsva.value.v);
const hue = computed(() => colorState.hsva.value.h);
// Background hue colour at full saturation & value (consumer paints the
// gradients over this via the exposed CSS variable / slot prop).
const hueColor = computed(() => {
const { r, g, b } = hsvToRgb({ h: hue.value, s: 1, v: 1 });
return `rgb(${r}, ${g}, ${b})`;
});
const trackRef = shallowRef<HTMLElement | null>(null);
function setSaturation(next: number): void {
if (colorState.disabled.value) return;
colorState.setSaturation(clampChannel(next, 1));
}
function setValue(next: number): void {
if (colorState.disabled.value) return;
colorState.setValue(clampChannel(next, 1));
}
// Rect cached for the duration of a gesture (snapshotted in `onStart`): the track
// box cannot change mid-drag, so re-reading getBoundingClientRect() every onMove
// frame is a needless forced reflow. A live read is the fallback.
let gestureRect: DOMRect | undefined;
function setFromPointer(clientCoord: { x: number; y: number }, rect?: DOMRect): void {
const r = rect ?? trackRef.value?.getBoundingClientRect();
if (!r || r.width === 0 || r.height === 0) return;
let sx = (clientCoord.x - r.left) / r.width;
// RTL flips the saturation axis.
if (direction.value === 'rtl') sx = 1 - sx;
// y-axis: top = full brightness (1), bottom = 0.
const vy = 1 - (clientCoord.y - r.top) / r.height;
colorState.setSaturationValue(clampChannel(sx, 1), clampChannel(vy, 1));
}
usePointerDrag(trackRef, {
axis: 'both',
threshold: 0,
disabled: () => colorState.disabled.value,
onStart: (state) => {
gestureRect = trackRef.value?.getBoundingClientRect();
setFromPointer({ x: state.point.x, y: state.point.y }, gestureRect);
},
onMove: (state) => {
setFromPointer({ x: state.point.x, y: state.point.y }, gestureRect);
},
onEnd: () => {
gestureRect = undefined;
},
});
provideColorAreaContext({
hsva: colorState.hsva,
saturation,
value,
step: toRef(() => step),
largeStep: toRef(() => largeStep),
direction,
disabled: colorState.disabled,
labelId: colorState.labelId,
trackRef,
setSaturation,
setValue,
});
defineExpose({ saturation, value, hue });
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (node) => {
trackRef.value = node ?? null;
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:aria-disabled="colorState.disabled.value || undefined"
:data-disabled="colorState.disabled.value ? '' : undefined"
:style="{ '--color-area-hue': hueColor }"
>
<slot :saturation="saturation" :value="value" :hue="hue" :hue-color="hueColor" />
</Primitive>
</template>
@@ -0,0 +1,128 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The 2D handle of a `ColorAreaRoot`. A single thumb carries two axes
* (saturation on x, brightness on y) which one `aria-valuenow` cannot express,
* so it is exposed as `role="slider"` with `aria-valuenow` set to the primary
* axis (brightness) and `aria-valuetext` conveying **both** channels (e.g.
* `"Saturation 60%, Brightness 80%"`). Keyboard: Left/Right nudge saturation by
* `step` (direction-aware), Up/Down nudge brightness (Up = brighter),
* Shift+Arrow uses the large step, Home/End set saturation to `0`/`1`, and Page
* Up/Down change brightness by the large step. It positions itself at
* `left = saturation`, `top = 1 brightness`. Pass `valueText` to override the
* announced text and `aria-label` for the accessible name (default
* `"Saturation and brightness"`).
*/
export interface ColorAreaThumbProps extends PrimitiveProps {
/**
* Override the `aria-valuetext` describing both axes. Receives saturation and
* value/brightness as `01` floats.
*/
valueText?: (saturation: number, value: number) => string;
}
</script>
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useColorAreaContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { valueText, as = 'span' } = defineProps<ColorAreaThumbProps>();
const ctx = useColorAreaContext();
const attrs = useAttrs();
const saturation = computed(() => ctx.saturation.value);
const value = computed(() => ctx.value.value);
// `left = s`, `top = 1 v` (top of the area is full brightness).
const positionStyle = computed(() => ({
left: `${saturation.value * 100}%`,
top: `${(1 - value.value) * 100}%`,
}));
const accessibleLabel = computed<string | undefined>(() => {
const hasLabel = attrs['aria-label'] !== undefined && attrs['aria-label'] !== null;
const hasLabelledBy = attrs['aria-labelledby'] !== undefined && attrs['aria-labelledby'] !== null;
if (hasLabel || hasLabelledBy) return undefined;
return ctx.labelId.value ? undefined : 'Saturation and brightness';
});
// One thumb, two axes: announce BOTH via aria-valuetext.
const ariaValueText = computed(() => {
if (attrs['aria-valuetext'] !== undefined && attrs['aria-valuetext'] !== null) return undefined;
if (valueText) return valueText(saturation.value, value.value);
return `Saturation ${Math.round(saturation.value * 100)}%, Brightness ${Math.round(value.value * 100)}%`;
});
// `aria-valuenow` carries the primary axis (brightness).
const ariaValueNow = computed(() => Math.round(value.value * 100));
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value) return;
const rtl = ctx.direction.value === 'rtl';
const step = ctx.step.value;
const big = ctx.largeStep.value;
const unit = event.shiftKey ? big : step;
switch (event.key) {
case 'ArrowRight':
event.preventDefault();
ctx.setSaturation(saturation.value + (rtl ? -unit : unit));
return;
case 'ArrowLeft':
event.preventDefault();
ctx.setSaturation(saturation.value + (rtl ? unit : -unit));
return;
case 'ArrowUp':
event.preventDefault();
ctx.setValue(value.value + unit);
return;
case 'ArrowDown':
event.preventDefault();
ctx.setValue(value.value - unit);
return;
case 'Home':
event.preventDefault();
ctx.setSaturation(0);
return;
case 'End':
event.preventDefault();
ctx.setSaturation(1);
return;
case 'PageUp':
event.preventDefault();
ctx.setValue(value.value + big);
return;
case 'PageDown':
event.preventDefault();
ctx.setValue(value.value - big);
break;
default:
break;
}
}
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="slider"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="accessibleLabel"
:aria-labelledby="!accessibleLabel ? ctx.labelId.value : undefined"
:aria-valuemin="0"
:aria-valuemax="100"
:aria-valuenow="ariaValueNow"
:aria-valuetext="ariaValueText"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:style="positionStyle"
@keydown="onKeyDown"
>
<slot :saturation="saturation" :value="value" />
</Primitive>
</template>
@@ -0,0 +1,168 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { HSVA } from '../../../internal/color';
import { ColorAreaRoot, ColorAreaThumb } from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }));
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
function mountArea(opts: Partial<{ defaultValue: HSVA; step: number; largeStep: number; disabled: boolean }> = {}, thumbProps: Record<string, unknown> = {}) {
const model = ref<HSVA | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ColorAreaRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null | undefined) => { model.value = v ?? undefined; },
...opts,
}, { default: () => h(ColorAreaThumb, thumbProps) }),
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model };
}
describe('ColorArea', () => {
it('thumb is role=slider with aria-valuetext conveying both axes', async () => {
mountArea({ defaultValue: { h: 0, s: 0.6, v: 0.8, a: 1 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb).toBeTruthy();
// aria-valuenow carries the primary axis (brightness).
expect(thumb.getAttribute('aria-valuenow')).toBe('80');
expect(thumb.getAttribute('aria-valuetext')).toBe('Saturation 60%, Brightness 80%');
expect(thumb.getAttribute('aria-label')).toBe('Saturation and brightness');
expect(thumb.tabIndex).toBe(0);
});
it('positions the thumb at left=s, top=1-v', async () => {
mountArea({ defaultValue: { h: 0, s: 0.25, v: 0.75, a: 1 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.style.left).toBe('25%');
expect(thumb.style.top).toBe('25%');
});
it('ArrowRight / ArrowLeft change saturation by step', async () => {
const { model } = mountArea({ defaultValue: { h: 0, s: 0.5, v: 0.5, a: 1 }, step: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(round(model.value!.s)).toBe(0.6);
keydown(thumb, 'ArrowLeft');
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(round(model.value!.s)).toBe(0.4);
});
it('ArrowUp increases brightness, ArrowDown decreases', async () => {
const { model } = mountArea({ defaultValue: { h: 0, s: 0.5, v: 0.5, a: 1 }, step: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowUp');
await nextTick();
expect(round(model.value!.v)).toBe(0.6);
keydown(thumb, 'ArrowDown');
keydown(thumb, 'ArrowDown');
await nextTick();
expect(round(model.value!.v)).toBe(0.4);
});
it('Shift+Arrow uses the large step', async () => {
const { model } = mountArea({ defaultValue: { h: 0, s: 0.5, v: 0.5, a: 1 }, step: 0.01, largeStep: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight', { shiftKey: true });
await nextTick();
expect(round(model.value!.s)).toBe(0.6);
});
it('Home / End set saturation to 0 / 1', async () => {
const { model } = mountArea({ defaultValue: { h: 30, s: 0.5, v: 0.5, a: 1 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'Home');
await nextTick();
expect(model.value!.s).toBe(0);
keydown(thumb, 'End');
await nextTick();
expect(model.value!.s).toBe(1);
});
it('PageUp / PageDown change brightness by the large step', async () => {
const { model } = mountArea({ defaultValue: { h: 0, s: 0.5, v: 0.5, a: 1 }, largeStep: 0.2 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'PageUp');
await nextTick();
expect(round(model.value!.v)).toBe(0.7);
keydown(thumb, 'PageDown');
keydown(thumb, 'PageDown');
await nextTick();
expect(round(model.value!.v)).toBe(0.3);
});
it('clamps saturation / value within [0, 1]', async () => {
const { model } = mountArea({ defaultValue: { h: 0, s: 0.95, v: 0.05, a: 1 }, step: 0.1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
keydown(thumb, 'ArrowDown');
await nextTick();
expect(model.value!.s).toBe(1);
expect(model.value!.v).toBe(0);
});
it('preserve-hue at s=0: dragging into and back out keeps the hue', async () => {
const { model } = mountArea({ defaultValue: { h: 200, s: 0.5, v: 0.5, a: 1 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
// Collapse saturation to 0 (grey — hue becomes ambiguous).
keydown(thumb, 'Home');
await nextTick();
expect(model.value!.s).toBe(0);
expect(model.value!.h).toBe(200);
// Restore saturation; the original hue must come back.
keydown(thumb, 'End');
await nextTick();
expect(model.value!.s).toBe(1);
expect(model.value!.h).toBe(200);
});
it('valueText prop overrides the announced text', async () => {
mountArea(
{ defaultValue: { h: 0, s: 0.5, v: 0.5, a: 1 } },
{ valueText: (s: number, v: number) => `S${Math.round(s * 100)}/V${Math.round(v * 100)}` },
);
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-valuetext')).toBe('S50/V50');
});
it('disabled: tabindex=-1 and keys do nothing', async () => {
const { model } = mountArea({ defaultValue: { h: 0, s: 0.5, v: 0.5, a: 1 }, disabled: true });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.tabIndex).toBe(-1);
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import type { Ref } from 'vue';
import type { HSVA } from '../../internal/color';
import { useContextFactory } from '@robonen/vue';
export type ColorAreaDirection = 'ltr' | 'rtl';
/**
* Context shared between `ColorAreaRoot` and `ColorAreaThumb`.
*
* Scalar props are exposed as plain `Ref<T>` — `ColorAreaRoot` builds them with
* `toRef(() => prop)` (a reactive getter ref without an extra effect).
*/
export interface ColorAreaContext {
/** The canonical colour the area reads saturation/value from. */
hsva: Ref<HSVA>;
/** Current saturation (`01`, x-axis). */
saturation: Ref<number>;
/** Current value/brightness (`01`, y-axis). */
value: Ref<number>;
/** Step granularity for keyboard nudges. */
step: Ref<number>;
/** Large-step granularity (Shift+Arrow / Page keys). */
largeStep: Ref<number>;
direction: Ref<ColorAreaDirection>;
disabled: Ref<boolean>;
/** Accessible name id contributed by a `ColorFieldLabel`, if present. */
labelId: Ref<string | undefined>;
trackRef: Ref<HTMLElement | null>;
/** Set the saturation channel (`01`), preserving hue and value. */
setSaturation: (saturation: number) => void;
/** Set the value/brightness channel (`01`), preserving hue and saturation. */
setValue: (value: number) => void;
}
const ctx = useContextFactory<ColorAreaContext>('ColorAreaContext');
export const provideColorAreaContext = ctx.provide;
export const useColorAreaContext = ctx.inject;
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { HSVA } from '@robonen/primitives';
import { ColorAreaRoot, ColorAreaThumb, hsvToRgb } from '@robonen/primitives';
import { computed, ref } from 'vue';
const color = ref<HSVA>({ h: 265, s: 0.72, v: 0.86, a: 1 });
const rgb = computed(() => hsvToRgb(color.value));
const cssColor = computed(() => `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`);
</script>
<template>
<div class="demo-card flex w-full max-w-sm flex-col gap-5 p-6 text-fg">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Saturation / Brightness</span>
<span class="font-mono text-fg-muted">
S {{ Math.round(color.s * 100) }}% · V {{ Math.round(color.v * 100) }}%
</span>
</div>
<!-- The SV square: hue base, whitetransparent left-right, transparentblack top-bottom -->
<ColorAreaRoot
v-model="color"
class="relative aspect-[4/3] w-full touch-none select-none overflow-hidden rounded-card border border-border shadow-(--shadow-card)"
:style="{ backgroundColor: 'var(--color-area-hue)' }"
>
<!-- white (left) transparent (right) -->
<div
class="pointer-events-none absolute inset-0"
style="background: linear-gradient(to right, #fff, transparent)"
/>
<!-- transparent (top) black (bottom) -->
<div
class="pointer-events-none absolute inset-0"
style="background: linear-gradient(to top, #000, transparent)"
/>
<ColorAreaThumb
aria-label="Saturation and brightness"
class="absolute z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
:style="{ backgroundColor: cssColor }"
/>
</ColorAreaRoot>
<div class="flex items-center gap-3 rounded-card bg-bg-inset p-3">
<span
class="size-9 shrink-0 rounded-lg border border-border-strong"
:style="{ backgroundColor: cssColor }"
/>
<div class="flex flex-col text-sm leading-tight">
<span class="font-mono text-fg">{{ cssColor }}</span>
<span class="font-mono text-xs text-fg-subtle">
hsv({{ Math.round(color.h) }}, {{ Math.round(color.s * 100) }}%, {{ Math.round(color.v * 100) }}%)
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,10 @@
export { default as ColorAreaRoot } from './ColorAreaRoot.vue';
export { default as ColorAreaThumb } from './ColorAreaThumb.vue';
export type { ColorAreaRootProps } from './ColorAreaRoot.vue';
export type { ColorAreaThumbProps } from './ColorAreaThumb.vue';
export {
type ColorAreaContext,
type ColorAreaDirection,
provideColorAreaContext,
useColorAreaContext,
} from './context';
@@ -0,0 +1,37 @@
<script lang="ts">
/**
* A visually-hidden native `<input>` carrying the surrounding `ColorFieldRoot`'s
* formatted colour under `name`, so the colour participates in native form
* submission and constraint validation. Renders nothing unless `name` is set.
* Place it inside a `ColorFieldRoot`.
*/
export interface ColorFieldHiddenInputProps {
/** Form field `name`. The input is only rendered when this is set. */
name?: string;
/** Mark the hidden input as required for native validation. */
required?: boolean;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { formatHsva } from '../../internal/color';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
import { useColorFieldContext } from './context';
const { name, required } = defineProps<ColorFieldHiddenInputProps>();
const ctx = useColorFieldContext();
// Serialize as `#rrggbbaa` so alpha survives the round-trip through the form.
const value = computed(() => formatHsva(ctx.hsva.value, 'hex8'));
</script>
<template>
<VisuallyHiddenInput
v-if="name"
:name="name"
:value="value"
:required="required"
:disabled="ctx.disabled.value"
/>
</template>
@@ -0,0 +1,83 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A native text `<input>` bound to the formatted colour string of the
* surrounding `ColorFieldRoot`. As the user types it parses the value via
* `parseColor`; a valid colour updates the canonical state, an invalid one is
* left uncommitted and `aria-invalid` flips to `true` so the field reflects the
* live parse state. While the input is focused it shows the user's in-progress
* text; on blur it re-syncs to the canonical formatted value. Place it inside a
* `ColorFieldRoot`.
*/
export interface ColorFieldInputProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { formatHsva, parseColor } from '../../internal/color';
import { Primitive } from '../../internal/primitive';
import { useColorFieldContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'input' } = defineProps<ColorFieldInputProps>();
const ctx = useColorFieldContext();
// The canonical colour, formatted as a hex string for editing.
const canonical = computed(() => formatHsva(ctx.hsva.value, 'hex'));
// Local draft text: what the user sees/types. Mirrors `canonical` unless the
// user is mid-edit with text that does not (yet) parse.
const draft = ref(canonical.value);
const focused = ref(false);
// When the canonical colour changes externally (e.g. dragging a slider), reflect
// it into the input — but only while the user is not actively editing.
watch(canonical, (next) => {
if (!focused.value) draft.value = next;
});
const parsed = computed(() => parseColor(draft.value));
const invalid = computed(() => parsed.value === null);
function onInput(event: Event): void {
if (ctx.disabled.value) return;
draft.value = (event.target as HTMLInputElement).value;
const result = parseColor(draft.value);
if (result) {
// Drive the canonical colour through the shared setters so preserve-hue
// policy still applies (set all channels in one shot via SV + hue + alpha).
ctx.setHue(result.h);
ctx.setSaturationValue(result.s, result.v);
ctx.setAlpha(result.a);
}
}
function onFocus(): void {
focused.value = true;
}
function onBlur(): void {
focused.value = false;
// Snap back to the canonical formatted value, discarding invalid drafts.
draft.value = canonical.value;
}
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:value="draft"
:disabled="ctx.disabled.value || undefined"
:aria-invalid="invalid || undefined"
:aria-labelledby="ctx.labelId.value"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-invalid="invalid ? '' : undefined"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
/>
</template>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The accessible name for the whole colour-picker cluster. It renders a
* `<label>` (or any element via `as`) with a generated `id` that it registers
* into the `ColorFieldRoot` context, so the otherwise-orphaned sub-pickers
* (`ColorArea`, `HueSlider`, `AlphaSlider`) and `ColorFieldInput` can reference
* it via `aria-labelledby`. This closes the "four orphaned controls" a11y gap.
* Place it inside a `ColorFieldRoot`.
*/
export interface ColorFieldLabelProps extends PrimitiveProps {
/** Override the generated label id. */
id?: string;
}
</script>
<script setup lang="ts">
import { onScopeDispose, watchEffect } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useColorFieldContext } from './context';
import { useId } from '../../utilities/config-provider';
import { useForwardExpose } from '@robonen/vue';
const { id, as = 'label' } = defineProps<ColorFieldLabelProps>();
const ctx = useColorFieldContext();
const generatedId = useId(undefined, 'color-field-label');
// An explicit `id` prop wins over the generated one.
const labelId = () => id ?? generatedId.value;
// Publish our id into the shared context so the sub-pickers can reference it.
watchEffect(() => {
ctx.labelId.value = labelId();
});
onScopeDispose(() => {
ctx.labelId.value = undefined;
});
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:id="labelId()"
:data-disabled="ctx.disabled.value ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,181 @@
<script lang="ts">
import type { ColorFormat } from './context';
import type { HSV, HSVA, RGB } from '../../internal/color';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The composite root of the colour-picker cluster. It owns the canonical
* {@link HSVA} colour (controlled via `v-model`, uncontrolled via
* `defaultValue`) and provides a shared context that `ColorArea`, `HueSlider`,
* and `AlphaSlider` read and write into, keeping every control in sync without
* round-tripping through RGB. The model accepts either an `HSVA` object or any
* CSS colour string (`#rrggbb`, `rgb()/rgba()`, `hsl()/hsla()`) via `parseColor`
* and emits in the configured `format`. Compose it with `ColorFieldSwatch`,
* `ColorFieldInput`, `ColorFieldLabel`, and `ColorFieldHiddenInput`. Reach for
* it whenever you need a full, accessible colour picker tied to a form value.
*/
export interface ColorFieldRootProps extends PrimitiveProps {
/**
* Uncontrolled initial value (`HSVA` object or CSS colour string).
* @default '#ff0000'
*/
defaultValue?: HSVA | string;
/**
* Serialization format used for `update:modelValue`, the swatch label, the
* input string, and the hidden form input.
* @default 'hex'
*/
format?: ColorFormat;
/** Disable all interaction across the cluster. @default false */
disabled?: boolean;
/** Hidden form input `name` (enables native form submission). */
name?: string;
}
</script>
<script setup lang="ts">
import { computed, ref, shallowRef, toRef, watch } from 'vue';
import { formatHsva, hsvToRgb, hsvaToCss, parseColor } from '../../internal/color';
import { VisuallyHiddenInput } from '../../utilities/visually-hidden';
import { Primitive } from '../../internal/primitive';
import { provideColorFieldContext } from './context';
import { useHsvaSetters } from './useColorState';
import { useForwardExpose } from '@robonen/vue';
const {
defaultValue = '#ff0000',
format = 'hex',
disabled = false,
name,
as = 'div',
} = defineProps<ColorFieldRootProps>();
// `defineModel` drives both controlled (`v-model`) and uncontrolled modes. The
// raw model may be an HSVA object OR a CSS string OR null; the canonical state
// below normalizes it to HSVA once. We do NOT bind `defineModel` directly to
// the canonical ref because we emit in the configured `format`, not the raw in.
const model = defineModel<HSVA | string | null>();
/** Normalize any accepted input (object | string) to canonical HSVA. */
function toHsva(input: HSVA | string | null | undefined): HSVA | null {
if (input === null || input === undefined) return null;
if (typeof input === 'string') return parseColor(input);
return { ...input };
}
const seed = toHsva(model.value) ?? toHsva(defaultValue) ?? { h: 0, s: 1, v: 1, a: 1 };
// The canonical, reactive HSVA. Every sub-picker replaces this wholesale through
// the shared setters (preserve-hue policy lives in `useHsvaSetters`); it is never
// mutated channel-by-channel, so shallowRef triggers identically without proxying.
const hsva = shallowRef<HSVA>(seed);
const setters = useHsvaSetters(hsva);
/** Serialize the current canonical colour in the configured format. */
function serialize(c: HSVA): HSVA | string {
if (format === 'hsva') return { ...c };
return formatHsva(c, format);
}
// Push the canonical colour out through the model in the configured format.
// Guarded against the echo where our own emit comes back in as `model.value`.
let writingOut = false;
function pushOut(c: HSVA): void {
writingOut = true;
model.value = serialize(c);
writingOut = false;
}
// `hsva` (shallowRef) is replaced wholesale on every change, so watching its
// identity already fires on each update — no deep {h,s,v,a} traversal per frame.
watch(hsva, c => pushOut(c));
// Uncontrolled adoption: when no controlled `modelValue` was supplied, surface
// the seeded (default) colour to the model once on mount so the consumer's
// `v-model` reflects the initial value in the configured format.
if (model.value === null || model.value === undefined) pushOut(hsva.value);
// Adopt externally driven model changes (controlled mode). Ignore the echo from
// our own outward write and any unparseable strings.
watch(model, (next) => {
if (writingOut) return;
const parsed = toHsva(next);
if (!parsed) return;
const cur = hsva.value;
if (parsed.h === cur.h && parsed.s === cur.s && parsed.v === cur.v && parsed.a === cur.a) return;
hsva.value = parsed;
});
// Derived, read-only views for consumers (`defineExpose`) and parts.
const rgb = computed<RGB>(() => hsvToRgb(hsva.value));
const hsv = computed<HSV>(() => ({ h: hsva.value.h, s: hsva.value.s, v: hsva.value.v }));
const cssColor = computed(() => hsvaToCss(hsva.value));
const hex = computed(() => formatHsva(hsva.value, 'hex'));
const hex8 = computed(() => formatHsva(hsva.value, 'hex8'));
const rgbaString = computed(() => formatHsva(hsva.value, 'rgba'));
const hslaString = computed(() => formatHsva(hsva.value, 'hsla'));
/** The value serialized in the configured `format` (used by parts). */
const formatted = computed(() => formatHsva(hsva.value, format === 'hsva' ? 'rgba' : format));
const labelId = ref<string | undefined>(undefined);
provideColorFieldContext({
hsva,
setHue: setters.setHue,
setSaturation: setters.setSaturation,
setValue: setters.setValue,
setAlpha: setters.setAlpha,
setSaturationValue: setters.setSaturationValue,
disabled: toRef(() => disabled),
labelId,
});
defineExpose({
/** The canonical HSVA colour (read-only view). */
hsva,
/** The colour as RGB. */
rgb,
/** The colour as HSV (no alpha). */
hsv,
/** The colour as a CSS `rgba()` string. */
cssColor,
/** The colour as `#rrggbb`. */
hex,
/** The colour as `#rrggbbaa`. */
hex8,
/** The colour as `rgba()`. */
rgbaString,
/** The colour as `hsla()`. */
hslaString,
/** The value serialized in the configured `format`. */
formatted,
});
// `useForwardExpose` runs AFTER `defineExpose` so it merges the prior expose
// bindings (plus props + `$el`) instead of clobbering them.
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:aria-disabled="disabled || undefined"
:data-disabled="disabled ? '' : undefined"
>
<slot
:hsva="hsva"
:rgb="rgb"
:hex="hex"
:css-color="cssColor"
:formatted="formatted"
/>
<VisuallyHiddenInput
v-if="name"
:name="name"
:value="hex8"
:disabled="disabled"
/>
</Primitive>
</template>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A presentational swatch showing the current colour of the surrounding
* `ColorFieldRoot`. It paints its `background` from the canonical colour and,
* unless `decorative`, exposes itself as `role="img"` with an `aria-label`
* carrying the formatted colour string so the swatch is announced to assistive
* technology. Place it inside a `ColorFieldRoot`.
*/
export interface ColorFieldSwatchProps extends PrimitiveProps {
/**
* When `true`, the swatch is hidden from assistive technology
* (`aria-hidden`) instead of being announced as an image.
* @default false
*/
decorative?: boolean;
/** Override the accessible label (defaults to the formatted colour). */
label?: string;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { formatHsva, hsvaToCss } from '../../internal/color';
import { Primitive } from '../../internal/primitive';
import { useColorFieldContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { decorative = false, label, as = 'span' } = defineProps<ColorFieldSwatchProps>();
const ctx = useColorFieldContext();
const background = computed(() => hsvaToCss(ctx.hsva.value));
const accessibleLabel = computed(() => label ?? formatHsva(ctx.hsva.value, 'hex8'));
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:role="decorative ? undefined : 'img'"
:aria-hidden="decorative ? '' : undefined"
:aria-label="decorative ? undefined : accessibleLabel"
:data-disabled="ctx.disabled.value ? '' : undefined"
:style="{ background, backgroundColor: background }"
>
<slot :background="background" :label="accessibleLabel" />
</Primitive>
</template>
@@ -0,0 +1,229 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { HSVA } from '../../../internal/color';
import { hsvToRgb, parseColor } from '../../../internal/color';
import { AlphaSliderRoot, AlphaSliderThumb } from '../../alpha-slider';
import { ColorAreaRoot, ColorAreaThumb } from '../../color-area';
import { HueSliderRoot, HueSliderThumb } from '../../hue-slider';
import {
ColorFieldHiddenInput,
ColorFieldInput,
ColorFieldLabel,
ColorFieldRoot,
ColorFieldSwatch,
} from '../index';
import type { ColorFormat } from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }));
}
function typeInput(el: HTMLInputElement, value: string): void {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
describe('ColorField — value & format', () => {
it('parses a hex string default into HSVA and emits the configured format', async () => {
const model = ref<HSVA | string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, {
modelValue: model.value,
defaultValue: '#00ff00',
format: 'rgb',
'onUpdate:modelValue': (v: HSVA | string | null | undefined) => { model.value = v ?? undefined; },
}, { default: () => h(ColorFieldInput) }),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
// #00ff00 is pure green → hsv { h: 120, s: 1, v: 1 }.
const input = document.querySelector<HTMLInputElement>('input')!;
expect(input.value.toLowerCase()).toBe('#00ff00');
// First emit is the default colour serialized as rgb.
expect(model.value).toBe('rgb(0, 255, 0)');
});
it('ColorFieldInput parses a typed hex into the canonical HSVA', async () => {
const model = ref<HSVA | string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, {
modelValue: model.value,
defaultValue: '#000000',
format: 'hex',
'onUpdate:modelValue': (v: HSVA | string | null | undefined) => { model.value = v ?? undefined; },
}, { default: () => h(ColorFieldInput) }),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const input = document.querySelector<HTMLInputElement>('input')!;
typeInput(input, '#3366ff');
await nextTick();
const expected = parseColor('#3366ff')!;
// Emitted as hex.
expect((model.value as string).toLowerCase()).toBe('#3366ff');
// The canonical conversion round-trips back to the same rgb.
expect(hsvToRgb(expected)).toEqual({ r: 51, g: 102, b: 255 });
});
it('marks the input aria-invalid for an unparseable value and does not commit', async () => {
const model = ref<HSVA | string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, {
modelValue: model.value,
defaultValue: '#ff0000',
'onUpdate:modelValue': (v: HSVA | string | null | undefined) => { model.value = v ?? undefined; },
}, { default: () => h(ColorFieldInput) }),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const input = document.querySelector<HTMLInputElement>('input')!;
typeInput(input, 'not-a-color');
await nextTick();
expect(input.getAttribute('aria-invalid')).toBe('true');
// The canonical value stays the red default (#ff0000), so the emit never
// changed to the garbage text.
expect(model.value).toBe('#ff0000');
});
});
describe('ColorField — cluster sync', () => {
function mountCluster(format: ColorFormat = 'hex') {
const model = ref<HSVA | string | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, {
modelValue: model.value,
defaultValue: { h: 0, s: 1, v: 1, a: 1 },
format,
'onUpdate:modelValue': (v: HSVA | string | null | undefined) => { model.value = v ?? undefined; },
}, {
default: () => [
h(ColorAreaRoot, null, { default: () => h(ColorAreaThumb, { id: 'area-thumb' }) }),
h(HueSliderRoot, null, { default: () => h(HueSliderThumb, { id: 'hue-thumb' }) }),
h(AlphaSliderRoot, null, { default: () => h(AlphaSliderThumb, { id: 'alpha-thumb' }) }),
h(ColorFieldSwatch, { id: 'swatch' }),
],
}),
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model };
}
it('changing the hue slider updates the field value (cluster stays in sync)', async () => {
const { model } = mountCluster('hsva');
await nextTick();
const hueThumb = document.getElementById('hue-thumb')!;
// default hue is 0; nudge it up.
keydown(hueThumb, 'ArrowRight');
await nextTick();
expect((model.value as HSVA).h).toBe(1);
// The area thumb reads the SAME shared colour (s=1, v=1 unchanged).
const areaThumb = document.getElementById('area-thumb')!;
expect(areaThumb.getAttribute('aria-valuetext')).toBe('Saturation 100%, Brightness 100%');
});
it('all three pickers share one HSVA: area edits show on the hue/alpha thumbs', async () => {
const { model } = mountCluster('hsva');
await nextTick();
const alphaThumb = document.getElementById('alpha-thumb')!;
expect(alphaThumb.getAttribute('aria-valuenow')).toBe('1');
keydown(alphaThumb, 'Home');
await nextTick();
expect((model.value as HSVA).a).toBe(0);
// The swatch reflects the new alpha (rgba background with a=0).
const swatch = document.getElementById('swatch')!;
expect(swatch.style.background).toContain('rgba(255, 0, 0, 0)');
});
it('preserve-hue at s=0 across the cluster: hue is unchanged', async () => {
const { model } = mountCluster('hsva');
await nextTick();
const hueThumb = document.getElementById('hue-thumb')!;
// Move hue to a known non-zero value first.
for (let i = 0; i < 5; i++) keydown(hueThumb, 'ArrowRight');
await nextTick();
expect((model.value as HSVA).h).toBe(5);
const areaThumb = document.getElementById('area-thumb')!;
// Collapse saturation to 0 then back to 1.
keydown(areaThumb, 'Home');
await nextTick();
expect((model.value as HSVA).s).toBe(0);
expect((model.value as HSVA).h).toBe(5);
keydown(areaThumb, 'End');
await nextTick();
expect((model.value as HSVA).s).toBe(1);
expect((model.value as HSVA).h).toBe(5);
});
});
describe('ColorField — accessibility & form', () => {
it('ColorFieldLabel provides an id the sub-pickers reference via aria-labelledby', async () => {
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, { defaultValue: '#ff0000' }, {
default: () => [
h(ColorFieldLabel, { id: 'cf-label' }, { default: () => 'Brand colour' }),
h(HueSliderRoot, null, { default: () => h(HueSliderThumb, { id: 'hue-thumb' }) }),
],
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const hueThumb = document.getElementById('hue-thumb')!;
expect(hueThumb.getAttribute('aria-labelledby')).toBe('cf-label');
// The default 'Hue' label is suppressed in favour of the shared name.
expect(hueThumb.getAttribute('aria-label')).toBeNull();
});
it('ColorFieldHiddenInput carries the value when name is set', async () => {
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, { defaultValue: '#ff0000' }, {
default: () => h(ColorFieldHiddenInput, { name: 'brand' }),
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const input = document.querySelector<HTMLInputElement>('input[name="brand"]')!;
expect(input).toBeTruthy();
// Serialized as #rrggbbaa so alpha survives.
expect(input.value.toLowerCase()).toBe('#ff0000ff');
});
it('ColorFieldRoot name renders a hidden form input directly', async () => {
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, { defaultValue: '#00ff00', name: 'fav' }, {
default: () => h(ColorFieldSwatch),
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const input = document.querySelector<HTMLInputElement>('input[name="fav"]')!;
expect(input).toBeTruthy();
expect(input.value.toLowerCase()).toBe('#00ff00ff');
});
it('the swatch exposes role=img with a formatted aria-label', async () => {
const Harness = defineComponent({
setup: () => () => h(ColorFieldRoot, { defaultValue: '#ff0000' }, {
default: () => h(ColorFieldSwatch, { id: 'swatch' }),
}),
});
track(mount(Harness, { attachTo: document.body }));
await nextTick();
const swatch = document.getElementById('swatch')!;
expect(swatch.getAttribute('role')).toBe('img');
expect(swatch.getAttribute('aria-label')!.toLowerCase()).toBe('#ff0000ff');
});
});
@@ -0,0 +1,43 @@
import type { InjectionKey, Ref } from 'vue';
import type { HSVA } from '../../internal/color';
import { useContextFactory } from '@robonen/vue';
/** CSS color string formats `ColorFieldRoot` can serialize the value to. */
export type ColorFormat = 'hsva' | 'hex' | 'hex8' | 'rgb' | 'rgba' | 'hsl' | 'hsla';
/**
* Shared color state surfaced by `ColorFieldRoot` and consumed by the picker
* sub-components (`ColorArea`, `HueSlider`, `AlphaSlider`).
*
* The canonical model is always {@link HSVA} (the value never round-trips
* through RGB). Each picker reads `hsva` and writes back through the channel
* setters so the whole cluster stays in sync. Sub-pickers inject this context
* with a fallback (`undefined`) so they also work standalone, owning their own
* HSVA via `defineModel`.
*/
export interface ColorFieldContext {
/** The canonical, reactive HSVA color shared across the cluster. */
hsva: Ref<HSVA>;
/** Set the hue channel (`0360`), preserving the other channels. */
setHue: (hue: number) => void;
/** Set the saturation channel (`01`), preserving the other channels. */
setSaturation: (saturation: number) => void;
/** Set the value/brightness channel (`01`), preserving the other channels. */
setValue: (value: number) => void;
/** Set the alpha channel (`01`), preserving the other channels. */
setAlpha: (alpha: number) => void;
/** Set saturation and value/brightness together (used by the 2D area). */
setSaturationValue: (saturation: number, value: number) => void;
/** Whether the whole cluster is disabled. */
disabled: Ref<boolean>;
/** Accessible name id contributed by `ColorFieldLabel`, if present. */
labelId: Ref<string | undefined>;
}
const ctx = useContextFactory<ColorFieldContext>('ColorFieldContext');
export const provideColorFieldContext = ctx.provide;
export const useColorFieldContext = ctx.inject;
/** Injection key — used by the sub-pickers to inject with a fallback. */
export const colorFieldContextKey = ctx.key as InjectionKey<ColorFieldContext>;
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { HSVA, RGB } from '@robonen/primitives';
import {
AlphaSliderRoot,
AlphaSliderThumb,
ColorAreaRoot,
ColorAreaThumb,
ColorFieldInput,
ColorFieldLabel,
ColorFieldRoot,
ColorFieldSwatch,
HueSliderRoot,
HueSliderThumb,
hsvToRgb,
} from '@robonen/primitives';
import { ref } from 'vue';
const color = ref('#7c5cff');
function hueRgb(hsva: HSVA): string {
const { r, g, b } = hsvToRgb({ h: hsva.h, s: 1, v: 1 });
return `rgb(${r}, ${g}, ${b})`;
}
function rgbStr(rgb: RGB): string {
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
}
function alphaGradient(rgb: RGB): string {
return `linear-gradient(to right, rgba(${rgb.r},${rgb.g},${rgb.b},0), rgb(${rgb.r},${rgb.g},${rgb.b}))`;
}
const HUE_GRADIENT
= 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)';
const CHECKER = {
backgroundImage:
'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
backgroundSize: '10px 10px',
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0',
backgroundColor: '#fff',
} as const;
</script>
<template>
<ColorFieldRoot
v-slot="{ hsva, rgb, hex }"
v-model="color"
format="hex"
class="demo-card flex w-full max-w-xs flex-col gap-4 p-5 text-fg"
>
<ColorFieldLabel class="text-sm font-medium">Pick a color</ColorFieldLabel>
<!-- SV square -->
<ColorAreaRoot
class="relative aspect-square w-full touch-none select-none overflow-hidden rounded-card border border-border shadow-(--shadow-card)"
:style="{ backgroundColor: 'var(--color-area-hue)' }"
>
<div
class="pointer-events-none absolute inset-0"
style="background: linear-gradient(to right, #fff, transparent)"
/>
<div
class="pointer-events-none absolute inset-0"
style="background: linear-gradient(to top, #000, transparent)"
/>
<ColorAreaThumb
class="absolute z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
:style="{ backgroundColor: rgbStr(rgb) }"
/>
</ColorAreaRoot>
<!-- Hue + alpha rails -->
<div class="flex flex-col gap-3">
<HueSliderRoot
class="relative block h-3.5 w-full touch-none select-none rounded-full border border-border"
:style="{ background: HUE_GRADIENT }"
>
<HueSliderThumb
class="absolute top-1/2 z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
:style="{ backgroundColor: hueRgb(hsva) }"
/>
</HueSliderRoot>
<AlphaSliderRoot
class="relative block h-3.5 w-full touch-none select-none rounded-full border border-border"
:style="CHECKER"
>
<div
class="pointer-events-none absolute inset-0 rounded-full"
:style="{ backgroundImage: alphaGradient(rgb) }"
/>
<AlphaSliderThumb
class="absolute top-1/2 z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
:style="{ backgroundColor: rgbStr(rgb) }"
/>
</AlphaSliderRoot>
</div>
<!-- Swatch + editable text input -->
<div class="flex items-center gap-2 rounded-card bg-bg-inset p-2">
<span
class="size-9 shrink-0 overflow-hidden rounded-lg border border-border-strong"
:style="CHECKER"
>
<ColorFieldSwatch class="block size-full" />
</span>
<ColorFieldInput
class="min-w-0 flex-1 rounded-lg border border-border bg-bg px-2.5 py-1.5 font-mono text-sm text-fg uppercase outline-none transition focus-visible:ring-2 focus-visible:ring-ring data-[invalid]:border-red-500 data-[invalid]:text-red-500"
spellcheck="false"
autocomplete="off"
/>
</div>
<p class="font-mono text-xs text-fg-subtle">
v-model <span class="text-fg-muted">{{ hex }}</span>
</p>
</ColorFieldRoot>
</template>
@@ -0,0 +1,18 @@
export { default as ColorFieldRoot } from './ColorFieldRoot.vue';
export { default as ColorFieldHiddenInput } from './ColorFieldHiddenInput.vue';
export { default as ColorFieldInput } from './ColorFieldInput.vue';
export { default as ColorFieldLabel } from './ColorFieldLabel.vue';
export { default as ColorFieldSwatch } from './ColorFieldSwatch.vue';
export type { ColorFieldRootProps } from './ColorFieldRoot.vue';
export type { ColorFieldHiddenInputProps } from './ColorFieldHiddenInput.vue';
export type { ColorFieldInputProps } from './ColorFieldInput.vue';
export type { ColorFieldLabelProps } from './ColorFieldLabel.vue';
export type { ColorFieldSwatchProps } from './ColorFieldSwatch.vue';
export {
type ColorFieldContext,
colorFieldContextKey,
type ColorFormat,
provideColorFieldContext,
useColorFieldContext,
} from './context';
export { useColorState, useHsvaSetters } from './useColorState';
@@ -0,0 +1,135 @@
import type { Ref } from 'vue';
import { computed, inject, ref, watch } from 'vue';
import type { HSVA } from '../../internal/color';
import { clampChannel } from '../../internal/color';
import { colorFieldContextKey } from './context';
/**
* Wraps the four canonical HSVA channel setters so they all share one
* **preserve-hue** policy and never round-trip through RGB.
*
* When saturation or value collapses to `0` the hue becomes ambiguous (any hue
* yields the same grey). Photoshop's HSB picker keeps the *last meaningful* hue
* so dragging into and back out of a corner restores the colour the user
* expects. We track that last non-zero hue and re-apply it whenever the live
* hue would otherwise be lost.
*
* @param hsva The reactive canonical colour to mutate.
*/
export function useHsvaSetters(hsva: Ref<HSVA>): {
setHue: (hue: number) => void;
setSaturation: (saturation: number) => void;
setValue: (value: number) => void;
setAlpha: (alpha: number) => void;
setSaturationValue: (saturation: number, value: number) => void;
} {
// Seed the remembered hue from the initial colour.
let lastHue = hsva.value.h;
// Keep `lastHue` current whenever a real (non-grey) colour is present, so an
// externally driven hue change is respected on the next preserve.
// No `deep: true`: `hsva` is replaced wholesale on every change, so watching its
// identity already fires per update — a deep traverse of {h,s,v,a} (once per
// pointer-move during a colour drag) is pure overhead.
watch(
() => hsva.value,
(c) => {
if (c.s > 0 && c.v > 0) lastHue = c.h;
},
);
function commit(next: HSVA): void {
hsva.value = next;
}
function setHue(hue: number): void {
// Clamp to the [0, 360] rail rather than wrapping: the picker treats hue as
// a bounded slider, so `End` (360°) stays 360 instead of collapsing to 0.
// Rendering still normalizes internally, so 360 reads as red like 0.
const h = clampChannel(hue, 360);
lastHue = h;
commit({ ...hsva.value, h });
}
function setSaturation(saturation: number): void {
const s = clampChannel(saturation, 1);
const cur = hsva.value;
// Preserve hue when the colour was/becomes grey.
const h = s > 0 && cur.v > 0 ? cur.h : lastHue;
commit({ ...cur, s, h });
}
function setValue(value: number): void {
const v = clampChannel(value, 1);
const cur = hsva.value;
const h = cur.s > 0 && v > 0 ? cur.h : lastHue;
commit({ ...cur, v, h });
}
function setAlpha(alpha: number): void {
commit({ ...hsva.value, a: clampChannel(alpha, 1) });
}
function setSaturationValue(saturation: number, value: number): void {
const s = clampChannel(saturation, 1);
const v = clampChannel(value, 1);
const cur = hsva.value;
const h = s > 0 && v > 0 ? cur.h : lastHue;
commit({ ...cur, s, v, h });
}
return { setHue, setSaturation, setValue, setAlpha, setSaturationValue };
}
/**
* Resolve the colour state a picker sub-component should drive.
*
* - When a `ColorFieldRoot` is an ancestor, the sub-component reads and writes
* that shared context so the whole cluster stays in sync.
* - Otherwise the sub-component is **standalone**: it owns the supplied
* `standalone` HSVA ref (typically backed by `defineModel`) and gets its own
* preserve-hue setters.
*
* Returns the resolved `hsva` ref plus the five channel setters, regardless of
* which mode is active, so the caller never branches.
*
* @param standalone The component's own HSVA ref, used only when no field
* context is present.
* @param disabledLocal The component's own `disabled` getter (standalone mode).
*/
export function useColorState(
standalone: Ref<HSVA>,
disabledLocal: () => boolean,
): {
hsva: Ref<HSVA>;
disabled: Ref<boolean>;
labelId: Ref<string | undefined>;
setHue: (hue: number) => void;
setSaturation: (saturation: number) => void;
setValue: (value: number) => void;
setAlpha: (alpha: number) => void;
setSaturationValue: (saturation: number, value: number) => void;
} {
const field = inject(colorFieldContextKey, null);
if (field) {
return {
hsva: field.hsva,
disabled: computed(() => field.disabled.value || disabledLocal()),
labelId: field.labelId,
setHue: field.setHue,
setSaturation: field.setSaturation,
setValue: field.setValue,
setAlpha: field.setAlpha,
setSaturationValue: field.setSaturationValue,
};
}
const setters = useHsvaSetters(standalone);
return {
hsva: standalone,
disabled: computed(disabledLocal),
labelId: ref<string | undefined>(undefined),
...setters,
};
}
@@ -0,0 +1,150 @@
<script lang="ts">
import type { HueSliderDirection, HueSliderOrientation } from './context';
import type { HSVA } from '../../internal/color';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A 1D slider for picking the hue (`0360°`) of a colour. It works standalone —
* owning its own `HSVA` via `v-model` / `defaultValue` — or, when nested inside
* a `ColorFieldRoot`, reads and writes that shared colour so the whole picker
* cluster stays in sync. Mirrors the standard slider anatomy: the root owns the
* value, maps pointer drags along the track, handles arrow / Page / Home / End
* keys, and provides context to `HueSliderTrack` and `HueSliderThumb`. The
* gradient background should run through the full hue wheel; style it via the
* exposed slot/`data-*` hooks. Reach for it as the hue rail of a colour picker.
*/
export interface HueSliderRootProps extends PrimitiveProps {
/** Uncontrolled initial colour. @default { h: 0, s: 1, v: 1, a: 1 } */
defaultValue?: HSVA;
/** Keyboard step in degrees. @default 1 */
step?: number;
/** Large-step multiplier (Page keys / Shift+Arrow). @default 10 */
largeStep?: number;
/** Orientation. @default 'horizontal' */
orientation?: HueSliderOrientation;
/** Writing direction (inherited from `ConfigProvider` when omitted). */
dir?: HueSliderDirection;
/** Disable interaction. @default false */
disabled?: boolean;
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, watch } from 'vue';
import { clampChannel } from '../../internal/color';
import { Primitive } from '../../internal/primitive';
import { provideHueSliderContext } from './context';
import { useColorState } from '../color-field/useColorState';
import { useDirection } from '../../utilities/config-provider';
import { usePointerDrag } from '../../internal/pointer-drag';
import { useForwardExpose } from '@robonen/vue';
const {
defaultValue,
step = 1,
largeStep = 10,
orientation = 'horizontal',
dir,
disabled = false,
as = 'span',
} = defineProps<HueSliderRootProps>();
const direction = useDirection(() => dir);
// Standalone colour state (used only when there is no `ColorFieldRoot`).
// shallowRef: HSVA is replaced wholesale by the setters, never mutated in place.
const model = defineModel<HSVA | null>();
const standalone = shallowRef<HSVA>(model.value ?? defaultValue ?? { h: 0, s: 1, v: 1, a: 1 });
// Reflect standalone writes out through the model.
const standaloneState = computed<HSVA>({
get: () => standalone.value,
set: (v) => {
standalone.value = v;
model.value = v;
},
});
// Resolve shared (ColorField) vs standalone colour + setters.
const colorState = useColorState(standaloneState, () => disabled);
const hue = computed(() => colorState.hsva.value.h);
const trackRef = shallowRef<HTMLElement | null>(null);
function setHue(next: number): void {
if (colorState.disabled.value) return;
// Hue is cyclic but the slider treats it as a clamped [0,360] rail.
colorState.setHue(clampChannel(next, 360));
}
// Rect cached for the duration of a gesture (snapshotted in `onStart`): the track
// box cannot change mid-drag, so re-reading getBoundingClientRect() every onMove
// frame is a needless forced reflow. A live read is the fallback for any caller
// without a cached rect.
let gestureRect: DOMRect | undefined;
function hueFromPointer(clientCoord: { x: number; y: number }, rect?: DOMRect): number {
const r = rect ?? trackRef.value?.getBoundingClientRect();
if (!r) return hue.value;
const horizontal = orientation === 'horizontal';
const size = horizontal ? r.width : r.height;
if (size === 0) return hue.value;
let offset = horizontal ? clientCoord.x - r.left : clientCoord.y - r.top;
// Horizontal ltr: left = 0. RTL flips. Vertical: top = max by convention.
const flip = horizontal ? direction.value === 'rtl' : true;
if (flip) offset = size - offset;
return clampChannel((offset / size) * 360, 360);
}
usePointerDrag(trackRef, {
axis: orientation === 'horizontal' ? 'x' : 'y',
threshold: 0,
disabled: () => colorState.disabled.value,
onStart: (state) => {
gestureRect = trackRef.value?.getBoundingClientRect();
setHue(hueFromPointer({ x: state.point.x, y: state.point.y }, gestureRect));
},
onMove: (state) => {
setHue(hueFromPointer({ x: state.point.x, y: state.point.y }, gestureRect));
},
onEnd: () => {
gestureRect = undefined;
},
});
provideHueSliderContext({
hsva: colorState.hsva,
hue,
step: toRef(() => step),
largeStep: toRef(() => largeStep),
orientation: toRef(() => orientation),
direction,
disabled: colorState.disabled,
labelId: colorState.labelId,
trackRef,
setHue,
});
defineExpose({ hue });
// The root element IS the draggable rail; bind it as the geometry track.
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (node) => {
trackRef.value = node ?? null;
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
:dir="direction"
:aria-disabled="colorState.disabled.value || undefined"
:data-disabled="colorState.disabled.value ? '' : undefined"
:data-orientation="orientation"
>
<slot :hue="hue" />
</Primitive>
</template>
@@ -0,0 +1,128 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The draggable handle of a `HueSliderRoot`, rendered as `role="slider"` with
* full ARIA value attributes (`aria-valuemin="0"`, `aria-valuemax="360"`,
* `aria-valuenow` = current hue, `aria-valuetext` = `"<n>°"`). It positions
* itself along the track by the hue percentage and handles keyboard interaction
* (arrows step by `step`, Page Up/Down and Shift+Arrow by the large step,
* Home/End jump to `0°`/`360°`). Give it an `aria-label` or rely on the default
* `"Hue"`. Exposes `hue` and `percent` as slot props.
*/
export interface HueSliderThumbProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { Primitive } from '../../internal/primitive';
import { useHueSliderContext } from './context';
import { useForwardExpose } from '@robonen/vue';
const { as = 'span' } = defineProps<HueSliderThumbProps>();
const ctx = useHueSliderContext();
const attrs = useAttrs();
const hue = computed(() => ctx.hue.value);
const percent = computed(() => (hue.value / 360) * 100);
// Fall back to the default "Hue" label unless the consumer supplied an
// explicit accessible name.
const accessibleLabel = computed<string | undefined>(() => {
const hasLabel = attrs['aria-label'] !== undefined && attrs['aria-label'] !== null;
const hasLabelledBy = attrs['aria-labelledby'] !== undefined && attrs['aria-labelledby'] !== null;
if (hasLabel || hasLabelledBy) return undefined;
return ctx.labelId.value ? undefined : 'Hue';
});
const valueText = computed(() => `${Math.round(hue.value)}°`);
const positionStyle = computed<{
left: string | undefined;
right: string | undefined;
top: string | undefined;
bottom: string | undefined;
}>(() => {
const pct = percent.value;
const horizontal = ctx.orientation.value === 'horizontal';
const rtl = ctx.direction.value === 'rtl';
if (horizontal) {
return {
left: rtl ? undefined : `${pct}%`,
right: rtl ? `${pct}%` : undefined,
top: undefined,
bottom: undefined,
};
}
return { left: undefined, right: undefined, top: undefined, bottom: `${pct}%` };
});
function onKeyDown(event: KeyboardEvent): void {
if (ctx.disabled.value) return;
const horizontal = ctx.orientation.value === 'horizontal';
const rtl = ctx.direction.value === 'rtl';
const step = ctx.step.value;
const big = step * ctx.largeStep.value;
const unit = event.shiftKey ? big : step;
const current = ctx.hue.value;
let delta: number;
switch (event.key) {
case 'ArrowRight':
delta = horizontal ? (rtl ? -unit : unit) : 0;
break;
case 'ArrowLeft':
delta = horizontal ? (rtl ? unit : -unit) : 0;
break;
case 'ArrowUp':
delta = horizontal ? 0 : unit;
break;
case 'ArrowDown':
delta = horizontal ? 0 : -unit;
break;
case 'PageUp':
delta = big;
break;
case 'PageDown':
delta = -big;
break;
case 'Home':
event.preventDefault();
ctx.setHue(0);
return;
case 'End':
event.preventDefault();
ctx.setHue(360);
return;
default:
return;
}
if (delta === 0) return;
event.preventDefault();
ctx.setHue(current + delta);
}
const { forwardRef } = useForwardExpose();
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="slider"
:tabindex="ctx.disabled.value ? -1 : 0"
:aria-label="accessibleLabel"
:aria-labelledby="!accessibleLabel ? ctx.labelId.value : undefined"
:aria-valuemin="0"
:aria-valuemax="360"
:aria-valuenow="Math.round(hue)"
:aria-valuetext="valueText"
:aria-orientation="ctx.orientation.value"
:aria-disabled="ctx.disabled.value || undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-orientation="ctx.orientation.value"
:style="positionStyle"
@keydown="onKeyDown"
>
<slot :hue="hue" :percent="percent" />
</Primitive>
</template>
@@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils';
import type { VueWrapper } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { HSVA } from '../../../internal/color';
import { HueSliderRoot, HueSliderThumb } from '../index';
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
function keydown(el: Element, key: string, opts: { shiftKey?: boolean } = {}): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, shiftKey: opts.shiftKey ?? false }));
}
function mountHue(opts: Partial<{ defaultValue: HSVA; step: number; disabled: boolean; orientation: 'horizontal' | 'vertical' }> = {}) {
const model = ref<HSVA | undefined>(undefined);
const Harness = defineComponent({
setup: () => () => h(HueSliderRoot, {
modelValue: model.value,
'onUpdate:modelValue': (v: HSVA | null | undefined) => { model.value = v ?? undefined; },
...opts,
}, { default: () => h(HueSliderThumb) }),
});
const w = track(mount(Harness, { attachTo: document.body }));
return { wrapper: w, model };
}
describe('HueSlider', () => {
it('thumb is role=slider with hue aria-value*', async () => {
mountHue({ defaultValue: { h: 120, s: 1, v: 1, a: 1 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb).toBeTruthy();
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
expect(thumb.getAttribute('aria-valuemax')).toBe('360');
expect(thumb.getAttribute('aria-valuenow')).toBe('120');
expect(thumb.getAttribute('aria-valuetext')).toBe('120°');
expect(thumb.getAttribute('aria-label')).toBe('Hue');
expect(thumb.tabIndex).toBe(0);
});
it('ArrowRight / ArrowLeft step the hue', async () => {
const { model } = mountHue({ defaultValue: { h: 100, s: 1, v: 1, a: 1 }, step: 5 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value!.h).toBe(105);
keydown(thumb, 'ArrowLeft');
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(model.value!.h).toBe(95);
});
it('Shift+Arrow uses the large step', async () => {
const { model } = mountHue({ defaultValue: { h: 100, s: 1, v: 1, a: 1 }, step: 1 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight', { shiftKey: true });
await nextTick();
expect(model.value!.h).toBe(110);
});
it('Home / End clamp to 0 / 360', async () => {
const { model } = mountHue({ defaultValue: { h: 180, s: 1, v: 1, a: 1 } });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'Home');
await nextTick();
expect(model.value!.h).toBe(0);
keydown(thumb, 'End');
await nextTick();
expect(model.value!.h).toBe(360);
});
it('clamps within [0, 360]', async () => {
const { model } = mountHue({ defaultValue: { h: 2, s: 1, v: 1, a: 1 }, step: 5 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowLeft');
await nextTick();
expect(model.value!.h).toBe(0);
});
it('preserves saturation / value / alpha while only changing hue', async () => {
const { model } = mountHue({ defaultValue: { h: 100, s: 0.4, v: 0.6, a: 0.8 }, step: 10 });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toMatchObject({ h: 110, s: 0.4, v: 0.6, a: 0.8 });
});
it('disabled: tabindex=-1 and keys do nothing', async () => {
const { model } = mountHue({ defaultValue: { h: 100, s: 1, v: 1, a: 1 }, disabled: true });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.tabIndex).toBe(-1);
expect(thumb.getAttribute('aria-disabled')).toBe('true');
keydown(thumb, 'ArrowRight');
await nextTick();
expect(model.value).toBeUndefined();
});
it('vertical orientation reports aria-orientation', async () => {
mountHue({ defaultValue: { h: 100, s: 1, v: 1, a: 1 }, orientation: 'vertical' });
await nextTick();
const thumb = document.querySelector<HTMLElement>('[role="slider"]')!;
expect(thumb.getAttribute('aria-orientation')).toBe('vertical');
});
});
@@ -0,0 +1,36 @@
import type { Ref } from 'vue';
import type { HSVA } from '../../internal/color';
import { useContextFactory } from '@robonen/vue';
export type HueSliderOrientation = 'horizontal' | 'vertical';
export type HueSliderDirection = 'ltr' | 'rtl';
/**
* Context shared between `HueSliderRoot` and `HueSliderThumb`.
*
* Scalar props are exposed as plain `Ref<T>` — `HueSliderRoot` builds them with
* `toRef(() => prop)` (a reactive getter ref without an extra effect).
*/
export interface HueSliderContext {
/** The canonical colour the slider reads its hue from. */
hsva: Ref<HSVA>;
/** Current hue (`0360`). */
hue: Ref<number>;
/** Step granularity for keyboard nudges. */
step: Ref<number>;
/** Large-step multiplier (Page keys / Shift+Arrow). */
largeStep: Ref<number>;
orientation: Ref<HueSliderOrientation>;
direction: Ref<HueSliderDirection>;
disabled: Ref<boolean>;
/** Accessible name id contributed by a `ColorFieldLabel`, if present. */
labelId: Ref<string | undefined>;
trackRef: Ref<HTMLElement | null>;
/** Set the hue (`0360`); clamped/wrapped by the root. */
setHue: (hue: number) => void;
}
const ctx = useContextFactory<HueSliderContext>('HueSliderContext');
export const provideHueSliderContext = ctx.provide;
export const useHueSliderContext = ctx.inject;
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { HSVA } from '@robonen/primitives';
import { HueSliderRoot, HueSliderThumb, hsvToRgb } from '@robonen/primitives';
import { computed, ref } from 'vue';
const color = ref<HSVA>({ h: 210, s: 1, v: 1, a: 1 });
const rgb = computed(() => hsvToRgb({ h: color.value.h, s: 1, v: 1 }));
const hueColor = computed(() => `rgb(${rgb.value.r}, ${rgb.value.g}, ${rgb.value.b})`);
const HUE_GRADIENT
= 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)';
</script>
<template>
<div class="demo-card flex w-full max-w-sm flex-col gap-5 p-6 text-fg">
<div class="flex items-baseline justify-between text-sm">
<span class="font-medium">Hue</span>
<span class="font-mono text-fg-muted">{{ Math.round(color.h) }}°</span>
</div>
<!-- The hue rail: the root span IS the track -->
<HueSliderRoot
v-model="color"
class="relative block h-4 w-full touch-none select-none rounded-full border border-border shadow-(--shadow-card)"
:style="{ background: HUE_GRADIENT }"
>
<HueSliderThumb
aria-label="Hue"
class="absolute top-1/2 z-10 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[transform] focus-visible:ring-2 focus-visible:ring-ring hover:scale-110"
:style="{ backgroundColor: hueColor }"
/>
</HueSliderRoot>
<div class="flex items-center gap-3 rounded-card bg-bg-inset p-3">
<span
class="size-9 shrink-0 rounded-lg border border-border-strong"
:style="{ backgroundColor: hueColor }"
/>
<div class="flex flex-col text-sm leading-tight">
<span class="font-mono text-fg">{{ hueColor }}</span>
<span class="font-mono text-xs text-fg-subtle">hue {{ Math.round(color.h) }}° of 360°</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,11 @@
export { default as HueSliderRoot } from './HueSliderRoot.vue';
export { default as HueSliderThumb } from './HueSliderThumb.vue';
export type { HueSliderRootProps } from './HueSliderRoot.vue';
export type { HueSliderThumbProps } from './HueSliderThumb.vue';
export {
type HueSliderContext,
type HueSliderDirection,
type HueSliderOrientation,
provideHueSliderContext,
useHueSliderContext,
} from './context';