parent
813c4c440c
commit
c02b13cd47
@ -0,0 +1,325 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import {
|
||||
colorValueFromRgb,
|
||||
DEFAULT_COLOR,
|
||||
type ColorFormat,
|
||||
type ColorValue,
|
||||
type EditableColorSegmentPart
|
||||
} from '$libs/color';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
import { FieldProvider } from '../field/field-provider.svelte';
|
||||
|
||||
import {
|
||||
ColorPickerAreaProvider,
|
||||
ColorPickerAreaThumbProvider,
|
||||
ColorPickerChannelSliderProvider,
|
||||
ColorPickerChannelSliderThumbProvider,
|
||||
ColorPickerHiddenInputProvider,
|
||||
ColorPickerLabelProvider,
|
||||
ColorPickerProvider,
|
||||
ColorPickerSwatchTriggerProvider,
|
||||
ColorPickerTriggerProvider,
|
||||
ColorPickerValueSwatchProvider,
|
||||
ColorPickerValueTextProvider,
|
||||
getChannelRange,
|
||||
getColorChannel,
|
||||
setColorChannel
|
||||
} from './color-picker-provider.svelte';
|
||||
import type { ColorChannel } from './types';
|
||||
import type { ColorOnInvalid, ColorValidator } from '../color-field/types';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
ts: (key: string) => key
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
|
||||
vi.spyOn(ColorPickerProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(ColorPickerAreaProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(ColorPickerChannelSliderProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function createColorPickerOpts() {
|
||||
return {
|
||||
id: state('color-picker-root'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
value: state<ColorValue | undefined>(undefined),
|
||||
placeholder: state<ColorValue>(DEFAULT_COLOR),
|
||||
format: state<ColorFormat>('hex'),
|
||||
open: state(false),
|
||||
allowedFormats: state<ColorFormat[]>(['hex', 'rgb', 'hsl']),
|
||||
enableAlpha: state(true),
|
||||
closeOnSelect: state(false),
|
||||
inline: state(false),
|
||||
areaChannels: state<{ x: ColorChannel; y: ColorChannel }>({
|
||||
x: 'saturation',
|
||||
y: 'brightness'
|
||||
}),
|
||||
readonlySegments: state<EditableColorSegmentPart[]>([]),
|
||||
validate: state<ColorValidator | undefined>(undefined),
|
||||
onInvalid: state<ColorOnInvalid | undefined>(undefined),
|
||||
onValueChangeEnd: state<((value: ColorValue | undefined) => void) | undefined>(undefined),
|
||||
onOpenChangeComplete: state<((open: boolean) => void) | undefined>(undefined),
|
||||
disabled: state(false),
|
||||
readonly: state(false),
|
||||
required: state(false),
|
||||
invalid: state(false),
|
||||
errorMessageId: state<string | undefined>(undefined),
|
||||
locale: state<string | undefined>('en-US'),
|
||||
dir: state<'ltr' | 'rtl'>('ltr')
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(key: string, shiftKey = false) {
|
||||
return {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return new DOMRect(left, top, width, height);
|
||||
}
|
||||
|
||||
describe('ColorPickerProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('preserves channel spaces and exposes value display parts', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorPickerOpts();
|
||||
opts.value.current = colorValueFromRgb({ r: 10, g: 20, b: 30, a: 1 });
|
||||
opts.format.current = 'rgb';
|
||||
opts.required.current = true;
|
||||
|
||||
expect(getColorChannel(opts.value.current, 'red')).toBe(10);
|
||||
expect(getChannelRange('alpha')).toEqual({ min: 0, max: 1 });
|
||||
expect(setColorChannel(opts.value.current, 'green', 40).rgb).toEqual({
|
||||
r: 10,
|
||||
g: 40,
|
||||
b: 30,
|
||||
a: 1
|
||||
});
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ColorPickerProvider.create(opts);
|
||||
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
|
||||
provider.setName('brandColor');
|
||||
|
||||
const triggerNode = document.createElement('button');
|
||||
const trigger = ColorPickerTriggerProvider.create({
|
||||
id: state('color-picker-trigger'),
|
||||
ref: state<HTMLElement | null>(triggerNode),
|
||||
ariaLabel: state<string | undefined>('Pick color')
|
||||
});
|
||||
provider.setTriggerNode(triggerNode);
|
||||
|
||||
const swatch = ColorPickerValueSwatchProvider.create({
|
||||
id: state('color-picker-value-swatch'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
const text = ColorPickerValueTextProvider.create({
|
||||
id: state('color-picker-value-text'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
format: state<ColorFormat | undefined>(undefined)
|
||||
});
|
||||
const hidden = ColorPickerHiddenInputProvider.create({
|
||||
id: state('color-picker-hidden')
|
||||
});
|
||||
|
||||
return { provider, trigger, swatch, text, hidden };
|
||||
});
|
||||
|
||||
expect(result.provider.props).toMatchObject({
|
||||
dir: 'ltr',
|
||||
'data-format': 'rgb',
|
||||
'data-required': ''
|
||||
});
|
||||
expect(result.trigger.props).toMatchObject({
|
||||
type: 'button',
|
||||
'aria-label': 'Pick color',
|
||||
'aria-haspopup': 'dialog'
|
||||
});
|
||||
expect(result.swatch.props.style).toBe('background-color: #0a141e;');
|
||||
expect(result.text.displayValue).toBe('rgb(10, 20, 30)');
|
||||
expect(result.hidden.props).toMatchObject({
|
||||
type: 'hidden',
|
||||
name: 'brandColor',
|
||||
value: 'rgb(10, 20, 30)',
|
||||
required: true
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('updates 2D area channels from pointer geometry and keyboard thumb movement', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorPickerOpts();
|
||||
opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
|
||||
const onCommit = vi.fn();
|
||||
opts.onValueChangeEnd.current = onCommit;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ColorPickerProvider.create(opts);
|
||||
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
|
||||
const area = ColorPickerAreaProvider.create({
|
||||
id: state('color-picker-area'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div'))
|
||||
});
|
||||
vi.spyOn(ColorPickerAreaProvider.ctx, 'get').mockReturnValue(area);
|
||||
const thumb = ColorPickerAreaThumbProvider.create({
|
||||
id: state('color-picker-area-thumb'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
return { provider, area, thumb };
|
||||
});
|
||||
|
||||
result.area.handlePointerMove(25, 150, rect(0, 0, 100, 200));
|
||||
|
||||
expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(25);
|
||||
expect(Math.round(result.provider.effectiveColor.hsv.v)).toBe(25);
|
||||
expect(result.area.thumbStyle).toEqual({ left: '25%', top: '75%' });
|
||||
expect(result.thumb.props).toMatchObject({
|
||||
role: 'slider',
|
||||
'aria-label': 'saturation, brightness',
|
||||
'aria-valuetext': 'saturation: 25, brightness: 25'
|
||||
});
|
||||
|
||||
result.thumb.props.onkeydown(keyEvent('ArrowRight') as never);
|
||||
expect(Math.round(result.provider.effectiveColor.hsv.s)).toBe(26);
|
||||
|
||||
result.provider.commit();
|
||||
expect(onCommit).toHaveBeenCalledWith(opts.value.current);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('drives a channel slider by pointer and keyboard and commits on pointerup', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorPickerOpts();
|
||||
opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
|
||||
const onCommit = vi.fn();
|
||||
opts.onValueChangeEnd.current = onCommit;
|
||||
|
||||
const sliderNode = document.createElement('div') as HTMLElement & {
|
||||
setPointerCapture: (pointerId: number) => void;
|
||||
};
|
||||
sliderNode.setPointerCapture = vi.fn();
|
||||
vi.spyOn(sliderNode, 'getBoundingClientRect').mockReturnValue(rect(0, 0, 360, 20));
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ColorPickerProvider.create(opts);
|
||||
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
|
||||
const slider = ColorPickerChannelSliderProvider.create({
|
||||
id: state('color-picker-hue-slider'),
|
||||
ref: state<HTMLElement | null>(sliderNode),
|
||||
channel: state<ColorChannel>('hue'),
|
||||
orientation: state<'horizontal' | 'vertical'>('horizontal')
|
||||
});
|
||||
vi.spyOn(ColorPickerChannelSliderProvider.ctx, 'get').mockReturnValue(slider);
|
||||
const thumb = ColorPickerChannelSliderThumbProvider.create({
|
||||
id: state('color-picker-hue-thumb'),
|
||||
ref: state<HTMLElement | null>(null)
|
||||
});
|
||||
return { provider, slider, thumb };
|
||||
});
|
||||
|
||||
result.slider.onpointerdown({
|
||||
currentTarget: sliderNode,
|
||||
pointerId: 7,
|
||||
clientX: 180,
|
||||
clientY: 0,
|
||||
preventDefault: vi.fn()
|
||||
} as never);
|
||||
|
||||
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(180);
|
||||
expect(result.slider.progress).toBe(0.5);
|
||||
expect(result.thumb.props).toMatchObject({
|
||||
role: 'slider',
|
||||
'aria-orientation': 'horizontal',
|
||||
'aria-label': 'hue',
|
||||
'aria-valuenow': 180
|
||||
});
|
||||
|
||||
result.thumb.props.onkeydown(keyEvent('ArrowRight', true) as never);
|
||||
expect(Math.round(result.provider.effectiveColor.hsv.h)).toBe(216);
|
||||
|
||||
result.slider.onpointerup({ pointerId: 7 } as never);
|
||||
expect(onCommit).toHaveBeenCalledWith(opts.value.current);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('selects swatches, commits and closes when closeOnSelect is enabled', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorPickerOpts();
|
||||
const onCommit = vi.fn();
|
||||
opts.value.current = colorValueFromRgb({ r: 255, g: 0, b: 0, a: 1 });
|
||||
opts.open.current = true;
|
||||
opts.closeOnSelect.current = true;
|
||||
opts.onValueChangeEnd.current = onCommit;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ColorPickerProvider.create(opts);
|
||||
vi.spyOn(ColorPickerProvider.ctx, 'get').mockReturnValue(provider);
|
||||
const red = ColorPickerSwatchTriggerProvider.create({
|
||||
id: state('color-picker-red'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
color: state('#ff0000')
|
||||
});
|
||||
const green = ColorPickerSwatchTriggerProvider.create({
|
||||
id: state('color-picker-green'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
color: state('#00ff00')
|
||||
});
|
||||
return { red, green };
|
||||
});
|
||||
|
||||
expect(result.red.isChecked).toBe(true);
|
||||
expect(result.red.props['data-checked']).toBe('');
|
||||
|
||||
result.green.onclick();
|
||||
|
||||
expect(opts.value.current?.hex).toBe('#00ff00');
|
||||
expect(opts.open.current).toBe(false);
|
||||
expect(onCommit).toHaveBeenCalledWith(opts.value.current);
|
||||
expect(result.green.isChecked).toBe(true);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue