From e9f0112f07fd7bbb50f968438b37f26d981d1799 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 14 Nov 2025 10:10:12 +0200 Subject: [PATCH 1/2] feat: Added color picker component --- .../color-picker/color-picker.spec.ts | 100 ++++ src/components/color-picker/color-picker.ts | 452 ++++++++++++++ src/components/color-picker/common.spec.ts | 174 ++++++ src/components/color-picker/common.ts | 63 ++ src/components/color-picker/converters.ts | 190 ++++++ src/components/color-picker/model.spec.ts | 555 ++++++++++++++++++ src/components/color-picker/model.ts | 295 ++++++++++ src/components/color-picker/picker-canvas.ts | 153 +++++ .../themes/color-picker.base.scss | 136 +++++ .../themes/picker-canvas.base.scss | 23 + .../common/definitions/defineAllComponents.ts | 2 + src/index.ts | 1 + stories/color-picker.stories.ts | 163 +++++ 13 files changed, 2307 insertions(+) create mode 100644 src/components/color-picker/color-picker.spec.ts create mode 100644 src/components/color-picker/color-picker.ts create mode 100644 src/components/color-picker/common.spec.ts create mode 100644 src/components/color-picker/common.ts create mode 100644 src/components/color-picker/converters.ts create mode 100644 src/components/color-picker/model.spec.ts create mode 100644 src/components/color-picker/model.ts create mode 100644 src/components/color-picker/picker-canvas.ts create mode 100644 src/components/color-picker/themes/color-picker.base.scss create mode 100644 src/components/color-picker/themes/picker-canvas.base.scss create mode 100644 stories/color-picker.stories.ts 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..65c65be18 --- /dev/null +++ b/src/components/color-picker/color-picker.spec.ts @@ -0,0 +1,100 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { createFormAssociatedTestBed } from '../common/utils.spec.js'; +import IgcColorPickerComponent from './color-picker.js'; + +async function createDefaultColorPicker() { + return await fixture( + html`` + ); +} + +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('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('#000000'); + }); + + 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 custom constraint', () => { + spec.element.setCustomValidity('invalid'); + spec.assertSubmitFails(); + + spec.element.setCustomValidity(''); + spec.assertSubmitPasses(); + }); + }); +}); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts new file mode 100644 index 000000000..6c25a5260 --- /dev/null +++ b/src/components/color-picker/color-picker.ts @@ -0,0 +1,452 @@ +import { html, nothing, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { + addKeybindings, + escapeKey, +} from '../common/controllers/key-bindings.js'; +import { addRootClickController } from '../common/controllers/root-click.js'; +import { registerComponent } from '../common/definitions/register.js'; +import { IgcBaseComboBoxLikeComponent } from '../common/mixins/combo-box.js'; +import type { AbstractConstructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { FormAssociatedMixin } from '../common/mixins/forms/associated.js'; +import { createFormValueState } from '../common/mixins/forms/form-value.js'; +import { addSafeEventListener, asNumber } from '../common/util.js'; +import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; +import IgcInputComponent from '../input/input.js'; +import IgcPopoverComponent from '../popover/popover.js'; +import type { IgcRadioChangeEventArgs } from '../radio/radio.js'; +import IgcRadioGroupComponent from '../radio-group/radio-group.js'; +import { ColorModel } from './model.js'; +import IgcPickerCanvasComponent, { + type IgcPickerCanvasEventMap, +} from './picker-canvas.js'; +import { styles } from './themes/color-picker.base.css.js'; + +export interface IgcColorPickerEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; + igcInput: CustomEvent; + igcChange: CustomEvent; + igcColorPicked: CustomEvent; +} + +function stopPropagation(event: Event, immediate = false) { + immediate ? event.stopImmediatePropagation() : event.stopPropagation(); +} + +/** + * Color input component. + * + * @element igc-color-picker + * + * @fires igcOpening - Emitted just before the picker dropdown is open. + * @fires igcOpened - Emitted after the picker dropdown is open. + * @fires igcClosing - Emitter just before the picker dropdown is closed. + * @fires igcClosed - Emitted after closing the picker dropdown. + * @fires igcColorPicked - Emitted when the color is changed in the picker area. + */ +export default class IgcColorPickerComponent extends FormAssociatedMixin( + EventEmitterMixin< + IgcColorPickerEventMap, + AbstractConstructor + >(IgcBaseComboBoxLikeComponent) +) { + public static readonly tagName = 'igc-color-picker'; + public static styles = styles; + + public static register(): void { + registerComponent( + IgcColorPickerComponent, + IgcInputComponent, + IgcPopoverComponent, + IgcFocusTrapComponent, + IgcRadioGroupComponent, + IgcPickerCanvasComponent + ); + } + + protected override readonly _rootClickController = addRootClickController( + this, + { + onHide: this._handleClosing, + } + ); + + protected override readonly _formValue = createFormValueState(this, { + initialValue: '', + }); + + private _color = ColorModel.default(); + + @state({ hasChanged: () => true }) + private _ownCurrentColor = ''; + + @query(IgcInputComponent.tagName, true) + protected readonly _input!: IgcInputComponent; + + @query('#color-thumb', true) + protected readonly _preview!: HTMLSpanElement; + + @query('[part="hue"]') + protected readonly _hueSlider!: HTMLInputElement; + + @query('[part="alpha"]') + protected readonly _alphaSlider!: HTMLInputElement; + + @query(IgcPickerCanvasComponent.tagName) + protected readonly _canvasPicker!: IgcPickerCanvasComponent; + + /** + * The label of the component. + * @attr label + */ + @property() + public label?: string; + + /** + * The value of the component. + * @attr value + */ + @property() + public set value(value: string) { + this._color = ColorModel.parse(value); + this._formValue.setValueAndFormState(this._color.asString(this.format)); + this._updateColor(); + this._syncCanvasPosition(); + } + + public get value(): string { + return this._formValue.value; + } + + /** + * Sets the color format for the string value. + * @attr + */ + @property() + public format: 'hex' | 'rgb' | 'hsl' = 'hex'; + + /** + * Whether to hide the format picker buttons. + * @attr + */ + @property({ type: Boolean, attribute: 'hide-formats', reflect: true }) + public hideFormats = false; + + constructor() { + super(); + + addSafeEventListener(this, 'igcOpened' as any, this._syncCanvasPosition); + + addKeybindings(this, { skip: () => this.disabled }).set( + escapeKey, + this._onEscapeKey + ); + } + + protected override update(props: PropertyValues): void { + if (props.has('open')) { + this._rootClickController.update(); + } + + super.update(props); + } + + private _handleClosing(): void { + this._hide(true); + } + + protected async _onEscapeKey(): Promise { + if (await this._hide(true)) { + this._input.focus(); + } + } + + protected override _restoreDefaultValue(): void { + super._restoreDefaultValue(); + this._color = ColorModel.parse(this._formValue.value); + this._updateColor(); + this._syncCanvasPosition(); + } + + private _handleHueValueChange(event: Event): void { + stopPropagation(event); + + this._color.h = this._hueSlider.valueAsNumber; + this._updateColor(); + this._emitColorPickedEvent(); + } + + private _handleAlphaValueChange(event: Event): void { + stopPropagation(event); + + this._color.alpha = this._alphaSlider.valueAsNumber / 100; + this._updateColor(); + this._emitColorPickedEvent(); + } + + 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)); + } + + private _syncCanvasPosition(): void { + if (!(this.open || this._canvasPicker)) return; + + const rect = this._canvasPicker.getBoundingClientRect(); + const { width: markerWidth, height: markerHeight } = + this._canvasPicker.getMarkerDimensions(); + + const x = (this._color.s / 100) * rect.width - markerWidth; + const y = ((100 - this._color.v) / 100) * rect.height - markerHeight; + + this._canvasPicker.x = x; + this._canvasPicker.y = y; + } + + protected _emitColorPickedEvent(): void { + this.emitEvent('igcColorPicked', { detail: this.value }); + } + + protected _handleFormatChange(event: CustomEvent) { + stopPropagation(event); + + this.format = event.detail.value as typeof this.format; + this._updateColor(); + } + + protected _handleCanvasColorPicked( + event: IgcPickerCanvasEventMap['igcColorPicked'] + ): void { + stopPropagation(event); + + this._color.s = event.detail.x; + this._color.v = 100 - event.detail.y; + this._updateColor(); + } + + protected _handleColorInputChange(event: CustomEvent): void { + stopPropagation(event); + + const input = event.target as IgcInputComponent; + + if (input.name === 'hex') { + this._color = ColorModel.parse(event.detail); + } else { + const value = asNumber(event.detail); + + switch (input.name) { + case 'red': + this._color.r = value; + break; + case 'green': + this._color.g = value; + break; + case 'blue': + this._color.b = value; + break; + case 'hue': + this._color.h = value; + break; + case 'saturation': + this._color.s = value; + break; + case 'lightness': + this._color.l = value; + break; + case 'alpha': + this._color.alpha = value; + break; + } + } + + this._updateColor(); + this._syncCanvasPosition(); + } + + protected _renderFormatRadios() { + return html` + + Hex + RGB + HSL + + `; + } + + protected _renderFormats() { + return html` + ${cache(this.hideFormats ? nothing : this._renderFormatRadios())} + `; + } + + protected _renderGradientArea() { + return html` + + + `; + } + + protected _renderHueSlider() { + return html` + + `; + } + + protected _renderAlphaSlider() { + return html` + + `; + } + + protected _renderRGBInput() { + const { r, g, b, h, s, l } = this._color; + const isRGB = this.format === 'rgb'; + + return html` + + + + `; + } + + protected _renderHexInput() { + return html` + + `; + } + + protected _renderAlphaInput() { + return html` + + `; + } + + protected _renderColorInputs() { + return html` +
+ ${cache( + this.format === 'hex' + ? this._renderHexInput() + : this._renderRGBInput() + )} + ${this._renderAlphaInput()} +
+ `; + } + + protected _renderPicker() { + return html` + +
+ ${this._renderGradientArea()} + +
+ ${this._renderHueSlider()}${this._renderAlphaSlider()} + ${this._renderFormats()}${this._renderColorInputs()} +
+
+
+ `; + } + + protected override render() { + const style = styleMap({ + 'background-color': this._color.asString('rgb', true), + }); + + return html` + + + + + ${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..efd8b6800 --- /dev/null +++ b/src/components/color-picker/common.spec.ts @@ -0,0 +1,174 @@ +import { expect } from '@open-wc/testing'; + +import { 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]); + // Note: Canvas may add alpha channel for some hex formats + expect(result.alpha).to.be.oneOf([0.5, 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 don't reset fillStyle, so result depends on previous state + // Just verify it doesn't throw and returns a valid structure + const result = parseColor('not-a-color', ctx); + + expect(result).to.have.property('value'); + expect(result).to.have.property('alpha'); + expect(Array.isArray(result.value)).to.be.true; + }); + + it('should handle malformed hex colors gracefully', () => { + // Malformed hex colors behave like invalid colors + const result = parseColor('#zzz', ctx); + + expect(result).to.have.property('value'); + expect(result).to.have.property('alpha'); + expect(Array.isArray(result.value)).to.be.true; + }); + + 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); + }); + }); +}); diff --git a/src/components/color-picker/common.ts b/src/components/color-picker/common.ts new file mode 100644 index 000000000..0329fb2e9 --- /dev/null +++ b/src/components/color-picker/common.ts @@ -0,0 +1,63 @@ +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; + +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; + } + + // Trigger parsing through canvas context + ctx.fillStyle = colorString; + 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; +} diff --git a/src/components/color-picker/converters.ts b/src/components/color-picker/converters.ts new file mode 100644 index 000000000..4bd77878e --- /dev/null +++ b/src/components/color-picker/converters.ts @@ -0,0 +1,190 @@ +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.round(v) & 0xff); + 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) : 1 + s - 1 * 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..011faa1b1 --- /dev/null +++ b/src/components/color-picker/model.spec.ts @@ -0,0 +1,555 @@ +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'); + }); + + 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 handle empty string', () => { + const color = ColorModel.parse(''); + + expect(color.r).to.equal(0); + expect(color.g).to.equal(0); + expect(color.b).to.equal(0); + expect(color.alpha).to.equal(1); + }); + }); + + 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('rgba(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('rgba(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('hsla(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('hsla(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..0fd08a4df --- /dev/null +++ b/src/components/color-picker/model.ts @@ -0,0 +1,295 @@ +import { clamp } from '../common/util.js'; +import { parseColor } from './common.js'; +import { converter, type HSL, type HSV, type RGB } from './converters.js'; + +export type ColorFormat = 'hex' | 'rgb' | 'hsl'; + +/** + * Configuration options for color formatting. + */ +export interface ColorConfig { + /** The output format for the color string */ + format?: ColorFormat; + /** Whether to include alpha channel in the output */ + withAlpha?: boolean; +} + +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; + + /** + * 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); + } + + /** + * Parses a color string and creates a ColorModel instance. + * Supports hex, rgb, rgba, hsl, hsla, and named color formats. + * + * @param color - The color string to parse + * @returns A new ColorModel instance + */ + public static parse(color: string): ColorModel { + const parsed = parseColor(color, getContext()); + 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); + } + + /** Red component (0-255) */ + public get r(): number { + return this._rgb[0]; + } + + public set r(value: number) { + 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._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._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._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._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._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._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._alpha = clamp(value, 0, 1); + } + + /** + * 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 { + 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 hasAlpha + ? `rgba(${r}, ${g}, ${b}, ${this._alpha})` + : `rgb(${r}, ${g}, ${b})`; + } + case 'hsl': { + const [h, s, l] = this._hsl.map((v) => Math.round(v)); + return hasAlpha + ? `hsla(${h}, ${s}%, ${l}%, ${this._alpha})` + : `hsl(${h}, ${s}%, ${l}%)`; + } + } + } + + /** + * Creates a copy of this color model. + * + * @returns A new ColorModel instance with the same values + */ + public clone(): ColorModel { + return new ColorModel([...this._rgb] as RGB, this._alpha); + } + + /** + * 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._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.ts b/src/components/color-picker/picker-canvas.ts new file mode 100644 index 000000000..295109867 --- /dev/null +++ b/src/components/color-picker/picker-canvas.ts @@ -0,0 +1,153 @@ +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 { + igcColorPicked: CustomEvent; +} + +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('div', 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) + .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; + + Object.assign(this, { x, y }); + + if (shouldEmit) { + this.emitEvent('igcColorPicked', { + detail: { + x: Math.round(asPercent(x + width, rect.width)), + y: Math.round(asPercent(y + height, rect.height)), + }, + }); + } + } + + 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; + + Object.assign(this, { x, y }); + + if (shouldEmit) { + this.emitEvent('igcColorPicked', { + detail: { + x: Math.round(asPercent(x + width, rect.width)), + y: Math.round(asPercent(y + height, rect.height)), + }, + }); + } + } + + 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 { width: rect.width / 2, height: rect.height / 2 }; + } + + 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..4ad9d42eb --- /dev/null +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -0,0 +1,136 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + content-visibility: auto; + contain-intrinsic-size: 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, + rgba(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='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); + } + + #color-thumb { + background-color: var(--current-color); + min-width: 2rem; + max-width: 4rem; + } + + [part='picker'] { + display: grid; + padding: 0.25rem; + min-width: rem(370px); + min-height: 12rem; + grid-template-rows: 2fr 1fr; + grid-row-gap: 0.5rem; + box-shadow: var(--ig-elevation-3); + } +} + +[part='inputs'] { + display: flex; + gap: 1rem; +} 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..24878624b --- /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 { + contain-intrinsic-size: auto; + content-visibility: auto; + contain: strict; + display: flex; + position: relative; + height: 100%; + background-image: linear-gradient(rgba(0, 0, 0, 0), #000), + linear-gradient(90deg, #fff, currentColor); + cursor: pointer; +} + +[part='marker'] { + position: absolute; + width: 0.75rem; + height: 0.75rem; + border: 1px solid #fff; + border-radius: 50%; + cursor: pointer; +} diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 4d3e03119..a0af73110 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'; @@ -91,6 +92,7 @@ const allComponents: IgniteComponent[] = [ IgcChatComponent, IgcCheckboxComponent, IgcChipComponent, + IgcColorPickerComponent, IgcComboComponent, IgcDatePickerComponent, IgcDateRangePickerComponent, diff --git a/src/index.ts b/src/index.ts index 324c4660f..ee7175618 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..96a888ad9 --- /dev/null +++ b/stories/color-picker.stories.ts @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; + +import { IgcColorPickerComponent, defineComponents } from '../src/index.js'; +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.' } }, + actions: { + handles: [ + 'igcOpening', + 'igcOpened', + 'igcClosing', + 'igcClosed', + 'igcColorPicked', + ], + }, + }, + argTypes: { + label: { + type: 'string', + description: 'The label of the component.', + control: 'text', + }, + value: { + type: 'string', + description: 'The value of the component.', + 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' } }, + }, + 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' } }, + }, + keepOpenOnSelect: { + type: 'boolean', + description: + 'Whether the component dropdown should be kept open on selection.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + keepOpenOnOutsideClick: { + type: 'boolean', + description: + 'Whether the component dropdown should be kept open on clicking outside of it.', + 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, + disabled: false, + invalid: false, + keepOpenOnSelect: false, + keepOpenOnOutsideClick: false, + open: false, + }, +}; + +export default metadata; + +interface IgcColorPickerArgs { + /** The label of the component. */ + label: string; + /** The value of the component. */ + value: string; + /** Sets the color format for the string value. */ + format: 'hex' | 'rgb' | 'hsl'; + /** Whether to hide the format picker buttons. */ + hideFormats: 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; + /** Whether the component dropdown should be kept open on selection. */ + keepOpenOnSelect: boolean; + /** Whether the component dropdown should be kept open on clicking outside of it. */ + keepOpenOnOutsideClick: 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', + }, +}; + +export const Form: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+ + + +
+ + ${formControls()} +
+ `, +}; From 0545c929216b89c899e26a5ce79df0de2fe7cb60 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 14 Nov 2025 10:25:39 +0200 Subject: [PATCH 2/2] fix: Stylelint auto-fix for SCSS files in color-picker component --- src/components/color-picker/themes/color-picker.base.scss | 7 +++---- src/components/color-picker/themes/picker-canvas.base.scss | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index 4ad9d42eb..d9d707441 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -5,6 +5,7 @@ content-visibility: auto; contain-intrinsic-size: auto; contain: strict; + --current-color: #000; --hue-slider-track: linear-gradient( to right, @@ -16,10 +17,9 @@ #f0f 83.33%, red 100% ); - --alpha-slider-track: linear-gradient( 90deg, - rgba(0, 0, 0, 0), + rgb(0 0 0 / 0%), var(--current-color) ), repeating-linear-gradient( @@ -38,7 +38,6 @@ #aaa 75%, #aaa ); - --alpha-track-position: 0 0, 0 0, 4px 4px; --alpha-track-size: contain, 8px 8px, 8px 8px; @@ -125,7 +124,7 @@ min-width: rem(370px); min-height: 12rem; grid-template-rows: 2fr 1fr; - grid-row-gap: 0.5rem; + row-gap: 0.5rem; box-shadow: var(--ig-elevation-3); } } diff --git a/src/components/color-picker/themes/picker-canvas.base.scss b/src/components/color-picker/themes/picker-canvas.base.scss index 24878624b..536137509 100644 --- a/src/components/color-picker/themes/picker-canvas.base.scss +++ b/src/components/color-picker/themes/picker-canvas.base.scss @@ -8,7 +8,7 @@ display: flex; position: relative; height: 100%; - background-image: linear-gradient(rgba(0, 0, 0, 0), #000), + background-image: linear-gradient(rgb(0 0 0 / 0%), #000), linear-gradient(90deg, #fff, currentColor); cursor: pointer; }