diff --git a/continue.md b/continue.md index 1975efdf8..0defbfe4d 100644 --- a/continue.md +++ b/continue.md @@ -57,9 +57,11 @@ Actualizacion 2026-05-15: input oculto, items, progress, remove y clear. - Cobertura nueva para ColorField: commit por segmentos, edición hex con teclado, selector de formato, input oculto y foco de label via `ActiveDom`. + - Cobertura nueva para ColorPicker: helpers de canales, trigger/value/hidden, + area 2D, slider de canal y swatches con commit/cierre. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 189 archivos, 2097 tests OK. + - `npm run test` -> 190 archivos, 2101 tests OK. - tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK. - tests focales Dialog/Drawer/Popover/Select -> 10 tests OK. - `command-provider.svelte.test.ts` -> 2 tests OK. @@ -77,6 +79,7 @@ Actualizacion 2026-05-15: - `number-field-provider.svelte.test.ts` -> 4 tests OK. - `file-upload-provider.svelte.test.ts` -> 4 tests OK. - `color-field-provider.svelte.test.ts` -> 4 tests OK. + - `color-picker-provider.svelte.test.ts` -> 4 tests OK. - guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'` -> solo queda `select/components/select-provider.svelte`, que es wrapper publico, no runtime provider. diff --git a/src/uix/soma/SOMA_ARCHITECTURE.md b/src/uix/soma/SOMA_ARCHITECTURE.md index 65f3e0945..35c715651 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -489,9 +489,11 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A - `color-field/color-field-provider.svelte.test.ts` — commit por segmentos, edición hex por teclado, selector de formato, input oculto y foco via `ActiveDom`. +- `color-picker/color-picker-provider.svelte.test.ts` — helpers de canales, + trigger/value/hidden, area 2D, slider de canal y swatches. Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del -bloque fecha/hora, virtualización, inputs numericos y file upload. +bloque fecha/hora, virtualización, inputs numericos, file upload y color. `table-core`, `form-core` y el scorer de Command ya no viven dentro de Soma: se consumen desde `$libs/datagrid`, `$libs/forms` y `$libs/strings`. diff --git a/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts b/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts new file mode 100644 index 000000000..178613bd6 --- /dev/null +++ b/src/uix/soma/components/color-picker/color-picker-provider.svelte.test.ts @@ -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(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) => + 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(null), + value: state(undefined), + placeholder: state(DEFAULT_COLOR), + format: state('hex'), + open: state(false), + allowedFormats: state(['hex', 'rgb', 'hsl']), + enableAlpha: state(true), + closeOnSelect: state(false), + inline: state(false), + areaChannels: state<{ x: ColorChannel; y: ColorChannel }>({ + x: 'saturation', + y: 'brightness' + }), + readonlySegments: state([]), + validate: state(undefined), + onInvalid: state(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(undefined), + locale: state('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(triggerNode), + ariaLabel: state('Pick color') + }); + provider.setTriggerNode(triggerNode); + + const swatch = ColorPickerValueSwatchProvider.create({ + id: state('color-picker-value-swatch'), + ref: state(null) + }); + const text = ColorPickerValueTextProvider.create({ + id: state('color-picker-value-text'), + ref: state(null), + format: state(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(document.createElement('div')) + }); + vi.spyOn(ColorPickerAreaProvider.ctx, 'get').mockReturnValue(area); + const thumb = ColorPickerAreaThumbProvider.create({ + id: state('color-picker-area-thumb'), + ref: state(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(sliderNode), + channel: state('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(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(null), + color: state('#ff0000') + }); + const green = ColorPickerSwatchTriggerProvider.create({ + id: state('color-picker-green'), + ref: state(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(); + }); +});