diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts new file mode 100644 index 000000000..662911d0d --- /dev/null +++ b/src/components/color-picker/color-picker.spec.ts @@ -0,0 +1,737 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy, stub } from 'sinon'; +import { + altKey, + arrowDown, + arrowUp, + escapeKey, +} from '../common/controllers/key-bindings.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { + createFormAssociatedTestBed, + isFocused, + runExternalLabelAssociationTests, + simulateClick, + simulateKeyboard, +} from '../common/utils.spec.js'; +import { + runValidationContainerTests, + type ValidationContainerTestsParams, + ValidityHelpers, +} from '../common/validity-helpers.spec.js'; +import type IgcInputComponent from '../input/input.js'; +import type IgcSelectComponent from '../select/select.js'; +import IgcColorPickerComponent from './color-picker.js'; +import { ColorModel } from './model.js'; +import type IgcPickerCanvasComponent from './picker-canvas.js'; + +async function createDefaultColorPicker() { + return await fixture( + html`` + ); +} + +function getAnchor(picker: IgcColorPickerComponent): HTMLElement { + return picker.renderRoot.querySelector('[part~="anchor"]')!; +} + +function isAnchorEmpty(picker: IgcColorPickerComponent): boolean { + return getAnchor(picker).part.contains('empty'); +} + +function getColorInput(picker: IgcColorPickerComponent): IgcInputComponent { + return picker.renderRoot.querySelector('#color-input')!; +} + +function commitColorInput(input: IgcInputComponent, value: string): void { + input.value = value; + input.dispatchEvent( + new CustomEvent('igcChange', { + detail: value, + bubbles: true, + composed: true, + }) + ); +} + +function getHueSlider(picker: IgcColorPickerComponent): HTMLInputElement { + return picker.renderRoot.querySelector('[part="hue"]')!; +} + +function getAlphaSlider(picker: IgcColorPickerComponent): HTMLInputElement { + return picker.renderRoot.querySelector('[part="alpha"]')!; +} + +function getAlphaInput(picker: IgcColorPickerComponent): IgcInputComponent { + return picker.renderRoot.querySelector('#alpha')!; +} + +function getCanvas(picker: IgcColorPickerComponent): IgcPickerCanvasComponent { + return picker.renderRoot.querySelector('igc-picker-canvas')!; +} + +function getFormatSelect(picker: IgcColorPickerComponent): IgcSelectComponent { + return picker.renderRoot.querySelector('#format-select')!; +} + +describe('Color picker', () => { + before(() => defineComponents(IgcColorPickerComponent)); + + let picker: IgcColorPickerComponent; + + describe('Default', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + }); + + it('is initialized', () => { + expect(picker).to.exist; + }); + + it('is accessible (close state)', async () => { + await expect(picker).shadowDom.to.be.accessible(); + await expect(picker).lightDom.to.be.accessible(); + }); + + it('is accessible (open state)', async () => { + picker.open = true; + await elementUpdated(picker); + + await expect(picker).shadowDom.to.be.accessible(); + await expect(picker).lightDom.to.be.accessible(); + }); + }); + + describe('API', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + }); + + it('`toggle()`', async () => { + await picker.toggle(); + expect(picker.open).to.be.true; + + await picker.toggle(); + expect(picker.open).to.be.false; + }); + }); + + describe('Empty value', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + }); + + it('has an empty value by default', () => { + expect(picker.value).to.equal(''); + }); + + it('renders a checkered anchor when empty', async () => { + expect(isAnchorEmpty(picker)).to.be.true; + + picker.value = '#ff0000'; + await elementUpdated(picker); + expect(isAnchorEmpty(picker)).to.be.false; + }); + + it('reverts to an empty value for null/undefined/empty', async () => { + for (const value of ['', null, undefined]) { + picker.value = '#ff0000'; + await elementUpdated(picker); + + picker.value = value as unknown as string; + await elementUpdated(picker); + + expect(picker.value).to.equal(''); + expect(isAnchorEmpty(picker)).to.be.true; + } + }); + }); + + describe('Color value input', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + picker.open = true; + await elementUpdated(picker); + }); + + it('commits a valid color', async () => { + commitColorInput(getColorInput(picker), '#00ff00'); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + }); + + it('reverts the input on an invalid color', async () => { + const input = getColorInput(picker); + commitColorInput(input, 'not-a-color'); + await elementUpdated(picker); + + expect(picker.value).to.equal('#ff0000'); + expect(input.value).to.equal('#ff0000'); + }); + + it('clears the value on an empty input', async () => { + const input = getColorInput(picker); + commitColorInput(input, ''); + await elementUpdated(picker); + + expect(picker.value).to.equal(''); + expect(isAnchorEmpty(picker)).to.be.true; + }); + }); + + describe('igcChange', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + await elementUpdated(picker); + }); + + it('emits when the value changed while focus was inside the component', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.value = '#00ff00'; + await elementUpdated(picker); + + picker.dispatchEvent(new FocusEvent('focusout', { relatedTarget: null })); + + expect(eventSpy).calledWith('igcChange', { detail: '#00ff00' }); + }); + + it('does not emit when the value did not change', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.dispatchEvent(new FocusEvent('focusout', { relatedTarget: null })); + + expect(eventSpy).not.calledWith('igcChange'); + }); + + it('does not emit when focus moves within the component', async () => { + const eventSpy = spy(picker, 'emitEvent'); + const anchor = getAnchor(picker); + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.value = '#00ff00'; + await elementUpdated(picker); + + picker.dispatchEvent( + new FocusEvent('focusout', { relatedTarget: anchor }) + ); + + expect(eventSpy).not.calledWith('igcChange'); + }); + }); + + describe('Color channels', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + picker.open = true; + await elementUpdated(picker); + }); + + it('updates the hue via the hue slider', async () => { + const inputSpy = spy(picker, 'emitEvent'); + const hue = getHueSlider(picker); + + hue.value = '120'; + hue.dispatchEvent(new Event('input', { bubbles: true })); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + expect(inputSpy).calledWith('igcInput', { detail: '#00ff00' }); + }); + + it('updates the alpha via the alpha slider', async () => { + picker.showAlpha = true; + picker.format = 'rgb'; + await elementUpdated(picker); + + const alpha = getAlphaSlider(picker); + alpha.value = '50'; + alpha.dispatchEvent(new Event('input', { bubbles: true })); + await elementUpdated(picker); + + expect(picker.value).to.equal('rgb(255 0 0 / 0.5)'); + }); + + it('updates the alpha via the alpha number input', async () => { + picker.showAlpha = true; + picker.format = 'rgb'; + await elementUpdated(picker); + + const alphaInput = getAlphaInput(picker); + alphaInput.dispatchEvent( + new CustomEvent('igcChange', { + detail: '0.25', + bubbles: true, + composed: true, + }) + ); + await elementUpdated(picker); + + expect(picker.value).to.equal('rgb(255 0 0 / 0.25)'); + }); + + it('picks a color from the canvas using HSV saturation/value', async () => { + const canvas = getCanvas(picker); + + canvas.dispatchEvent( + new CustomEvent('igcColorPicked', { + detail: { x: 50, y: 25 }, + }) + ); + await elementUpdated(picker); + + const expected = ColorModel.parse('#ff0000'); + expected.setSaturationAndValue(50, 75); + + expect(picker.value).to.equal(expected.asString('hex')); + // Hue is preserved by the HSV saturation/value update. + expect(expected.h).to.equal(ColorModel.parse('#ff0000').h); + }); + + it('updates the format via the format select', async () => { + const select = getFormatSelect(picker); + + select.dispatchEvent( + new CustomEvent('igcChange', { + detail: { value: 'rgb' } as unknown as IgcSelectComponent, + bubbles: true, + composed: true, + }) + ); + await elementUpdated(picker); + + expect(picker.format).to.equal('rgb'); + expect(getColorInput(picker).value).to.equal('rgb(255 0 0)'); + }); + }); + + describe('Swatches', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.open = true; + await elementUpdated(picker); + }); + + it('does not render swatches by default', () => { + expect(picker.renderRoot.querySelector('[part="swatches"]')).to.be.null; + }); + + it('renders and selects a swatch', async () => { + picker.swatches = ['#ff0000', '#00ff00']; + await elementUpdated(picker); + + const buttons = picker.renderRoot.querySelectorAll( + 'button[part="swatch"]' + ); + expect(buttons.length).to.equal(2); + expect(buttons[0].ariaLabel).to.equal('#ff0000'); + + const inputSpy = spy(picker, 'emitEvent'); + buttons[1].click(); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + expect(inputSpy).calledWith('igcInput', { detail: '#00ff00' }); + }); + }); + + describe('Copy and EyeDropper', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + picker.open = true; + await elementUpdated(picker); + }); + + it('copies the value to the clipboard', async () => { + const writeText = stub(navigator.clipboard, 'writeText').resolves(); + + picker.renderRoot.querySelector('[part="copy"]')!.click(); + + expect(writeText).calledWith('#ff0000'); + writeText.restore(); + }); + + describe('unsupported', () => { + let originalEyeDropper: unknown; + + beforeEach(() => { + originalEyeDropper = (globalThis as any).EyeDropper; + delete (globalThis as any).EyeDropper; + }); + + afterEach(() => { + (globalThis as any).EyeDropper = originalEyeDropper; + }); + + it('disables the eye dropper button', async () => { + picker = await createDefaultColorPicker(); + + const button = picker.renderRoot.querySelector('[part="eye-dropper"]')!; + expect(button.hasAttribute('disabled')).to.be.true; + }); + }); + + describe('supported', () => { + let originalEyeDropper: unknown; + + beforeEach(() => { + originalEyeDropper = (globalThis as any).EyeDropper; + (globalThis as any).EyeDropper = class { + public open() { + return Promise.resolve({ sRGBHex: '#112233' }); + } + }; + }); + + afterEach(() => { + (globalThis as any).EyeDropper = originalEyeDropper; + }); + + it('picks a color via the EyeDropper API', async () => { + picker = await createDefaultColorPicker(); + + const button = picker.renderRoot.querySelector( + '[part="eye-dropper"]' + )!; + expect(button.hasAttribute('disabled')).to.be.false; + + button.click(); + // Let the mocked EyeDropper's promise resolve. + await new Promise((resolve) => setTimeout(resolve)); + await elementUpdated(picker); + + expect(picker.value).to.equal('#112233'); + }); + }); + }); + + describe('Open and close', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + }); + + it('opens and closes via the anchor click', async () => { + const eventSpy = spy(picker, 'emitEvent'); + const anchor = getAnchor(picker); + + simulateClick(anchor); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); + expect(eventSpy).calledWith('igcOpened'); + + eventSpy.resetHistory(); + simulateClick(anchor); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); + }); + + it('closes and refocuses the anchor on Escape', async () => { + const anchor = getAnchor(picker); + picker.open = true; + await elementUpdated(picker); + + simulateKeyboard(picker, escapeKey); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(isFocused(anchor)).to.be.true; + }); + + it('opens with Alt+ArrowDown and closes with Alt+ArrowUp', async () => { + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); + expect(picker.open).to.be.true; + + simulateKeyboard(picker, [altKey, arrowUp]); + await elementUpdated(picker); + expect(picker.open).to.be.false; + }); + + it('skips keybindings when disabled', async () => { + picker.disabled = true; + await elementUpdated(picker); + + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + }); + }); + + describe('Input mode', () => { + function getInputAnchor(): IgcInputComponent { + return picker.renderRoot.querySelector( + 'igc-input[slot="anchor"]' + )!; + } + + beforeEach(async () => { + picker = await fixture( + html`` + ); + }); + + it('renders an input anchor instead of a button', () => { + expect(picker.renderRoot.querySelector('igc-button')).to.be.null; + expect(getInputAnchor()).to.exist; + }); + + it('opens the popover when the prefix swatch is clicked', async () => { + simulateClick(getAnchor(picker)); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + }); + + it('commits a color via the anchor input', async () => { + commitColorInput(getInputAnchor(), '#00ff00'); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + }); + + it('forwards required/invalid state to the anchor input', async () => { + picker.required = true; + await elementUpdated(picker); + expect(getInputAnchor().required).to.be.true; + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.dispatchEvent(new FocusEvent('focusout', { relatedTarget: null })); + picker.reportValidity(); + await elementUpdated(picker); + + expect(getInputAnchor().invalid).to.equal(picker.invalid); + }); + }); + + describe('Rendering', () => { + it('renders the label only in default mode with a label set', async () => { + picker = await createDefaultColorPicker(); + expect( + picker.renderRoot.querySelector('[part="label"]')?.textContent + ).to.equal('Choose a color'); + + picker.label = undefined; + await elementUpdated(picker); + expect(picker.renderRoot.querySelector('[part="label"]')).to.be.null; + }); + + it('does not render a label element in input mode', async () => { + picker = await fixture( + html`` + ); + expect(picker.renderRoot.querySelector('[part="label"]')).to.be.null; + }); + + it('reflects disabled onto the button anchor', async () => { + picker = await createDefaultColorPicker(); + picker.disabled = true; + await elementUpdated(picker); + + expect(getAnchor(picker).hasAttribute('disabled')).to.be.true; + }); + + it('hides the format select when hideFormats is set', async () => { + picker = await createDefaultColorPicker(); + picker.open = true; + await elementUpdated(picker); + expect(picker.renderRoot.querySelector('#format-select')).to.exist; + + picker.hideFormats = true; + await elementUpdated(picker); + expect(picker.renderRoot.querySelector('#format-select')).to.be.null; + }); + }); + + describe('Form associated', () => { + const spec = createFormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('is form associated', () => { + expect(spec.element.form).to.equal(spec.form); + }); + + it('is not associated on submit if no value', async () => { + expect(spec.submit()?.get(spec.element.name)).to.be.null; + }); + + it('is associated on submit', () => { + spec.setProperties({ value: '#bada55' }); + spec.assertSubmitHasValue('#bada55'); + }); + + it('is correctly reset on form reset', () => { + spec.setProperties({ value: '#bada55' }); + + spec.reset(); + expect(spec.element.value).to.equal(''); + }); + + it('reflects disabled ancestor state', () => { + spec.setAncestorDisabledState(true); + expect(spec.element.disabled).to.be.true; + + spec.setAncestorDisabledState(false); + expect(spec.element.disabled).to.be.false; + }); + + it('fulfils required constraint', () => { + spec.setProperties({ required: true }); + spec.assertSubmitFails(); + + spec.setProperties({ value: '#bada55' }); + spec.assertSubmitPasses(); + }); + + it('fulfils custom constraint', () => { + spec.element.setCustomValidity('invalid'); + spec.assertSubmitFails(); + + spec.element.setCustomValidity(''); + spec.assertSubmitPasses(); + }); + }); + + describe('Touched state', () => { + const spec = createFormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('marks the control as touched on blur', () => { + // biome-ignore lint/complexity/useLiteralKeys: internal state check + expect((spec.element as any)['_touched']).to.be.false; + + spec.element.dispatchEvent( + new FocusEvent('focusin', { relatedTarget: null }) + ); + spec.element.dispatchEvent( + new FocusEvent('focusout', { relatedTarget: null }) + ); + + // biome-ignore lint/complexity/useLiteralKeys: internal state check + expect((spec.element as any)['_touched']).to.be.true; + }); + + it('clears invalid styles after a form reset', async () => { + spec.setProperties({ required: true }); + await elementUpdated(spec.element); + + spec.element.dispatchEvent( + new FocusEvent('focusin', { relatedTarget: null }) + ); + spec.element.dispatchEvent( + new FocusEvent('focusout', { relatedTarget: null }) + ); + await elementUpdated(spec.element); + + spec.assertSubmitFails(); + await elementUpdated(spec.element); + ValidityHelpers.hasInvalidStyles(spec.element).to.be.true; + + spec.reset(); + await elementUpdated(spec.element); + ValidityHelpers.hasInvalidStyles(spec.element).to.be.false; + }); + }); + + describe('defaultValue', () => { + const defaultValue = '#bada55'; + const spec = createFormAssociatedTestBed(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('correct initial state', () => { + spec.assertIsPristine(); + expect(spec.element.value).to.equal(defaultValue); + }); + + it('is correctly submitted', () => { + spec.assertSubmitHasValue(defaultValue); + }); + + it('is correctly reset on form reset', () => { + spec.setProperties({ value: '#ff0000' }); + + spec.reset(); + expect(spec.element.value).to.equal(defaultValue); + }); + }); + + describe('Validation', () => { + const spec = createFormAssociatedTestBed(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('fails required validation', () => { + spec.setProperties({ required: true }); + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes required validation when updating defaultValue', () => { + spec.setProperties({ required: true, defaultValue: '#bada55' }); + spec.assertIsPristine(); + spec.assertSubmitPasses(); + }); + }); + + describe('Validation message slots', () => { + it('renders validation message slots', () => { + const testParameters: ValidationContainerTestsParams[] = + [ + { slots: ['valueMissing'], props: { required: true } }, + { slots: ['customError'] }, + { slots: ['invalid'], props: { required: true } }, + ]; + + runValidationContainerTests(IgcColorPickerComponent, testParameters); + }); + }); + + runExternalLabelAssociationTests({ + tagName: IgcColorPickerComponent.tagName, + hostAttributes: 'mode="input"', + getNativeInput: (host) => + (host as IgcColorPickerComponent).renderRoot + .querySelector('igc-input')! + .renderRoot.querySelector('input')!, + }); +}); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts new file mode 100644 index 000000000..d68957dd3 --- /dev/null +++ b/src/components/color-picker/color-picker.ts @@ -0,0 +1,760 @@ +import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import IgcButtonComponent from '../button/button.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { + addKeybindings, + altKey, + arrowDown, + arrowUp, + escapeKey, +} from '../common/controllers/key-bindings.js'; +import { addRootClickController } from '../common/controllers/root-click.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; +import { shadowOptions } from '../common/decorators/shadow-options.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { IgcBaseComboBoxComponent } from '../common/mixins/combo-box.js'; +import type { AbstractConstructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { FormAssociatedRequiredMixin } from '../common/mixins/forms/associated-required.js'; +import { createFormValueState } from '../common/mixins/forms/form-value.js'; +import { partMap } from '../common/part-map.js'; +import { + addSafeEventListener, + asNumber, + bindIf, + getElementFromPath, + isEmpty, + stopPropagation, +} from '../common/util.js'; +import IgcDividerComponent from '../divider/divider.js'; +import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; +import IgcInputComponent from '../input/input.js'; +import IgcPopoverComponent from '../popover/popover.js'; +import IgcSelectComponent from '../select/select.js'; +import type IgcSelectItemComponent from '../select/select-item.js'; +import IgcValidationContainerComponent from '../validation-container/validation-container.js'; +import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; +import { isValidColor } from './common.js'; +import { ColorModel, getContext } from './model.js'; +import IgcPickerCanvasComponent, { + type PickerCanvasEventDetail, +} from './picker-canvas.js'; +import { styles } from './themes/color-picker.base.css.js'; +import { colorPickerValidators } from './validators.js'; + +export interface IgcColorPickerEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; + igcInput: CustomEvent; + igcChange: CustomEvent; +} + +const Slots = setSlots( + 'value-missing', + 'custom-error', + 'invalid', + 'helper-text' +); + +/** + * Color input component. + * + * Lets the user pick a color visually - via an HSV saturation/value canvas, a + * hue slider and an optional alpha slider - or by typing a color string + * (hex, rgb(a), hsl(a) or a named CSS color) directly. Supports pre-defined + * swatches, the native EyeDropper API where available, and two anchor + * presentations: a trigger button (`mode="default"`) or an editable text + * field (`mode="input"`). + * + * @element igc-color-picker + * + * @slot value-missing - Renders content when the required validation fails. + * @slot custom-error - Renders content when setCustomValidity(message) is set. + * @slot invalid - Renders content when the component is in invalid state (validity.valid = false). + * @slot helper-text - Renders content below the picker. + * + * @fires igcOpening - Emitted just before the picker dropdown is open. + * @fires igcOpened - Emitted after the picker dropdown is open. + * @fires igcClosing - Emitted just before the picker dropdown is closed. + * @fires igcClosed - Emitted after closing the picker dropdown. + * @fires igcInput - Emitted when the value of the component is changed. + * @fires igcChange - Emitted when the value of the component is committed. + * + * @csspart anchor - The trigger element that opens the picker (the button in default mode, or the swatch prefix in input mode). + * @csspart empty - Applied alongside `anchor` when no color value is set, rendering a checkered background. + * @csspart label - The label rendered above the anchor in default mode. + * @csspart picker - The popover container holding the canvas, sliders, inputs and swatches. + * @csspart main-row - The row containing the hue slider and the copy/eyedropper buttons. + * @csspart alpha-row - The row containing the alpha slider and input, rendered when `show-alpha` is set. + * @csspart inputs-row - The row containing the format select and the color value input. + * @csspart buttons - The wrapper around the copy and eyedropper buttons. + * @csspart hue - The hue slider. + * @csspart alpha - The alpha slider. + * @csspart copy - The button that copies the current color value to the clipboard. + * @csspart eye-dropper - The button that activates the EyeDropper API. + * @csspart format-select - The select control used to switch the color string format. + * @csspart swatches - The container of the pre-defined color swatches. + * @csspart swatch - An individual color swatch button. + */ +@shadowOptions({ delegatesFocus: true }) +export default class IgcColorPickerComponent extends FormAssociatedRequiredMixin( + EventEmitterMixin< + IgcColorPickerEventMap, + AbstractConstructor + >(IgcBaseComboBoxComponent) +) { + public static readonly tagName = 'igc-color-picker'; + public static styles = styles; + + /* blazorSuppress */ + public static register(): void { + registerComponent( + IgcColorPickerComponent, + IgcInputComponent, + IgcPopoverComponent, + IgcFocusTrapComponent, + IgcSelectComponent, + IgcPickerCanvasComponent, + IgcDividerComponent, + IgcButtonComponent, + IgcIconButtonComponent, + IgcValidationContainerComponent, + IgcVisuallyHiddenComponent + ); + } + + //#region Internal state and properties + + protected override get __validators() { + return colorPickerValidators; + } + + protected readonly _slots = addSlotController(this, { slots: Slots }); + + protected override readonly _rootClickController = addRootClickController( + this, + { + onHide: this._handleClosing, + } + ); + + protected override readonly _formValue = createFormValueState(this, { + initialValue: '', + }); + + private readonly _alphaRef = createRef(); + private readonly _canvasRef = createRef(); + private readonly _hueRef = createRef(); + private readonly _anchorRef = createRef< + IgcButtonComponent | IgcInputComponent + >(); + + private _supportsEyeDropper = 'EyeDropper' in globalThis; + private _color = ColorModel.empty(); + private _oldValue = ''; + + @state() + private _ownCurrentColor = ''; + + //#endregion + + //#region Public attributes and properties + + /** + * The label of the component. + * + * In `mode="input"` this is forwarded to the anchor input's own label + * instead of being rendered as a separate element. + * @attr label + */ + @property() + public label?: string; + + /** + * The value of the component, as a CSS color string (hex, rgb(a), hsl(a) + * or a named color). + * + * Setting an empty, whitespace-only or otherwise invalid string clears + * the value. + * + * @attr value + */ + @property() + public set value(value: string) { + this._color = ColorModel.parse(value); + this._updateColor(); + } + + public get value(): string { + return this._formValue.value; + } + + /** + * Sets the color format for the string value. + * + * @attr format + * @default 'hex' + */ + @property() + public format: 'hex' | 'rgb' | 'hsl' = 'hex'; + + /** + * Whether to hide the format picker buttons. + * + * @attr hide-formats + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'hide-formats' }) + public hideFormats = false; + + /** + * Whether to show the alpha slider and input. + * + * @attr show-alpha + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'show-alpha' }) + public showAlpha = false; + + /** + * The mode of the color picker. + * + * In `"default"` mode the anchor is a trigger button. In `"input"` mode + * the anchor is an editable text field with a color swatch prefix that + * also opens the picker. + * + * @attr mode + * @default 'default' + */ + @property() + public mode: 'default' | 'input' = 'default'; + + /** + * Pre-defined color strings rendered as clickable swatches below the + * picker controls. Clicking a swatch commits its color as the value. + */ + @property({ attribute: false }) + public swatches: string[] = []; + + //#endregion + + //#region Lifecycle + + constructor() { + super(); + + addSafeEventListener(this, 'focusin', this._handleFocusIn); + addSafeEventListener(this, 'focusout', this._handleFocusOut); + + addKeybindings(this, { skip: () => this.disabled }) + .set(escapeKey, this._handleKeyboardClosing) + .set([altKey, arrowDown], this._handleAnchorClick) + .set([altKey, arrowUp], this._handleKeyboardClosing); + } + + protected override update(props: PropertyValues): void { + if (props.has('open')) { + this._rootClickController.update(); + } + + super.update(props); + } + + protected override updated(properties: PropertyValues): void { + if (properties.has('open') || properties.has('value')) { + // Wait until the browser paints and then sync the marker position with the color. + requestAnimationFrame(() => this._syncCanvasPosition()); + } + this._forwardLabelElements(); + } + + protected override _restoreDefaultValue(): void { + super._restoreDefaultValue(); + this._color = ColorModel.parse(this._formValue.value); + this._updateColor(); + this._syncCanvasPosition(); + } + + //#endregion + + //#region Event handlers + + private _handleClosing(): void { + this._hide(true); + } + + private async _handleKeyboardClosing(): Promise { + if (await this._hide(true)) { + this._anchorRef.value?.focus(); + } + } + + private _handleFocusIn({ relatedTarget }: FocusEvent): void { + if (!this.contains(relatedTarget as Node)) { + this._oldValue = this.value; + } + } + + private _handleFocusOut({ relatedTarget }: FocusEvent): void { + if (this.contains(relatedTarget as Node)) { + return; + } + + this._handleBlur(); + + if (this.value !== this._oldValue) { + this._oldValue = this.value; + this.emitEvent('igcChange', { detail: this.value }); + } + } + + private _handleCanvasColorPicked( + event: CustomEvent + ): void { + this._color.setSaturationAndValue(event.detail.x, 100 - event.detail.y); + this._updateColor(); + this._emitInputEvent(); + } + + private _handleHueValueChange(event: Event): void { + stopPropagation(event); + + this._color.h = asNumber(this._hueRef.value?.value); + this._updateColor(); + this._emitInputEvent(); + } + + private _handleAlphaSliderValueChange(event: Event): void { + stopPropagation(event); + + this._color.alpha = asNumber(this._alphaRef.value?.value) / 100; + this._updateColor(); + this._emitInputEvent(); + } + + private _handleAlphaInputChange(event: CustomEvent): void { + stopPropagation(event); + + this._color.alpha = asNumber(event.detail); + this._updateColor(); + this._emitInputEvent(); + } + + private _handleFormatChange( + event: CustomEvent + ): void { + stopPropagation(event); + + this.format = event.detail.value as typeof this.format; + this._updateColor(); + } + + private _handleColorInputChange(event: CustomEvent): void { + stopPropagation(event); + + const input = event.target as IgcInputComponent; + const value = event.detail; + const cleared = !value?.trim(); + + // A non-empty but invalid value reverts the input back to the currently + // represented color. An empty value clears it. + if (!cleared && !isValidColor(value, getContext())) { + input.value = this._color.asString(this.format); + return; + } + + this._color = cleared ? ColorModel.empty() : ColorModel.parse(value); + this._updateColor(); + this._syncCanvasPosition(); + } + + private _handleEyeDropperClick(): void { + if (!this._supportsEyeDropper) return; + + const eyeDropper = new (globalThis as any).EyeDropper(); + + eyeDropper + .open() + .then((result: { sRGBHex: string }) => { + this.value = result.sRGBHex; + this._syncCanvasPosition(); + this._emitInputEvent(); + }) + .catch(() => {}); + } + + private _handleCopy(): void { + navigator.clipboard?.writeText(this.value).catch(() => {}); + } + + private _handleSwatchClick(event: Event): void { + const color = getElementFromPath('button[part="swatch"]', event)?.ariaLabel; + + if (color) { + this.value = color; + this._syncCanvasPosition(); + this._emitInputEvent(); + } + } + + //#endregion + + //#region Internal methods + + private _updateColor(): void { + this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; + this.style.setProperty('--current-color', this._ownCurrentColor); + this._formValue.setValueAndFormState(this._color.asString(this.format)); + this._validate(); + this.requestUpdate(); + } + + private _syncCanvasPosition(): void { + if (!this._canvasRef.value || !this.open) return; + + const rect = this._canvasRef.value.getBoundingClientRect(); + const { width: markerWidth, height: markerHeight } = + this._canvasRef.value.getMarkerDimensions(); + + const [, s, v] = this._color.toHSV(); + const x = (s / 100) * rect.width - markerWidth; + const y = ((100 - v) / 100) * rect.height - markerHeight; + + this._canvasRef.value.x = x; + this._canvasRef.value.y = y; + } + + private _emitInputEvent(): void { + this.emitEvent('igcInput', { detail: this.value }); + } + + private _forwardLabelElements(): void { + if (this.mode === 'input' && this._anchorRef.value) { + const input = this._anchorRef.value as IgcInputComponent; + input._labelElements = this._internals.labels; + } + } + + //#endregion + + //#region Canvas area rendering + + private _renderCanvasGradient(): TemplateResult { + return html` + + + `; + } + + //#endregion + + //#region Hue row and buttons rendering + + private _renderHueSlider(): TemplateResult { + return html` + + `; + } + + private _renderCopyButton(): TemplateResult { + const style = styleMap({ + '--current-color': this._color.asString('rgb', true), + '--border-color': 'transparent', + }); + + return html` + + Copy color value to clipboard + + `; + } + + private _renderEyeDropperButton(): TemplateResult { + return html` + + + + Pick a color from the screen + + `; + } + + private _renderHueRowAndButtons(): TemplateResult { + return html` + ${this._renderHueSlider()} +
+ ${this._renderCopyButton()} ${this._renderEyeDropperButton()} +
+ `; + } + + //#endregion + + //#region Alpha row rendering + + private _renderAlphaRow(): TemplateResult | typeof nothing { + return this.showAlpha + ? html` + + + + + + + + ` + : nothing; + } + + //#endregion + + //#region Color formats and input row rendering + + private _renderSelect(): TemplateResult { + return html` + + + + + + Hex + RGB + HSL + + `; + } + + private _renderFormats(): TemplateResult { + return html`${cache(this.hideFormats ? nothing : this._renderSelect())}`; + } + + private _renderInputsRow(): TemplateResult { + return html` + ${this._renderFormats()} + + + + + + `; + } + + //#endregion + + //#region Swatches rendering + + private _renderSwatches(): TemplateResult | typeof nothing { + return !isEmpty(this.swatches) + ? html` + +
+ ${this.swatches.map( + (color) => html` + + ` + )} +
+ ` + : nothing; + } + + //#endregion + + //#region Anchor rendering + + private _renderButtonAnchor( + color: string, + parts: ReturnType + ): TemplateResult { + return html` + + Open color picker + + `; + } + + private _renderInputAnchor( + color: string, + parts: ReturnType + ): TemplateResult { + return html` + +
+
+ `; + } + + private _renderAnchor( + color: string, + parts: ReturnType, + isDefaultMode: boolean + ): TemplateResult { + return isDefaultMode + ? this._renderButtonAnchor(color, parts) + : this._renderInputAnchor(color, parts); + } + + private _renderHelperText(): TemplateResult { + return IgcValidationContainerComponent.create(this, { + id: 'color-picker-helper-text', + slot: 'anchor', + hasHelperText: true, + }); + } + + //#endregion + + private _renderPicker(): TemplateResult { + return html` + +
+ ${this._renderCanvasGradient()} +
${this._renderHueRowAndButtons()}
+
${this._renderAlphaRow()}
+
${this._renderInputsRow()}
+ ${this._renderSwatches()} +
+
+ `; + } + + protected override render(): TemplateResult { + const color = this._color.asString('rgb', true); + const parts = partMap({ anchor: true, empty: this._color.isEmpty }); + const isDefaultMode = this.mode === 'default'; + + return html` +
+ ${ + isDefaultMode && this.label + ? html`` + : nothing + } + + ${this._renderAnchor(color, parts, isDefaultMode)}${this._renderHelperText()}${this._renderPicker()} + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-color-picker': IgcColorPickerComponent; + } +} diff --git a/src/components/color-picker/common.spec.ts b/src/components/color-picker/common.spec.ts new file mode 100644 index 000000000..5a524ced9 --- /dev/null +++ b/src/components/color-picker/common.spec.ts @@ -0,0 +1,211 @@ +import { expect } from '@open-wc/testing'; + +import { isValidColor, type ParsedColor, parseColor } from './common.js'; + +function makeTestContext() { + try { + return new OffscreenCanvas(0, 0).getContext('2d'); + } catch { + return null; + } +} + +describe('parseColor', () => { + let ctx: OffscreenCanvasRenderingContext2D | null; + + before(() => { + ctx = makeTestContext(); + }); + + describe('null context handling', () => { + it('should return default color when context is null', () => { + const result = parseColor('#ff0000', null); + + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); + }); + + it('should return default color when color string is empty', () => { + const result = parseColor('', ctx); + + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); + }); + }); + + describe('hex color parsing', () => { + it('should parse 6-digit hex colors', () => { + const result = parseColor('#ff8040', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.equal(1); + }); + + it('should parse 3-digit hex colors', () => { + const result = parseColor('#f80', ctx); + + expect(result.value[0]).to.equal(255); + expect(result.value[1]).to.equal(136); + expect(result.value[2]).to.equal(0); + expect(result.alpha).to.equal(1); + }); + + it('should parse 8-digit hex colors with alpha', () => { + const result = parseColor('#ff804080', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.be.closeTo(0.5, 0.01); + }); + + it('should parse hex colors without hash', () => { + const result = parseColor('ff8040', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.equal(1); + }); + }); + + describe('rgb/rgba color parsing', () => { + it('should parse rgb colors', () => { + const result = parseColor('rgb(255, 128, 64)', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.equal(1); + }); + + it('should parse rgba colors with alpha', () => { + const result = parseColor('rgba(255, 128, 64, 0.75)', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.equal(0.75); + }); + + it('should parse rgb with spaces', () => { + const result = parseColor('rgb( 255 , 128 , 64 )', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.equal(1); + }); + + it('should parse rgba with zero alpha', () => { + const result = parseColor('rgba(255, 128, 64, 0)', ctx); + + expect(result.value).to.deep.equal([255, 128, 64]); + expect(result.alpha).to.equal(0); + }); + }); + + describe('named color parsing', () => { + it('should parse red', () => { + const result = parseColor('red', ctx); + + expect(result.value).to.deep.equal([255, 0, 0]); + expect(result.alpha).to.equal(1); + }); + + it('should parse white', () => { + const result = parseColor('white', ctx); + + expect(result.value).to.deep.equal([255, 255, 255]); + expect(result.alpha).to.equal(1); + }); + + it('should parse black', () => { + const result = parseColor('black', ctx); + + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); + }); + + it('should parse transparent', () => { + const result = parseColor('transparent', ctx); + + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(0); + }); + }); + + describe('hsl/hsla color parsing', () => { + it('should parse hsl colors', () => { + const result = parseColor('hsl(0, 100%, 50%)', ctx); + + expect(result.value).to.deep.equal([255, 0, 0]); + expect(result.alpha).to.equal(1); + }); + + it('should parse hsla colors with alpha', () => { + const result = parseColor('hsla(120, 100%, 50%, 0.5)', ctx); + + expect(result.value).to.deep.equal([0, 255, 0]); + expect(result.alpha).to.equal(0.5); + }); + }); + + describe('edge cases', () => { + it('should handle invalid color strings gracefully', () => { + // Invalid colors are rejected before parsing, always returning the + // deterministic default result. + const result = parseColor('not-a-color', ctx); + + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); + }); + + it('should handle malformed hex colors gracefully', () => { + // Malformed hex colors are rejected before parsing, always returning + // the deterministic default result. + const result = parseColor('#zzz', ctx); + + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); + }); + + it('should return correct type', () => { + const result: ParsedColor = parseColor('#ff0000', ctx); + + expect(result).to.have.property('value'); + expect(result).to.have.property('alpha'); + expect(Array.isArray(result.value)).to.be.true; + expect(result.value.length).to.equal(3); + }); + }); +}); + +describe('isValidColor', () => { + let ctx: OffscreenCanvasRenderingContext2D | null; + + before(() => { + ctx = makeTestContext(); + }); + + it('should return true for valid hex colors', () => { + expect(isValidColor('#ff0000', ctx)).to.be.true; + expect(isValidColor('#f80', ctx)).to.be.true; + expect(isValidColor('#ff000080', ctx)).to.be.true; + }); + + it('should return true for valid rgb/rgba colors', () => { + expect(isValidColor('rgb(0, 128, 255)', ctx)).to.be.true; + expect(isValidColor('rgba(0, 128, 255, 0.5)', ctx)).to.be.true; + }); + + it('should return true for valid named colors', () => { + expect(isValidColor('red', ctx)).to.be.true; + expect(isValidColor('rebeccapurple', ctx)).to.be.true; + }); + + it('should return false for invalid colors', () => { + expect(isValidColor('not-a-color', ctx)).to.be.false; + expect(isValidColor('#zzz', ctx)).to.be.false; + expect(isValidColor('rgb(300)', ctx)).to.be.false; + }); + + it('should return false for empty or whitespace strings', () => { + expect(isValidColor('', ctx)).to.be.false; + expect(isValidColor(' ', ctx)).to.be.false; + }); + + it('should return false when context is null', () => { + expect(isValidColor('#ff0000', null)).to.be.false; + }); +}); diff --git a/src/components/color-picker/common.ts b/src/components/color-picker/common.ts new file mode 100644 index 000000000..16ec2bcfe --- /dev/null +++ b/src/components/color-picker/common.ts @@ -0,0 +1,104 @@ +import { asNumber } from '../common/util.js'; +import type { RGB } from './converters.js'; + +export const RGBA_RE = + /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i; +export const HEX_RE = /.{2}/g; +const HEX_WITHOUT_HASH_RE = /^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i; + +export interface ParsedColor { + value: RGB; + alpha: number; +} + +/** + * Parses a color string into RGB values and alpha channel. + * Supports hex, rgb, rgba, hsl, hsla, and named color formats. + * + * @param colorString - The color string to parse + * @param ctx - Optional canvas context for color parsing. If not provided, returns default black color. + * @returns Object containing RGB values and alpha channel + */ +export function parseColor( + colorString: string, + ctx: OffscreenCanvasRenderingContext2D | null +): ParsedColor { + const result: ParsedColor = { + value: [0, 0, 0], + alpha: 1, + }; + + if (!colorString || !ctx) { + return result; + } + + const trimmed = colorString.trim(); + const normalized = HEX_WITHOUT_HASH_RE.test(trimmed) + ? `#${trimmed}` + : trimmed; + + if (!isValidColor(normalized, ctx)) { + return result; + } + + // Trigger parsing through canvas context + ctx.fillStyle = normalized; + const color = ctx.fillStyle; + + const rgbaMatch = RGBA_RE.exec(color); + + if (rgbaMatch) { + const [r, g, b, a] = rgbaMatch.slice(3).map((part) => asNumber(part)); + result.value = [r, g, b]; + result.alpha = a ?? 1; + } else { + // Parse hex color + const hexValue = color.replace('#', ''); + const matches = hexValue.match(HEX_RE); + + if (!matches) { + return result; + } + + const [r, g, b, a] = matches.map((part) => Number.parseInt(part, 16)); + result.value = [r, g, b]; + + // Handle 8-digit hex with alpha channel + if (matches.length === 4 && a !== undefined) { + result.alpha = a / 255; + } + } + + return result; +} + +/** + * Determines whether a given string is a valid CSS color. + * + * Uses the canvas 2D context to attempt parsing the string against two + * different baseline colors. A valid color resolves to the same computed value + * regardless of the baseline, while an invalid color leaves each baseline + * untouched and therefore produces two different results. + * + * @param colorString - The color string to validate + * @param ctx - Canvas context used for parsing + * @returns `true` if the string is a valid, non-empty CSS color + */ +export function isValidColor( + colorString: string, + ctx: OffscreenCanvasRenderingContext2D | null +): boolean { + if (!colorString?.trim() || !ctx) { + return false; + } + + ctx.fillStyle = '#000'; + ctx.fillStyle = colorString; + const onBlack = ctx.fillStyle; + + ctx.fillStyle = '#fff'; + ctx.fillStyle = colorString; + const onWhite = ctx.fillStyle; + + return onBlack === onWhite; +} diff --git a/src/components/color-picker/converters.ts b/src/components/color-picker/converters.ts new file mode 100644 index 000000000..57716d482 --- /dev/null +++ b/src/components/color-picker/converters.ts @@ -0,0 +1,192 @@ +const ONE_THIRD = 1 / 3; +const TWO_THIRDS = 2 / 3; + +export type RGB = [number, number, number]; +export type HSL = [number, number, number]; +export type HSV = [number, number, number]; + +export const converter = Object.freeze({ + rgb: { + hex: (rgb: RGB): string => { + const [r, g, b] = rgb.map((v) => + Math.min(255, Math.max(0, Math.round(v))) + ); + const value = (r << 16) + (g << 8) + b; + return value.toString(16).padStart(6, '0'); + }, + hsl: (rgb: RGB): HSL => { + const [r, g, b] = rgb.map((v) => v / 255); + const min = Math.min(r, g, b); + const max = Math.max(r, g, b); + const delta = max - min; + let h = 0; + let s: number; + + if (max === min) { + h = 0; + } else if (r === max) { + h = (g - b) / delta; + } else if (g === max) { + h = 2 + (b - r) / delta; + } else if (b === max) { + h = 4 + (r - g) / delta; + } + + h = Math.min(h * 60, 360); + + if (h < 0) { + h += 360; + } + + const l = (min + max) / 2; + + if (max === min) { + s = 0; + } else if (l <= 0.5) { + s = delta / (max + min); + } else { + s = delta / (2 - max - min); + } + + return [h, s * 100, l * 100]; + }, + hsv: (rgb: RGB): HSV => { + const [r, g, b] = rgb.map((v) => v / 255); + const v = Math.max(r, g, b); + const diff = v - Math.min(r, g, b); + const calc = (c: number) => (v - c) / 6 / diff + 1 / 2; + + let h = 0; + let s = 0; + + if (diff > 0) { + s = diff / v; + const rDiff = calc(r); + const gDiff = calc(g); + const bDiff = calc(b); + + if (r === v) { + h = bDiff - gDiff; + } else if (g === v) { + h = ONE_THIRD * rDiff - bDiff; + } else if (b === v) { + h = TWO_THIRDS + gDiff - rDiff; + } + + if (h < 0) { + h += 1; + } else if (h > 1) { + h -= 1; + } + } + + return [h * 360, s * 100, v * 100]; + }, + }, + hsl: { + rgb: (hsl: HSL): RGB => { + const h = hsl[0] / 360; + const s = hsl[1] / 100; + const l = hsl[2] / 100; + + if (s === 0) { + const val = l * 255; + return [val, val, val]; + } + + let t3: number; + let val: number; + const t2 = l < 0.5 ? l * (1 + s) : l + s - l * s; + const t1 = 2 * l - t2; + const rgb: RGB = [0, 0, 0]; + + for (let i = 0; i < 3; i++) { + t3 = h + ONE_THIRD * -(i - 1); + if (t3 < 0) { + t3++; + } + + if (t3 > 1) { + t3--; + } + + if (6 * t3 < 1) { + val = t1 + (t2 - t1) * 6 * t3; + } else if (2 * t3 < 1) { + val = t2; + } else if (3 * t3 < 2) { + val = t1 + (t2 - t1) * (TWO_THIRDS - t3) * 6; + } else { + val = t1; + } + + rgb[i] = val * 255; + } + + return rgb; + }, + hsv: (hsl: HSL): HSV => { + const h = hsl[0]; + let s = hsl[1] / 100; + let l = hsl[2] / 100; + let sMin = s; + const lMin = Math.max(l, 0.01); + + l *= 2; + s *= lMin <= 1 ? l : 2 - l; + sMin *= lMin <= 1 ? lMin : 2 - lMin; + const v = (l + s) / 2; + const sv = l === 0 ? (2 * sMin) / (lMin + sMin) : (2 * s) / (l + s); + + return [h, sv * 100, v * 100]; + }, + }, + hsv: { + rgb: (hsv: HSV): RGB => { + const h = hsv[0] / 60; + const s = hsv[1] / 100; + let v = hsv[2] / 100; + const hi = Math.floor(h) % 6; + + const f = h - Math.floor(h); + const p = 255 * v * (1 - s); + const q = 255 * v * (1 - s * f); + const t = 255 * v * (1 - s * (1 - f)); + v *= 255; + + switch (hi) { + case 0: + return [v, t, p]; + case 1: + return [q, v, p]; + case 2: + return [p, v, t]; + case 3: + return [p, q, v]; + case 4: + return [t, p, v]; + case 5: + return [v, p, q]; + default: + return [v, t, p]; + } + }, + hsl: (hsv: HSV): HSL => { + const h = hsv[0]; + const s = hsv[1] / 100; + const v = hsv[2] / 100; + const vMin = Math.max(v, 0.01); + let sl: number; + let l: number; + + l = (2 - s) * v; + const lMin = (2 - s) * vMin; + sl = s * vMin; + sl /= lMin <= 1 ? lMin : 2 - lMin; + sl = sl || 0; + l /= 2; + + return [h, sl * 100, l * 100]; + }, + }, +}); diff --git a/src/components/color-picker/model.spec.ts b/src/components/color-picker/model.spec.ts new file mode 100644 index 000000000..a4d691cef --- /dev/null +++ b/src/components/color-picker/model.spec.ts @@ -0,0 +1,589 @@ +import { expect } from '@open-wc/testing'; + +import { ColorModel } from './model.js'; + +describe('ColorModel', () => { + describe('constructor and factory methods', () => { + it('should create a default black color', () => { + const color = ColorModel.default(); + + expect(color.r).to.equal(0); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + expect(color.alpha).to.equal(1); + expect(color.asString('hex')).to.equal('#000000'); + expect(color.isEmpty).to.be.false; + }); + + it('should create an empty color', () => { + const color = ColorModel.empty(); + + expect(color.isEmpty).to.be.true; + expect(color.asString('hex')).to.equal(''); + expect(color.asString('rgb')).to.equal(''); + expect(color.asString('hsl')).to.equal(''); + }); + + it('should clear the empty state when a channel is modified', () => { + const color = ColorModel.empty(); + color.r = 128; + + expect(color.isEmpty).to.be.false; + expect(color.asString('hex')).to.equal('#800000'); + }); + + it('should preserve the empty state when cloned', () => { + const color = ColorModel.empty(); + const clone = color.clone(); + + expect(clone.isEmpty).to.be.true; + expect(clone.equals(color)).to.be.true; + }); + + it('should treat empty and non-empty colors as not equal', () => { + expect(ColorModel.empty().equals(ColorModel.default())).to.be.false; + }); + + it('should create a color from RGB values', () => { + const color = new ColorModel([255, 0, 0]); + + expect(color.r).to.equal(255); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + expect(color.alpha).to.equal(1); + }); + + it('should create a color with alpha channel', () => { + const color = new ColorModel([255, 0, 0], 0.5); + + expect(color.r).to.equal(255); + expect(color.alpha).to.equal(0.5); + }); + + it('should clamp alpha values to 0-1 range', () => { + const colorNegative = new ColorModel([255, 0, 0], -0.5); + const colorOverOne = new ColorModel([255, 0, 0], 1.5); + + expect(colorNegative.alpha).to.equal(0); + expect(colorOverOne.alpha).to.equal(1); + }); + }); + + describe('parse', () => { + it('should parse hex colors', () => { + const color = ColorModel.parse('#ff0000'); + + expect(color.r).to.equal(255); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + }); + + it('should parse hex colors with alpha', () => { + const color = ColorModel.parse('#ff000080'); + + expect(color.r).to.equal(255); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + expect(color.alpha).to.be.closeTo(0.5, 0.01); + }); + + it('should parse rgb colors', () => { + const color = ColorModel.parse('rgb(0, 255, 0)'); + + expect(color.r).to.equal(0); + expect(color.g).to.equal(255); + expect(color.b).to.equal(0); + }); + + it('should parse rgba colors', () => { + const color = ColorModel.parse('rgba(0, 0, 255, 0.75)'); + + expect(color.r).to.equal(0); + expect(color.g).to.equal(0); + expect(color.b).to.equal(255); + expect(color.alpha).to.equal(0.75); + }); + + it('should parse named colors', () => { + const color = ColorModel.parse('red'); + + expect(color.r).to.equal(255); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + }); + + it('should return an empty color for an empty string', () => { + const color = ColorModel.parse(''); + + expect(color.isEmpty).to.be.true; + expect(color.asString('hex')).to.equal(''); + }); + + it('should return an empty color for a whitespace string', () => { + const color = ColorModel.parse(' '); + + expect(color.isEmpty).to.be.true; + }); + }); + + describe('RGB property setters', () => { + it('should update red component', () => { + const color = ColorModel.default(); + color.r = 128; + + expect(color.r).to.equal(128); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + }); + + it('should update green component', () => { + const color = ColorModel.default(); + color.g = 128; + + expect(color.r).to.equal(0); + expect(color.g).to.equal(128); + expect(color.b).to.equal(0); + }); + + it('should update blue component', () => { + const color = ColorModel.default(); + color.b = 128; + + expect(color.r).to.equal(0); + expect(color.g).to.equal(0); + expect(color.b).to.equal(128); + }); + + it('should clamp RGB values to 0-255 range', () => { + const color = ColorModel.default(); + + color.r = -10; + expect(color.r).to.equal(0); + + color.r = 300; + expect(color.r).to.equal(255); + + color.g = -5; + expect(color.g).to.equal(0); + + color.g = 260; + expect(color.g).to.equal(255); + + color.b = -1; + expect(color.b).to.equal(0); + + color.b = 256; + expect(color.b).to.equal(255); + }); + + it('should update HSL values when RGB changes', () => { + const color = ColorModel.default(); + color.r = 255; + + expect(color.h).to.equal(0); + expect(color.s).to.equal(100); + expect(color.l).to.equal(50); + }); + + it('should update HSV values when RGB changes', () => { + const color = ColorModel.default(); + color.r = 255; + + expect(color.h).to.equal(0); + expect(color.s).to.equal(100); + expect(color.v).to.equal(100); + }); + }); + + describe('HSL property setters', () => { + it('should update hue', () => { + const color = new ColorModel([255, 0, 0]); + color.h = 120; + + expect(color.h).to.equal(120); + expect(color.g).to.be.greaterThan(250); + }); + + it('should clamp hue to 0-360 range', () => { + const color = ColorModel.default(); + + color.h = -10; + expect(color.h).to.equal(0); + + color.h = 400; + expect(color.h).to.equal(360); + }); + + it('should update saturation', () => { + const color = new ColorModel([255, 0, 0]); + color.s = 50; + + expect(color.s).to.equal(50); + }); + + it('should clamp saturation to 0-100 range', () => { + const color = new ColorModel([255, 0, 0]); + + color.s = -10; + expect(color.s).to.equal(0); + + color.s = 150; + expect(color.s).to.equal(100); + }); + + it('should update lightness', () => { + const color = new ColorModel([255, 0, 0]); + color.l = 25; + + expect(color.l).to.equal(25); + }); + + it('should clamp lightness to 0-100 range', () => { + const color = new ColorModel([255, 0, 0]); + + color.l = -10; + expect(color.l).to.equal(0); + + color.l = 150; + expect(color.l).to.equal(100); + }); + + it('should update RGB when HSL changes', () => { + const color = ColorModel.default(); + color.h = 120; + color.s = 100; + color.l = 50; + + expect(color.r).to.equal(0); + expect(color.g).to.equal(255); + expect(color.b).to.equal(0); + }); + }); + + describe('HSV property setters', () => { + it('should update value', () => { + const color = new ColorModel([255, 0, 0]); + color.v = 50; + + expect(color.v).to.equal(50); + }); + + it('should clamp value to 0-100 range', () => { + const color = new ColorModel([255, 0, 0]); + + color.v = -10; + expect(color.v).to.equal(0); + + color.v = 150; + expect(color.v).to.equal(100); + }); + + it('should update RGB when value changes', () => { + const color = new ColorModel([255, 0, 0]); + const originalR = color.r; + color.v = 50; + + expect(color.r).to.be.lessThan(originalR); + }); + + it('should update HSL when value changes', () => { + const color = new ColorModel([255, 0, 0]); + color.v = 50; + + expect(color.l).to.equal(25); + }); + }); + + describe('alpha property', () => { + it('should get and set alpha', () => { + const color = ColorModel.default(); + color.alpha = 0.3; + + expect(color.alpha).to.equal(0.3); + }); + + it('should clamp alpha to 0-1 range', () => { + const color = ColorModel.default(); + + color.alpha = -0.5; + expect(color.alpha).to.equal(0); + + color.alpha = 1.5; + expect(color.alpha).to.equal(1); + }); + }); + + describe('asString', () => { + describe('hex format', () => { + it('should output hex without alpha when alpha is 1', () => { + const color = new ColorModel([255, 128, 64]); + + expect(color.asString('hex')).to.equal('#ff8040'); + }); + + it('should output hex with alpha when alpha < 1', () => { + const color = new ColorModel([255, 128, 64], 0.5); + + expect(color.asString('hex')).to.equal('#ff804080'); + }); + + it('should force alpha output when requested', () => { + const color = new ColorModel([255, 128, 64], 1); + + expect(color.asString('hex', true)).to.equal('#ff8040ff'); + }); + + it('should handle black color', () => { + const color = ColorModel.default(); + + expect(color.asString('hex')).to.equal('#000000'); + }); + + it('should handle white color', () => { + const color = new ColorModel([255, 255, 255]); + + expect(color.asString('hex')).to.equal('#ffffff'); + }); + }); + + describe('rgb format', () => { + it('should output rgb without alpha when alpha is 1', () => { + const color = new ColorModel([255, 128, 64]); + + expect(color.asString('rgb')).to.equal('rgb(255 128 64)'); + }); + + it('should output rgba with alpha when alpha < 1', () => { + const color = new ColorModel([255, 128, 64], 0.75); + + expect(color.asString('rgb')).to.equal('rgb(255 128 64 / 0.75)'); + }); + + it('should force alpha output when requested', () => { + const color = new ColorModel([255, 128, 64], 1); + + expect(color.asString('rgb', true)).to.equal('rgb(255 128 64 / 1)'); + }); + + it('should round RGB values', () => { + const color = new ColorModel([255.7, 128.3, 64.9]); + + expect(color.asString('rgb')).to.equal('rgb(256 128 65)'); + }); + }); + + describe('hsl format', () => { + it('should output hsl without alpha when alpha is 1', () => { + const color = new ColorModel([255, 0, 0]); + + expect(color.asString('hsl')).to.equal('hsl(0 100% 50%)'); + }); + + it('should output hsla with alpha when alpha < 1', () => { + const color = new ColorModel([255, 0, 0], 0.5); + + expect(color.asString('hsl')).to.equal('hsl(0 100% 50% / 0.5)'); + }); + + it('should force alpha output when requested', () => { + const color = new ColorModel([255, 0, 0], 1); + + expect(color.asString('hsl', true)).to.equal('hsl(0 100% 50% / 1)'); + }); + + it('should round HSL values', () => { + const color = new ColorModel([128, 64, 32]); + + const hslString = color.asString('hsl'); + expect(hslString).to.match(/^hsl\(\d+ \d+% \d+%\)$/); + }); + }); + }); + + describe('color space conversions', () => { + it('should maintain color when converting between spaces', () => { + const originalRGB: [number, number, number] = [128, 64, 192]; + const color = new ColorModel(originalRGB); + + const { h, s, v } = color; + const newColor = ColorModel.default(); + newColor.h = h; + newColor.s = s; + newColor.v = v; + + expect(newColor.r).to.be.closeTo(originalRGB[0], 2); + expect(newColor.g).to.be.closeTo(originalRGB[1], 2); + expect(newColor.b).to.be.closeTo(originalRGB[2], 2); + }); + + it('should handle grayscale colors correctly', () => { + const color = new ColorModel([128, 128, 128]); + + expect(color.s).to.equal(0); + expect(color.l).to.be.closeTo(50, 1); + }); + + it('should handle pure colors correctly', () => { + const red = new ColorModel([255, 0, 0]); + expect(red.h).to.equal(0); + expect(red.s).to.equal(100); + expect(red.l).to.equal(50); + + const green = new ColorModel([0, 255, 0]); + expect(green.h).to.equal(120); + expect(green.s).to.equal(100); + expect(green.l).to.equal(50); + + const blue = new ColorModel([0, 0, 255]); + expect(blue.h).to.equal(240); + expect(blue.s).to.equal(100); + expect(blue.l).to.equal(50); + }); + }); + + describe('edge cases', () => { + it('should handle zero values', () => { + const color = ColorModel.default(); + + expect(color.r).to.equal(0); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + expect(color.h).to.equal(0); + expect(color.s).to.equal(0); + expect(color.l).to.equal(0); + expect(color.v).to.equal(0); + }); + + it('should handle maximum values', () => { + const color = new ColorModel([255, 255, 255]); + + expect(color.r).to.equal(255); + expect(color.g).to.equal(255); + expect(color.b).to.equal(255); + expect(color.s).to.equal(0); + expect(color.l).to.equal(100); + expect(color.v).to.equal(100); + }); + + it('should handle repeated conversions without drift', () => { + const color = new ColorModel([123, 45, 67], 0.8); + + const hex1 = color.asString('hex'); + const rgb1 = color.asString('rgb'); + const hsl1 = color.asString('hsl'); + + // Simulate multiple conversions + const { h, s, l } = color; + color.h = h; + color.s = s; + color.l = l; + + const hex2 = color.asString('hex'); + const rgb2 = color.asString('rgb'); + const hsl2 = color.asString('hsl'); + + expect(hex1).to.equal(hex2); + expect(rgb1).to.equal(rgb2); + expect(hsl1).to.equal(hsl2); + }); + }); + + describe('factory methods', () => { + it('should create color from HSL values', () => { + const color = ColorModel.fromHSL(120, 100, 50); + + expect(color.h).to.equal(120); + expect(color.s).to.equal(100); + expect(color.l).to.equal(50); + expect(color.g).to.be.greaterThan(250); + }); + + it('should create color from HSL with alpha', () => { + const color = ColorModel.fromHSL(240, 100, 50, 0.7); + + expect(color.h).to.equal(240); + expect(color.alpha).to.equal(0.7); + }); + + it('should create color from HSV values', () => { + const color = ColorModel.fromHSV(180, 100, 100); + + expect(color.h).to.equal(180); + expect(color.s).to.be.greaterThan(99); + expect(color.v).to.equal(100); + }); + + it('should create color from HSV with alpha', () => { + const color = ColorModel.fromHSV(60, 50, 75, 0.3); + + expect(color.h).to.equal(60); + expect(color.v).to.equal(75); + expect(color.alpha).to.equal(0.3); + }); + }); + + describe('utility methods', () => { + it('should clone a color', () => { + const original = new ColorModel([128, 64, 192], 0.5); + const clone = original.clone(); + + expect(clone.r).to.equal(original.r); + expect(clone.g).to.equal(original.g); + expect(clone.b).to.equal(original.b); + expect(clone.alpha).to.equal(original.alpha); + + // Verify it's a different instance + clone.r = 200; + expect(original.r).to.equal(128); + }); + + it('should compare colors for equality', () => { + const color1 = new ColorModel([255, 128, 64], 0.8); + const color2 = new ColorModel([255, 128, 64], 0.8); + const color3 = new ColorModel([255, 128, 65], 0.8); + const color4 = new ColorModel([255, 128, 64], 0.7); + + expect(color1.equals(color2)).to.be.true; + expect(color1.equals(color3)).to.be.false; + expect(color1.equals(color4)).to.be.false; + }); + + it('should export RGB values as tuple', () => { + const color = new ColorModel([100, 150, 200]); + const rgb = color.toRGB(); + + expect(rgb).to.deep.equal([100, 150, 200]); + expect(Array.isArray(rgb)).to.be.true; + expect(rgb.length).to.equal(3); + }); + + it('should export HSL values as tuple', () => { + const color = new ColorModel([255, 0, 0]); + const hsl = color.toHSL(); + + expect(hsl[0]).to.equal(0); + expect(hsl[1]).to.equal(100); + expect(hsl[2]).to.equal(50); + }); + + it('should export HSV values as tuple', () => { + const color = new ColorModel([255, 0, 0]); + const hsv = color.toHSV(); + + expect(hsv[0]).to.equal(0); + expect(hsv[1]).to.equal(100); + expect(hsv[2]).to.equal(100); + }); + + it('should protect internal RGB from external mutations', () => { + const originalRGB: [number, number, number] = [128, 64, 192]; + const color = new ColorModel(originalRGB); + + // Mutate the original array + originalRGB[0] = 0; + + // Color should not be affected + expect(color.r).to.equal(128); + }); + }); +}); diff --git a/src/components/color-picker/model.ts b/src/components/color-picker/model.ts new file mode 100644 index 000000000..652091292 --- /dev/null +++ b/src/components/color-picker/model.ts @@ -0,0 +1,342 @@ +import { clamp } from '../common/util.js'; +import { isValidColor, parseColor } from './common.js'; +import { converter, type HSL, type HSV, type RGB } from './converters.js'; + +export type ColorFormat = 'hex' | 'rgb' | 'hsl'; + +function makeCanvasContext() { + let context: OffscreenCanvasRenderingContext2D | null; + + return () => { + if (context) return context; + + try { + context = new OffscreenCanvas(0, 0).getContext('2d'); + return context; + } catch {} + return null; + }; +} +export const getContext = makeCanvasContext(); + +/** + * Represents a color with support for RGB, HSL, and HSV color spaces. + * Automatically syncs between color spaces when properties are modified. + * + * @example + * ```ts + * // Create from RGB + * const color = new ColorModel([255, 0, 0], 0.5); + * + * // Parse from string + * const parsed = ColorModel.parse('#ff0000'); + * + * // Modify and convert + * color.h = 120; + * console.log(color.asString('hsl')); // 'hsla(120, 100%, 50%, 0.5)' + * ``` + */ +export class ColorModel { + private _rgb: RGB; + private _hsl: HSL; + private _hsv: HSV; + private _alpha: number; + private _empty = false; + + /** + * Creates a default black color with full opacity. + * @returns A new ColorModel instance representing black + */ + public static default(): ColorModel { + return new ColorModel([0, 0, 0], 1); + } + + /** + * Creates an empty color, representing a missing/undefined color value. + * An empty color serializes to an empty string and is considered "empty" + * until any of its channels are modified. + * + * @returns A new empty ColorModel instance + */ + public static empty(): ColorModel { + const color = new ColorModel([0, 0, 0], 1); + color._empty = true; + return color; + } + + /** + * Parses a color string and creates a ColorModel instance. + * Supports hex, rgb, rgba, hsl, hsla, and named color formats. + * + * Empty, whitespace-only, or otherwise invalid strings produce an empty + * ColorModel instead of a stale/incorrect color. + * + * @param color - The color string to parse + * @returns A new ColorModel instance + */ + public static parse(color: string): ColorModel { + const ctx = getContext(); + + if (!isValidColor(color, ctx)) { + return ColorModel.empty(); + } + + const parsed = parseColor(color, ctx); + return new ColorModel(parsed.value, parsed.alpha); + } + + /** + * Creates a ColorModel from HSL values. + * + * @param h - Hue (0-360) + * @param s - Saturation (0-100) + * @param l - Lightness (0-100) + * @param alpha - Alpha channel (0-1) + * @returns A new ColorModel instance + */ + public static fromHSL( + h: number, + s: number, + l: number, + alpha = 1 + ): ColorModel { + const rgb = converter.hsl.rgb([h, s, l]); + return new ColorModel(rgb, alpha); + } + + /** + * Creates a ColorModel from HSV values. + * + * @param h - Hue (0-360) + * @param s - Saturation (0-100) + * @param v - Value (0-100) + * @param alpha - Alpha channel (0-1) + * @returns A new ColorModel instance + */ + public static fromHSV( + h: number, + s: number, + v: number, + alpha = 1 + ): ColorModel { + const rgb = converter.hsv.rgb([h, s, v]); + return new ColorModel(rgb, alpha); + } + + /** + * Creates a new ColorModel instance. + * + * @param value - RGB values as [r, g, b] tuple (0-255 each) + * @param alpha - Alpha channel value (0-1), defaults to 1 + */ + constructor(value: RGB, alpha = 1) { + // Create a copy to prevent external mutations + this._rgb = [value[0], value[1], value[2]]; + this._hsl = converter.rgb.hsl(this._rgb); + this._hsv = converter.rgb.hsv(this._rgb); + this._alpha = clamp(alpha, 0, 1); + } + + /** Whether the color represents a missing/undefined value. */ + public get isEmpty(): boolean { + return this._empty; + } + + /** Red component (0-255) */ + public get r(): number { + return this._rgb[0]; + } + + public set r(value: number) { + this._empty = false; + this._rgb[0] = clamp(value, 0, 255); + this._hsl = converter.rgb.hsl(this._rgb); + this._hsv = converter.rgb.hsv(this._rgb); + } + + /** Green component (0-255) */ + public get g(): number { + return this._rgb[1]; + } + + public set g(value: number) { + this._empty = false; + this._rgb[1] = clamp(value, 0, 255); + this._hsl = converter.rgb.hsl(this._rgb); + this._hsv = converter.rgb.hsv(this._rgb); + } + + /** Blue component (0-255) */ + public get b(): number { + return this._rgb[2]; + } + + public set b(value: number) { + this._empty = false; + this._rgb[2] = clamp(value, 0, 255); + this._hsl = converter.rgb.hsl(this._rgb); + this._hsv = converter.rgb.hsv(this._rgb); + } + + /** Hue component (0-360) */ + public get h(): number { + return this._hsl[0]; + } + + public set h(value: number) { + this._empty = false; + this._hsl[0] = clamp(value, 0, 360); + this._rgb = converter.hsl.rgb(this._hsl); + this._hsv = converter.hsl.hsv(this._hsl); + } + + /** Saturation component from HSL (0-100) */ + public get s(): number { + return this._hsl[1]; + } + + public set s(value: number) { + this._empty = false; + this._hsl[1] = clamp(value, 0, 100); + this._rgb = converter.hsl.rgb(this._hsl); + this._hsv = converter.hsl.hsv(this._hsl); + } + + /** Lightness component (0-100) */ + public get l(): number { + return this._hsl[2]; + } + + public set l(value: number) { + this._empty = false; + this._hsl[2] = clamp(value, 0, 100); + this._rgb = converter.hsl.rgb(this._hsl); + this._hsv = converter.hsl.hsv(this._hsl); + } + + /** Value component from HSV (0-100) */ + public get v(): number { + return this._hsv[2]; + } + + public set v(value: number) { + this._empty = false; + this._hsv[2] = clamp(value, 0, 100); + this._rgb = converter.hsv.rgb(this._hsv); + this._hsl = converter.hsv.hsl(this._hsv); + } + + /** Alpha/opacity channel (0-1) */ + public get alpha(): number { + return this._alpha; + } + + public set alpha(value: number) { + this._empty = false; + this._alpha = clamp(value, 0, 1); + } + + /** + * Sets the HSV saturation and value in a single atomic update, preserving + * the current hue and alpha. Intended for the 2D saturation/value picker + * area, where both components change together and setting them through the + * individual `s` (HSL) and `v` (HSV) setters would be both incorrect + * (mixing color spaces) and order-dependent. + * + * @param saturation - HSV saturation (0-100) + * @param value - HSV value (0-100) + */ + public setSaturationAndValue(saturation: number, value: number): void { + this._empty = false; + this._hsv[1] = clamp(saturation, 0, 100); + this._hsv[2] = clamp(value, 0, 100); + this._rgb = converter.hsv.rgb(this._hsv); + this._hsl = converter.hsv.hsl(this._hsv); + } + + /** + * Converts the color to a CSS color string. + * + * @param format - The output format ('hex', 'rgb', or 'hsl') + * @param forceAlpha - Whether to always include alpha channel + * @returns CSS color string + */ + public asString(format: ColorFormat, forceAlpha = false): string { + if (this._empty) { + return ''; + } + + const hasAlpha = this._alpha < 1 || forceAlpha; + switch (format) { + case 'hex': { + return hasAlpha + ? `#${converter.rgb.hex(this._rgb)}${Math.round(this._alpha * 255) + .toString(16) + .padStart(2, '0')}` + : `#${converter.rgb.hex(this._rgb)}`; + } + case 'rgb': { + const [r, g, b] = this._rgb.map((v) => Math.round(v)); + return `rgb(${r} ${g} ${b}${hasAlpha ? ` / ${this._alpha}` : ''})`; + } + case 'hsl': { + const [h, s, l] = this._hsl.map((v) => Math.round(v)); + return `hsl(${h} ${s}% ${l}%${hasAlpha ? ` / ${this._alpha}` : ''})`; + } + } + } + + /** + * Creates a copy of this color model. + * + * @returns A new ColorModel instance with the same values + */ + public clone(): ColorModel { + const color = new ColorModel([...this._rgb] as RGB, this._alpha); + color._empty = this._empty; + return color; + } + + /** + * Checks if this color equals another color. + * + * @param other - The color to compare with + * @returns True if colors are equal + */ + public equals(other: ColorModel): boolean { + return ( + this._empty === other._empty && + this._rgb[0] === other._rgb[0] && + this._rgb[1] === other._rgb[1] && + this._rgb[2] === other._rgb[2] && + this._alpha === other._alpha + ); + } + + /** + * Returns the RGB values as a tuple. + * + * @returns RGB values [r, g, b] + */ + public toRGB(): RGB { + return [this._rgb[0], this._rgb[1], this._rgb[2]]; + } + + /** + * Returns the HSL values as a tuple. + * + * @returns HSL values [h, s, l] + */ + public toHSL(): HSL { + return [this._hsl[0], this._hsl[1], this._hsl[2]]; + } + + /** + * Returns the HSV values as a tuple. + * + * @returns HSV values [h, s, v] + */ + public toHSV(): HSV { + return [this._hsv[0], this._hsv[1], this._hsv[2]]; + } +} diff --git a/src/components/color-picker/picker-canvas.spec.ts b/src/components/color-picker/picker-canvas.spec.ts new file mode 100644 index 000000000..d1427ddb0 --- /dev/null +++ b/src/components/color-picker/picker-canvas.spec.ts @@ -0,0 +1,180 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; + +import { + arrowDown, + arrowLeft, + arrowRight, + arrowUp, +} from '../common/controllers/key-bindings.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { asPercent } from '../common/util.js'; +import { + simulateKeyboard, + simulateLostPointerCapture, + simulatePointerDown, + simulatePointerMove, +} from '../common/utils.spec.js'; +import IgcPickerCanvasComponent from './picker-canvas.js'; + +async function createCanvas() { + return await fixture( + html`` + ); +} + +function getMarker(canvas: IgcPickerCanvasComponent): HTMLDivElement { + return canvas.renderRoot.querySelector('[part="marker"]')!; +} + +describe('Picker canvas', () => { + before(() => defineComponents(IgcPickerCanvasComponent)); + + let canvas: IgcPickerCanvasComponent; + + beforeEach(async () => { + canvas = await createCanvas(); + }); + + describe('Rendering', () => { + it('renders a focusable marker', () => { + const marker = getMarker(canvas); + expect(marker).to.exist; + expect(marker.getAttribute('tabindex')).to.equal('0'); + }); + + it('`getMarkerDimensions()` returns half the marker size', () => { + const rect = getMarker(canvas).getBoundingClientRect(); + const dimensions = canvas.getMarkerDimensions(); + + expect(dimensions.width).to.equal(rect.width / 2); + expect(dimensions.height).to.equal(rect.height / 2); + }); + + it('reflects `x`/`y` into the marker position', async () => { + canvas.x = 40; + canvas.y = 20; + await elementUpdated(canvas); + + const marker = getMarker(canvas); + expect(marker.style.left).to.equal('40px'); + expect(marker.style.top).to.equal('20px'); + }); + + it('sets the host color from `currentColor`', async () => { + canvas.currentColor = 'red'; + await elementUpdated(canvas); + + expect(canvas.style.color).to.equal('red'); + }); + }); + + describe('Keyboard interaction', () => { + it('moves the marker with arrow keys and emits `igcColorPicked`', () => { + const eventSpy = spy(canvas, 'emitEvent'); + + simulateKeyboard(canvas, arrowRight); + expect(canvas.x).to.equal(1); + expect(eventSpy).calledOnce; + + simulateKeyboard(canvas, arrowDown); + expect(canvas.y).to.equal(1); + + simulateKeyboard(canvas, arrowLeft); + expect(canvas.x).to.equal(0); + + simulateKeyboard(canvas, arrowUp); + expect(canvas.y).to.equal(0); + + expect(eventSpy.callCount).to.equal(4); + }); + + it('emits rounded percentages relative to the canvas size', () => { + const eventSpy = spy(canvas, 'emitEvent'); + const { width, height } = canvas.getMarkerDimensions(); + const rect = canvas.getBoundingClientRect(); + + simulateKeyboard(canvas, arrowRight); + + expect(eventSpy).calledWith('igcColorPicked', { + detail: { + x: Math.round(asPercent(1 + width, rect.width)), + y: Math.round(asPercent(height, rect.height)), + }, + bubbles: false, + }); + }); + + it('clamps at the boundaries and stops emitting', async () => { + const { width, height } = canvas.getMarkerDimensions(); + canvas.x = -width; + canvas.y = -height; + await elementUpdated(canvas); + + const eventSpy = spy(canvas, 'emitEvent'); + simulateKeyboard(canvas, arrowLeft); + simulateKeyboard(canvas, arrowUp); + + expect(canvas.x).to.equal(-width); + expect(canvas.y).to.equal(-height); + expect(eventSpy).not.called; + }); + }); + + describe('Pointer interaction', () => { + it('drags the marker on pointer down/move', () => { + const rect = canvas.getBoundingClientRect(); + const { width, height } = canvas.getMarkerDimensions(); + const eventSpy = spy(canvas, 'emitEvent'); + + simulatePointerDown(canvas, { + clientX: rect.x + 50, + clientY: rect.y + 40, + }); + + expect(canvas.x).to.equal(50 - width); + expect(canvas.y).to.equal(40 - height); + expect(eventSpy).calledOnce; + + simulatePointerMove( + canvas, + { clientX: rect.x + 50, clientY: rect.y + 40 }, + { x: 10, y: 5 } + ); + + expect(canvas.x).to.equal(60 - width); + expect(canvas.y).to.equal(45 - height); + }); + + it('stops dragging and focuses the marker on lost pointer capture', () => { + const rect = canvas.getBoundingClientRect(); + + simulatePointerDown(canvas, { + clientX: rect.x + 50, + clientY: rect.y + 40, + }); + simulateLostPointerCapture(canvas); + + const { x, y } = canvas; + simulatePointerMove(canvas, { + clientX: rect.x + 100, + clientY: rect.y + 100, + }); + + expect(canvas.x).to.equal(x); + expect(canvas.y).to.equal(y); + expect(canvas.shadowRoot?.activeElement).to.equal(getMarker(canvas)); + }); + + it('does not bubble the `igcColorPicked` event', () => { + const parentSpy = spy(); + canvas.parentElement?.addEventListener('igcColorPicked', parentSpy); + + simulatePointerDown(canvas, { clientX: 10, clientY: 10 }); + + expect(parentSpy).not.called; + }); + }); +}); diff --git a/src/components/color-picker/picker-canvas.ts b/src/components/color-picker/picker-canvas.ts new file mode 100644 index 000000000..9ff58245a --- /dev/null +++ b/src/components/color-picker/picker-canvas.ts @@ -0,0 +1,159 @@ +import { html, LitElement, type PropertyValues } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, +} from '../common/controllers/key-bindings.js'; +import { registerComponent } from '../common/definitions/register.js'; +import type { AbstractConstructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { addSafeEventListener, asPercent, clamp } from '../common/util.js'; +import { styles } from './themes/picker-canvas.base.css.js'; + +export interface IgcPickerCanvasEventMap { + /** + * Emitted when the color is picked in the canvas. Does not bubble. + */ + igcColorPicked: CustomEvent; +} + +export type PickerCanvasEventDetail = { x: number; y: number }; + +export default class IgcPickerCanvasComponent extends EventEmitterMixin< + IgcPickerCanvasEventMap, + AbstractConstructor +>(LitElement) { + public static readonly tagName = 'igc-picker-canvas'; + public static styles = styles; + + public static register(): void { + registerComponent(IgcPickerCanvasComponent); + } + + @query('[part="marker"]', true) + private readonly _marker?: HTMLDivElement; + + @property() + public currentColor = ''; + + @property({ attribute: false }) + public x = 0; + + @property({ attribute: false }) + public y = 0; + + constructor() { + super(); + + addSafeEventListener(this, 'pointerdown', this._handlePointerDown); + addSafeEventListener( + this, + 'lostpointercapture', + this._handleLostPointerCapture + ); + + addKeybindings(this, { bindingDefaults: { repeat: true } }) + .set(arrowDown, this._onArrowKey.bind(this, { dx: 0, dy: 1 })) + .set(arrowUp, this._onArrowKey.bind(this, { dx: 0, dy: -1 })) + .set(arrowLeft, this._onArrowKey.bind(this, { dx: -1, dy: 0 })) + .set(arrowRight, this._onArrowKey.bind(this, { dx: 1, dy: 0 })); + } + + protected override updated(properties: PropertyValues): void { + if (properties.has('currentColor')) { + this.style.color = this.currentColor; + } + } + + private _onArrowKey({ dx, dy }: { dx: number; dy: number }): void { + const rect = this.getBoundingClientRect(); + const { width, height } = this.getMarkerDimensions(); + + const x = clamp(this.x + dx, -width, rect.width - width); + const y = clamp(this.y + dy, -height, rect.height - height); + + const shouldEmit = x !== this.x || y !== this.y; + + this.x = x; + this.y = y; + + if (shouldEmit) { + this.emitEvent('igcColorPicked', { + detail: { + x: Math.round(asPercent(x + width, rect.width)), + y: Math.round(asPercent(y + height, rect.height)), + }, + bubbles: false, + }); + } + } + + private _move(event: PointerEvent): void { + event.preventDefault(); + event.stopPropagation(); + + const rect = this.getBoundingClientRect(); + const { width, height } = this.getMarkerDimensions(); + const maxX = rect.width - width; + const maxY = rect.height - height; + + const x = clamp(event.clientX - rect.x - width, -width, maxX); + const y = clamp(event.clientY - rect.y - height, -height, maxY); + const shouldEmit = x !== this.x || y !== this.y; + + this.x = x; + this.y = y; + + if (shouldEmit) { + this.emitEvent('igcColorPicked', { + detail: { + x: Math.round(asPercent(x + width, rect.width)), + y: Math.round(asPercent(y + height, rect.height)), + }, + bubbles: false, + }); + } + } + + private _handlePointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + this.setPointerCapture(event.pointerId); + this.addEventListener('pointermove', this._handlePointerMove); + this._move(event); + } + + private _handleLostPointerCapture(): void { + this.removeEventListener('pointermove', this._handlePointerMove); + this._marker?.focus(); + } + + private _handlePointerMove(event: PointerEvent): void { + this._move(event); + } + + public getMarkerDimensions(): { width: number; height: number } { + const rect = this._marker?.getBoundingClientRect(); + return rect + ? { width: rect.width / 2, height: rect.height / 2 } + : { width: 0, height: 0 }; + } + + protected override render() { + const styles = styleMap({ + top: `${this.y}px`, + left: `${this.x}px`, + }); + + return html`
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-picker-canvas': IgcPickerCanvasComponent; + } +} diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss new file mode 100644 index 000000000..d3a91014d --- /dev/null +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -0,0 +1,189 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + content-visibility: auto; + contain: strict; + + --current-color: #000; + --hue-slider-track: linear-gradient( + to right, + red 0, + #ff0 16.66%, + #0f0 33.33%, + #0ff 50%, + #00f 66.66%, + #f0f 83.33%, + red 100% + ); + --alpha-slider-track: + linear-gradient(90deg, rgb(0 0 0 / 0%), var(--current-color)), + repeating-linear-gradient( + 45deg, + #aaa 25%, + transparent 25%, + transparent 75%, + #aaa 75%, + #aaa + ), + repeating-linear-gradient( + 45deg, + #aaa 25%, + #fff 25%, + #fff 75%, + #aaa 75%, + #aaa + ); + --alpha-track-position: 0 0, 0 0, 4px 4px; + --alpha-track-size: contain, 8px 8px, 8px 8px; + + input[type='range'] { + appearance: none; + background: transparent; + cursor: pointer; + width: 100%; + } + + [part='main-row'], + [part='alpha-row'], + [part='inputs-row'] { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + } + + [part='buttons'] { + --ig-size: 1; + + display: flex; + margin-left: 1rem; + } + + [part='hue']::-webkit-slider-runnable-track { + border-radius: rem(4px); + height: 0.5rem; + background: var(--hue-slider-track); + } + + [part='hue']::-moz-range-track { + border-radius: rem(4px); + height: 0.5rem; + background: var(--hue-slider-track); + } + + [part='hue']::-webkit-slider-thumb { + appearance: none; + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: var(--current-color); + margin-top: calc(-0.5 * 0.5rem); + } + + [part='hue']::-moz-range-thumb { + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: var(--current-color); + margin-top: calc(-0.5 * 0.5rem); + } + + [part='alpha']::-webkit-slider-runnable-track { + border-radius: rem(4px); + height: 0.5rem; + background: none; + background-image: var(--alpha-slider-track); + background-position: var(--alpha-track-position); + background-size: var(--alpha-track-size); + } + + [part='alpha']::-moz-range-track { + border-radius: rem(4px); + height: 0.5rem; + background: none; + background-image: var(--alpha-slider-track); + background-position: var(--alpha-track-position); + background-size: var(--alpha-track-size); + } + + [part='alpha']::-webkit-slider-thumb { + appearance: none; + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: var(--current-color); + margin-top: calc(-0.5 * 0.5rem); + } + + [part='alpha']::-moz-range-thumb { + width: 1rem; + height: 1rem; + border-radius: 50%; + background-color: var(--current-color); + margin-top: calc(-0.5 * 0.5rem); + } + + [part='picker'] { + display: grid; + padding: 0.25rem; + width: rem(300px); + min-height: 12rem; + grid-template-rows: 6fr 1fr; + row-gap: 0.5rem; + box-shadow: var(--ig-elevation-3); + } + + [part='copy']::part(base) { + background-color: var(--current-color); + } + + [part~='empty'], + [part~='empty']::part(base) { + background-image: + repeating-linear-gradient( + 45deg, + #aaa 25%, + transparent 25%, + transparent 75%, + #aaa 75%, + #aaa + ), + repeating-linear-gradient( + 45deg, + #aaa 25%, + #fff 25%, + #fff 75%, + #aaa 75%, + #aaa + ); + background-position: + 0 0, + 4px 4px; + background-size: 8px 8px; + } + + [part='copy']::part(icon) { + --foreground: contrast-color(var(--current-color)); + } + + [part='swatches'] { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + max-width: rem(300px); + overflow: hidden; + } + + [part='swatch'] { + width: 1.5rem; + height: 1.5rem; + border-radius: 0.25rem; + border: 1px solid #fff; + cursor: pointer; + } +} + +:host([required]) [part='label']::after { + content: '*'; +} diff --git a/src/components/color-picker/themes/picker-canvas.base.scss b/src/components/color-picker/themes/picker-canvas.base.scss new file mode 100644 index 000000000..c3a25aae6 --- /dev/null +++ b/src/components/color-picker/themes/picker-canvas.base.scss @@ -0,0 +1,23 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + content-visibility: auto; + contain: strict; + display: flex; + position: relative; + height: 100%; + background-image: + linear-gradient(rgb(0 0 0 / 0%), #000), + linear-gradient(90deg, #fff, currentColor); + cursor: crosshair; +} + +[part='marker'] { + position: absolute; + width: 0.75rem; + height: 0.75rem; + border: 1px solid #fff; + border-radius: 50%; + cursor: crosshair; +} diff --git a/src/components/color-picker/validators.ts b/src/components/color-picker/validators.ts new file mode 100644 index 000000000..f514364b5 --- /dev/null +++ b/src/components/color-picker/validators.ts @@ -0,0 +1,6 @@ +import { requiredValidator, type Validator } from '../common/validators.js'; +import type IgcColorPickerComponent from './color-picker.js'; + +export const colorPickerValidators: Validator[] = [ + requiredValidator, +]; diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 52d6ea3fe..7b4ae0df4 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -19,6 +19,7 @@ import IgcChatComponent from '../../chat/chat.js'; import IgcCheckboxComponent from '../../checkbox/checkbox.js'; import IgcSwitchComponent from '../../checkbox/switch.js'; import IgcChipComponent from '../../chip/chip.js'; +import IgcColorPickerComponent from '../../color-picker/color-picker.js'; import IgcComboComponent from '../../combo/combo.js'; import IgcDatePickerComponent from '../../date-picker/date-picker.js'; import IgcDateRangePickerComponent from '../../date-range-picker/date-range-picker.js'; @@ -96,6 +97,7 @@ const allComponents: IgniteComponent[] = [ IgcChatComponent, IgcCheckboxComponent, IgcChipComponent, + IgcColorPickerComponent, IgcFileInputComponent, IgcComboComponent, IgcDatePickerComponent, diff --git a/src/index.ts b/src/index.ts index 4e800dfa0..a04060cc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { default as IgcCarouselComponent } from './components/carousel/carousel. export { default as IgcCarouselIndicatorComponent } from './components/carousel/carousel-indicator.js'; export { default as IgcCarouselSlideComponent } from './components/carousel/carousel-slide.js'; export { default as IgcChatComponent } from './components/chat/chat.js'; +export { default as IgcColorPickerComponent } from './components/color-picker/color-picker.js'; export { default as IgcCheckboxComponent } from './components/checkbox/checkbox.js'; export { default as IgcCircularProgressComponent } from './components/progress/circular-progress.js'; export { default as IgcCircularGradientComponent } from './components/progress/circular-gradient.js'; diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts new file mode 100644 index 000000000..d0e17b2b2 --- /dev/null +++ b/stories/color-picker.stories.ts @@ -0,0 +1,365 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +import { + IgcColorPickerComponent, + defineComponents, +} from 'igniteui-webcomponents'; +import { + disableStoryControls, + formControls, + formSubmitHandler, +} from './story.js'; + +defineComponents(IgcColorPickerComponent); + +// region default +const metadata: Meta = { + title: 'ColorPicker', + component: 'igc-color-picker', + parameters: { + docs: { + description: { + component: + 'Color input component.\n\nLets the user pick a color visually - via an HSV saturation/value canvas, a\nhue slider and an optional alpha slider - or by typing a color string\n(hex, rgb(a), hsl(a) or a named CSS color) directly. Supports pre-defined\nswatches, the native EyeDropper API where available, and two anchor\npresentations: a trigger button (`mode="default"`) or an editable text\nfield (`mode="input"`).', + }, + }, + actions: { + handles: [ + 'igcOpening', + 'igcOpened', + 'igcClosing', + 'igcClosed', + 'igcInput', + 'igcChange', + ], + }, + }, + argTypes: { + label: { + type: 'string', + description: + 'The label of the component.\n\nIn `mode="input"` this is forwarded to the anchor input\'s own label\ninstead of being rendered as a separate element.', + control: 'text', + }, + value: { + type: 'string', + description: + 'The value of the component, as a CSS color string (hex, rgb(a), hsl(a)\nor a named color).\n\nSetting an empty, whitespace-only or otherwise invalid string clears\nthe value.', + control: 'text', + }, + format: { + type: '"hex" | "rgb" | "hsl"', + description: 'Sets the color format for the string value.', + options: ['hex', 'rgb', 'hsl'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'hex' } }, + }, + hideFormats: { + type: 'boolean', + description: 'Whether to hide the format picker buttons.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + showAlpha: { + type: 'boolean', + description: 'Whether to show the alpha slider and input.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + mode: { + type: '"default" | "input"', + description: + 'The mode of the color picker.\n\nIn `"default"` mode the anchor is a trigger button. In `"input"` mode\nthe anchor is an editable text field with a color swatch prefix that\nalso opens the picker.', + options: ['default', 'input'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'default' } }, + }, + required: { + type: 'boolean', + description: + 'When set, makes the component a required field for validation.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + name: { + type: 'string', + description: 'The name attribute of the control.', + control: 'text', + }, + disabled: { + type: 'boolean', + description: 'The disabled state of the component.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + invalid: { + type: 'boolean', + description: 'Sets the control into invalid state (visual state only).', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + open: { + type: 'boolean', + description: 'Sets the open state of the component.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + }, + args: { + format: 'hex', + hideFormats: false, + showAlpha: false, + mode: 'default', + required: false, + disabled: false, + invalid: false, + open: false, + }, +}; + +export default metadata; + +interface IgcColorPickerArgs { + /** + * The label of the component. + * + * In `mode="input"` this is forwarded to the anchor input's own label + * instead of being rendered as a separate element. + */ + label: string; + /** + * The value of the component, as a CSS color string (hex, rgb(a), hsl(a) + * or a named color). + * + * Setting an empty, whitespace-only or otherwise invalid string clears + * the value. + */ + value: string; + /** Sets the color format for the string value. */ + format: 'hex' | 'rgb' | 'hsl'; + /** Whether to hide the format picker buttons. */ + hideFormats: boolean; + /** Whether to show the alpha slider and input. */ + showAlpha: boolean; + /** + * The mode of the color picker. + * + * In `"default"` mode the anchor is a trigger button. In `"input"` mode + * the anchor is an editable text field with a color swatch prefix that + * also opens the picker. + */ + mode: 'default' | 'input'; + /** When set, makes the component a required field for validation. */ + required: boolean; + /** The name attribute of the control. */ + name: string; + /** The disabled state of the component. */ + disabled: boolean; + /** Sets the control into invalid state (visual state only). */ + invalid: boolean; + /** Sets the open state of the component. */ + open: boolean; +} +type Story = StoryObj; + +// endregion + +export const Default: Story = { + args: { + label: 'Pick a color', + }, +}; + +export const InitialValue: Story = { + args: { + label: 'Pick a color', + value: 'rebeccapurple', + }, +}; + +const rowStyle = + 'display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start;'; + +export const Formats: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+

Hex

+ +
+
+

RGB

+ +
+
+

HSL

+ +
+
+ `, +}; + +export const AlphaChannel: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +

+ Set show-alpha to reveal the alpha slider and input, and pass + a color with an alpha component through value. +

+ + `, +}; + +export const InputMode: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +

+ mode="input" renders the color value as an editable text + field with a color swatch prefix, instead of a plain trigger button. +

+
+
+

Default

+ +
+
+

Input

+ +
+
+ `, +}; + +export const CustomSwatches: Story = { + render: () => html` + + `, +}; + +export const States: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+

Empty

+ +
+
+

Disabled

+ +
+
+

Invalid

+ +
+
+ `, +}; + +export const Events: Story = { + argTypes: disableStoryControls(metadata), + render: () => { + const onInput = (event: CustomEvent) => { + const log = document.querySelector('#color-events-log'); + if (log) log.textContent = `igcInput — "${event.detail}"`; + }; + + const onChange = (event: CustomEvent) => { + const log = document.querySelector('#color-events-log'); + if (log) log.textContent = `igcChange — "${event.detail}"`; + }; + + return html` +

+ igcInput fires on every interaction with the picker area + (dragging the canvas or sliders, typing). igcChange fires + once, when the committed value changes and focus leaves the component. +

+ +

+ No events yet +

+ `; + }, +}; + +export const Form: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + + + + +
+ + ${formControls()} +
+ `, +}; diff --git a/tsconfig.json b/tsconfig.json index dec392518..3f9112ac2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,7 @@ { "name": "ts-lit-plugin", "strict": true, - "globalAttributes": ["command", "commandfor", "popover"], + "globalAttributes": ["command", "commandfor", "popover", "inert"], "rules": { "no-incompatible-type-binding": "warning" }