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:
@@ -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, `0–1`) 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 (`0–1`). */
|
||||
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 (`0–1`); 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 (`0–1`). @default 0.01 */
|
||||
step?: number;
|
||||
/** Large keyboard step (Shift+Arrow / Page keys, `0–1`). @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 `0–1` 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 (`0–1`, x-axis). */
|
||||
saturation: Ref<number>;
|
||||
/** Current value/brightness (`0–1`, 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 (`0–1`), preserving hue and value. */
|
||||
setSaturation: (saturation: number) => void;
|
||||
/** Set the value/brightness channel (`0–1`), 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, white→transparent left-right, transparent→black 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');
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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 (`0–360`), preserving the other channels. */
|
||||
setHue: (hue: number) => void;
|
||||
/** Set the saturation channel (`0–1`), preserving the other channels. */
|
||||
setSaturation: (saturation: number) => void;
|
||||
/** Set the value/brightness channel (`0–1`), preserving the other channels. */
|
||||
setValue: (value: number) => void;
|
||||
/** Set the alpha channel (`0–1`), 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 (`0–360°`) 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');
|
||||
});
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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 (`0–360`). */
|
||||
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 (`0–360`); 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';
|
||||
Reference in New Issue
Block a user