parent
cab1363616
commit
813c4c440c
@ -0,0 +1,258 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
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 {
|
||||
ColorFieldFormatSelectProvider,
|
||||
ColorFieldHiddenInputProvider,
|
||||
ColorFieldInputProvider,
|
||||
ColorFieldLabelProvider,
|
||||
ColorFieldProvider,
|
||||
createColorSegmentProvider
|
||||
} from './color-field-provider.svelte';
|
||||
import type { ColorOnInvalid, ColorValidator } from './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
|
||||
},
|
||||
logger: {
|
||||
warn: vi.fn()
|
||||
},
|
||||
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(ColorFieldProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function createColorFieldOpts() {
|
||||
return {
|
||||
id: state('color-field-root'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
value: state<ColorValue | undefined>(undefined),
|
||||
placeholder: state<ColorValue>(DEFAULT_COLOR),
|
||||
format: state<ColorFormat>('rgb'),
|
||||
allowedFormats: state<ColorFormat[]>(['hex', 'rgb', 'hsl']),
|
||||
enableAlpha: state(false),
|
||||
validate: state<ColorValidator | undefined>(undefined),
|
||||
onInvalid: state<ColorOnInvalid | undefined>(undefined),
|
||||
disabled: state(false),
|
||||
readonly: state(false),
|
||||
readonlySegments: state<EditableColorSegmentPart[]>([]),
|
||||
required: state(false),
|
||||
invalid: state(false),
|
||||
errorMessageId: state<string | undefined>(undefined),
|
||||
dir: state<'ltr' | 'rtl'>('ltr')
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(key: string, target: HTMLElement = document.createElement('span')) {
|
||||
return {
|
||||
key,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
currentTarget: target,
|
||||
preventDefault: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
describe('ColorFieldProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('commits an RGB value only when the required editable segments are filled', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorFieldOpts();
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => ColorFieldProvider.create(opts));
|
||||
|
||||
provider.updateSegment('r', () => '10');
|
||||
provider.updateSegment('g', () => '20');
|
||||
expect(opts.value.current).toBeUndefined();
|
||||
|
||||
provider.updateSegment('b', () => '30');
|
||||
|
||||
expect(opts.value.current?.rgb).toEqual({ r: 10, g: 20, b: 30, a: 1 });
|
||||
expect(opts.value.current?.hex).toBe('#0a141e');
|
||||
expect(provider.formattedValue).toBe('rgb(10, 20, 30)');
|
||||
expect(provider.props).toMatchObject({
|
||||
dir: 'ltr',
|
||||
'data-format': 'rgb',
|
||||
'data-invalid': undefined
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('edits a hex segment with keyboard input and exposes spinbutton aria', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorFieldOpts();
|
||||
opts.format.current = 'hex';
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => ColorFieldProvider.create(opts));
|
||||
vi.spyOn(ColorFieldProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const target = document.createElement('span');
|
||||
const segment = createColorSegmentProvider('hex', {
|
||||
id: state('color-field-hex'),
|
||||
ref: state<HTMLElement | null>(target)
|
||||
});
|
||||
|
||||
for (const key of ['f', 'f', '6', '6', '0', '0']) {
|
||||
(segment.props as { onkeydown: (event: unknown) => void }).onkeydown(keyEvent(key, target));
|
||||
}
|
||||
|
||||
expect(opts.value.current?.hex).toBe('#ff6600');
|
||||
expect(segment.props).toMatchObject({
|
||||
role: 'spinbutton',
|
||||
'data-segment': 'hex',
|
||||
'data-placeholder': undefined,
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 16777215,
|
||||
'aria-valuetext': '#ff6600'
|
||||
});
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('switches format only within allowed formats and keeps select props locked when needed', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorFieldOpts();
|
||||
opts.value.current = colorValueFromRgb({ r: 255, g: 128, b: 0, a: 1 });
|
||||
opts.allowedFormats.current = ['hex', 'hsl'];
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => ColorFieldProvider.create(opts));
|
||||
vi.spyOn(ColorFieldProvider.ctx, 'get').mockReturnValue(provider);
|
||||
|
||||
const select = ColorFieldFormatSelectProvider.create({
|
||||
id: state('color-field-format'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
ariaLabel: state<string | undefined>(undefined)
|
||||
});
|
||||
|
||||
select.onchange({ target: { value: 'hsl' } } as never);
|
||||
expect(opts.format.current).toBe('hsl');
|
||||
expect(provider.segmentValues.h).toBe('30');
|
||||
expect(provider.segmentValues.s).toBe('100');
|
||||
expect(provider.segmentValues.l).toBe('50');
|
||||
expect(select.options).toEqual(['hex', 'hsl']);
|
||||
expect(select.props['data-locked']).toBeUndefined();
|
||||
|
||||
opts.allowedFormats.current = ['hsl'];
|
||||
expect(select.isLocked).toBe(true);
|
||||
expect(select.props['data-locked']).toBe('');
|
||||
provider.setFormat('rgb');
|
||||
expect(opts.format.current).toBe('hsl');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('wires input, label focus and hidden form value through ActiveDom', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = createColorFieldOpts();
|
||||
opts.value.current = colorValueFromRgb({ r: 1, g: 2, b: 3, a: 1 });
|
||||
opts.required.current = true;
|
||||
const focus = vi.spyOn(dom, 'focus');
|
||||
|
||||
const field = document.createElement('div');
|
||||
const firstSegment = document.createElement('span');
|
||||
firstSegment.dataset.segment = 'r';
|
||||
field.appendChild(firstSegment);
|
||||
|
||||
const labelNode = document.createElement('label');
|
||||
labelNode.id = 'color-label';
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ColorFieldProvider.create(opts);
|
||||
vi.spyOn(ColorFieldProvider.ctx, 'get').mockReturnValue(provider);
|
||||
provider.setFieldNode(field);
|
||||
|
||||
const label = ColorFieldLabelProvider.create({
|
||||
id: state('color-label'),
|
||||
ref: state<HTMLElement | null>(labelNode)
|
||||
});
|
||||
provider.setLabelNode(labelNode);
|
||||
|
||||
const input = ColorFieldInputProvider.create({
|
||||
id: state('color-field-input'),
|
||||
ref: state<HTMLElement | null>(field),
|
||||
name: state('brandColor'),
|
||||
ariaLabel: state<string | undefined>(undefined),
|
||||
ariaLabelledBy: state<string | undefined>(undefined)
|
||||
});
|
||||
const hidden = ColorFieldHiddenInputProvider.create({
|
||||
id: state('color-field-hidden')
|
||||
});
|
||||
|
||||
return { provider, label, input, hidden };
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(result.input.segments.map((segment) => segment.part)).toEqual([
|
||||
'r',
|
||||
'literal',
|
||||
'g',
|
||||
'literal',
|
||||
'b'
|
||||
]);
|
||||
expect(result.input.props).toMatchObject({
|
||||
role: 'group',
|
||||
'aria-labelledby': 'color-label',
|
||||
'aria-disabled': 'false',
|
||||
'data-format': 'rgb'
|
||||
});
|
||||
expect(result.hidden.props).toMatchObject({
|
||||
type: 'hidden',
|
||||
name: 'brandColor',
|
||||
value: 'rgb(1, 2, 3)',
|
||||
required: true,
|
||||
'aria-hidden': 'true'
|
||||
});
|
||||
|
||||
result.label.onclick();
|
||||
expect(focus).toHaveBeenCalledWith(firstSegment);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue