From e9f0112f07fd7bbb50f968438b37f26d981d1799 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 14 Nov 2025 10:10:12 +0200 Subject: [PATCH 1/6] 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 0000000000..65c65be185 --- /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 0000000000..6c25a5260d --- /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 0000000000..efd8b68002 --- /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 0000000000..0329fb2e9e --- /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 0000000000..4bd77878e5 --- /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 0000000000..011faa1b12 --- /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 0000000000..0fd08a4df2 --- /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 0000000000..295109867c --- /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 0000000000..4ad9d42eb6 --- /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 0000000000..24878624bc --- /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 4d3e031192..a0af731107 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 324c4660f2..ee7175618e 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 0000000000..96a888ad9c --- /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/6] 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 4ad9d42eb6..d9d7074418 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 24878624bc..536137509b 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; } From 003ea7f5a8ff6a8a7d956d58e006ffd347004282 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 26 May 2026 13:26:50 +0300 Subject: [PATCH 3/6] refactor: Cleaned up color syncing logic in color-picker component --- src/components/color-picker/color-picker.ts | 28 ++++++++++---------- src/components/color-picker/picker-canvas.ts | 18 ++++++++----- stories/color-picker.stories.ts | 20 -------------- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 6c25a5260d..aa66a0761f 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -9,12 +9,12 @@ import { } 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 { IgcBaseComboBoxComponent } 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 { asNumber, stopPropagation } from '../common/util.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -36,10 +36,6 @@ export interface IgcColorPickerEventMap { igcColorPicked: CustomEvent; } -function stopPropagation(event: Event, immediate = false) { - immediate ? event.stopImmediatePropagation() : event.stopPropagation(); -} - /** * Color input component. * @@ -54,8 +50,8 @@ function stopPropagation(event: Event, immediate = false) { export default class IgcColorPickerComponent extends FormAssociatedMixin( EventEmitterMixin< IgcColorPickerEventMap, - AbstractConstructor - >(IgcBaseComboBoxLikeComponent) + AbstractConstructor + >(IgcBaseComboBoxComponent) ) { public static readonly tagName = 'igc-color-picker'; public static styles = styles; @@ -100,7 +96,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( protected readonly _alphaSlider!: HTMLInputElement; @query(IgcPickerCanvasComponent.tagName) - protected readonly _canvasPicker!: IgcPickerCanvasComponent; + protected readonly _canvasPicker?: IgcPickerCanvasComponent; /** * The label of the component. @@ -118,7 +114,6 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._color = ColorModel.parse(value); this._formValue.setValueAndFormState(this._color.asString(this.format)); this._updateColor(); - this._syncCanvasPosition(); } public get value(): string { @@ -142,8 +137,6 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( constructor() { super(); - addSafeEventListener(this, 'igcOpened' as any, this._syncCanvasPosition); - addKeybindings(this, { skip: () => this.disabled }).set( escapeKey, this._onEscapeKey @@ -158,6 +151,13 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( super.update(props); } + protected override updated(properties: PropertyValues): void { + if (properties.has('open')) { + // Wait till the browser paints and then sync the marker position with the color. + requestAnimationFrame(() => this._syncCanvasPosition()); + } + } + private _handleClosing(): void { this._hide(true); } @@ -198,7 +198,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } private _syncCanvasPosition(): void { - if (!(this.open || this._canvasPicker)) return; + if (!this._canvasPicker || !this.open) return; const rect = this._canvasPicker.getBoundingClientRect(); const { width: markerWidth, height: markerHeight } = @@ -435,7 +435,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( ?disabled=${this.disabled} .value=${this.value} label=${ifDefined(this.label)} - @pointerdown=${this.handleAnchorClick} + @click=${this._handleAnchorClick} > diff --git a/src/components/color-picker/picker-canvas.ts b/src/components/color-picker/picker-canvas.ts index 295109867c..db9a7ac60a 100644 --- a/src/components/color-picker/picker-canvas.ts +++ b/src/components/color-picker/picker-canvas.ts @@ -34,8 +34,8 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< registerComponent(IgcPickerCanvasComponent); } - @query('div', true) - private readonly _marker!: HTMLDivElement; + @query('div') + private readonly _marker?: HTMLDivElement; @property() public currentColor = ''; @@ -78,7 +78,8 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< const shouldEmit = x !== this.x || y !== this.y; - Object.assign(this, { x, y }); + this.x = x; + this.y = y; if (shouldEmit) { this.emitEvent('igcColorPicked', { @@ -103,7 +104,8 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< const y = clamp(event.clientY - rect.y - height, -height, maxY); const shouldEmit = x !== this.x || y !== this.y; - Object.assign(this, { x, y }); + this.x = x; + this.y = y; if (shouldEmit) { this.emitEvent('igcColorPicked', { @@ -124,7 +126,7 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< private _handleLostPointerCapture(): void { this.removeEventListener('pointermove', this._handlePointerMove); - this._marker.focus(); + this._marker?.focus(); } private _handlePointerMove(event: PointerEvent): void { @@ -132,8 +134,10 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< } public getMarkerDimensions(): { width: number; height: number } { - const rect = this._marker.getBoundingClientRect(); - return { width: rect.width / 2, height: rect.height / 2 }; + const rect = this._marker?.getBoundingClientRect(); + return rect + ? { width: rect.width / 2, height: rect.height / 2 } + : { width: 0, height: 0 }; } protected override render() { diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index 96a888ad9c..951341eabd 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -67,20 +67,6 @@ const metadata: Meta = { 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.', @@ -93,8 +79,6 @@ const metadata: Meta = { hideFormats: false, disabled: false, invalid: false, - keepOpenOnSelect: false, - keepOpenOnOutsideClick: false, open: false, }, }; @@ -116,10 +100,6 @@ interface IgcColorPickerArgs { 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; } From 6dbefd33a09b4d3354e4b5e7de3927375f15eef2 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 26 May 2026 15:45:26 +0300 Subject: [PATCH 4/6] feat: Use EyeDropper API for color picking where supported --- src/components/color-picker/color-picker.ts | 39 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index aa66a0761f..dda8b3123c 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -3,6 +3,7 @@ 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 IgcButtonComponent from '../button/button.js'; import { addKeybindings, escapeKey, @@ -63,7 +64,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( IgcPopoverComponent, IgcFocusTrapComponent, IgcRadioGroupComponent, - IgcPickerCanvasComponent + IgcPickerCanvasComponent, + IgcButtonComponent ); } @@ -271,6 +273,19 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._syncCanvasPosition(); } + private _handleEyeDropperClick(): void { + const eyeDropper = new (window as any).EyeDropper(); + + eyeDropper + .open() + .then((result: { sRGBHex: string }) => { + this.value = result.sRGBHex; + this._syncCanvasPosition(); + this._emitColorPickedEvent(); + }) + .catch(() => {}); + } + protected _renderFormatRadios() { return html` + 👁️💧 + + `; + } + protected _renderPicker() { return html`
${this._renderGradientArea()} -
${this._renderHueSlider()}${this._renderAlphaSlider()} ${this._renderFormats()}${this._renderColorInputs()} + ${this._renderEyeDropperButton()}
@@ -450,3 +481,7 @@ declare global { 'igc-color-picker': IgcColorPickerComponent; } } + +function supportsEyeDropper(): boolean { + return 'EyeDropper' in window; +} From decc7e25232eb9df3df323e9a037a913fdb52826 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 28 May 2026 10:44:38 +0300 Subject: [PATCH 5/6] refactor: Use modern color formats and update color picker component --- src/components/color-picker/color-picker.ts | 64 ++++++---- src/components/color-picker/model.spec.ts | 16 +-- src/components/color-picker/model.ts | 8 +- src/components/color-picker/picker-canvas.ts | 2 +- .../themes/color-picker.base.scss | 11 +- stories/color-picker.stories.ts | 10 ++ stories/date-time-input.stories.ts | 109 +++++++++--------- tsconfig.json | 2 +- 8 files changed, 123 insertions(+), 99 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index dda8b3123c..dd578a2645 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -4,6 +4,7 @@ import { cache } from 'lit/directives/cache.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import IgcButtonComponent from '../button/button.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; import { addKeybindings, escapeKey, @@ -65,7 +66,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( IgcFocusTrapComponent, IgcRadioGroupComponent, IgcPickerCanvasComponent, - IgcButtonComponent + IgcButtonComponent, + IgcIconButtonComponent ); } @@ -80,6 +82,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( initialValue: '', }); + private _supportsEyeDropper = 'EyeDropper' in globalThis; private _color = ColorModel.default(); @state({ hasChanged: () => true }) @@ -154,7 +157,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } protected override updated(properties: PropertyValues): void { - if (properties.has('open')) { + if (properties.has('open') || properties.has('value')) { // Wait till the browser paints and then sync the marker position with the color. requestAnimationFrame(() => this._syncCanvasPosition()); } @@ -194,7 +197,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } private _updateColor(): void { - this._ownCurrentColor = `hsl(${this._color.h}, 100%, 50%)`; + this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; this.style.setProperty('--current-color', this._ownCurrentColor); this._formValue.setValueAndFormState(this._color.asString(this.format)); } @@ -274,7 +277,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } private _handleEyeDropperClick(): void { - const eyeDropper = new (window as any).EyeDropper(); + const eyeDropper = new (globalThis as any).EyeDropper(); eyeDropper .open() @@ -286,6 +289,10 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( .catch(() => {}); } + private _handleCopy(): void { + navigator.clipboard.writeText(this.value); + } + protected _renderFormatRadios() { return html` @@ -340,7 +347,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( part="alpha" min="0" max="100" - .value=${(this._color.alpha * 100).toString()} + value=${String(this._color.alpha * 100)} @input=${this._handleAlphaValueChange} @change=${stopPropagation} /> @@ -423,30 +430,47 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } private _renderEyeDropperButton() { - if (!supportsEyeDropper()) { - return nothing; - } + return this._supportsEyeDropper + ? html` + + 👁️ + + ` + : nothing; + } + + private _renderCopyButton() { + const style = styleMap({ + '--current-color': this._color.asString('rgb', true), + '--border-color': 'transparent', + }); return html` - - 👁️💧 - + `; } protected _renderPicker() { return html` -
+
${this._renderGradientArea()}
${this._renderHueSlider()}${this._renderAlphaSlider()} ${this._renderFormats()}${this._renderColorInputs()} - ${this._renderEyeDropperButton()} + ${this._renderEyeDropperButton()}${this._renderCopyButton()}
@@ -481,7 +505,3 @@ declare global { 'igc-color-picker': IgcColorPickerComponent; } } - -function supportsEyeDropper(): boolean { - return 'EyeDropper' in window; -} diff --git a/src/components/color-picker/model.spec.ts b/src/components/color-picker/model.spec.ts index 011faa1b12..8bdebd434f 100644 --- a/src/components/color-picker/model.spec.ts +++ b/src/components/color-picker/model.spec.ts @@ -316,25 +316,25 @@ describe('ColorModel', () => { 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)'); + 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)'); + 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('rgba(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)'); + expect(color.asString('rgb')).to.equal('rgb(256 128 65)'); }); }); @@ -342,26 +342,26 @@ describe('ColorModel', () => { 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%)'); + 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)'); + 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('hsla(0, 100%, 50%, 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+%\)$/); + expect(hslString).to.match(/^hsl\(\d+ \d+% \d+%\)$/); }); }); }); diff --git a/src/components/color-picker/model.ts b/src/components/color-picker/model.ts index 0fd08a4df2..8c3bb7e686 100644 --- a/src/components/color-picker/model.ts +++ b/src/components/color-picker/model.ts @@ -229,15 +229,11 @@ export class ColorModel { } 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})`; + return `rgb(${r} ${g} ${b}${hasAlpha ? ` / ${this._alpha}` : ''})`; } 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}%)`; + return `hsl(${h} ${s}% ${l}%${hasAlpha ? ` / ${this._alpha}` : ''})`; } } } diff --git a/src/components/color-picker/picker-canvas.ts b/src/components/color-picker/picker-canvas.ts index db9a7ac60a..32e0ffef46 100644 --- a/src/components/color-picker/picker-canvas.ts +++ b/src/components/color-picker/picker-canvas.ts @@ -56,7 +56,7 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< this._handleLostPointerCapture ); - addKeybindings(this) + 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 })) diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index d9d7074418..3925bec244 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -17,11 +17,8 @@ #f0f 83.33%, red 100% ); - --alpha-slider-track: linear-gradient( - 90deg, - rgb(0 0 0 / 0%), - var(--current-color) - ), + --alpha-slider-track: + linear-gradient(90deg, rgb(0 0 0 / 0%), var(--current-color)), repeating-linear-gradient( 45deg, #aaa 25%, @@ -127,6 +124,10 @@ row-gap: 0.5rem; box-shadow: var(--ig-elevation-3); } + + [part='copy']::part(base) { + background-color: var(--current-color); + } } [part='inputs'] { diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index 951341eabd..b6549f32d2 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -108,12 +108,22 @@ type Story = StoryObj; // endregion export const Default: Story = { + parameters: { + actions: { + handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], + }, + }, args: { label: 'Pick a color', }, }; export const InitialValue: Story = { + parameters: { + actions: { + handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], + }, + }, args: { label: 'Pick a color', value: 'rebeccapurple', diff --git a/stories/date-time-input.stories.ts b/stories/date-time-input.stories.ts index b770c25dcf..5c2f30daa7 100644 --- a/stories/date-time-input.stories.ts +++ b/stories/date-time-input.stories.ts @@ -27,44 +27,10 @@ const metadata: Meta = { }, argTypes: { value: { - type: 'string | Date', - description: 'The value of the input.', - options: ['string', 'Date'], - control: 'text', - }, - min: { type: 'Date', - description: 'The minimum value required for the input to remain valid.', - control: 'date', - }, - max: { - type: 'Date', - description: 'The maximum value required for the input to remain valid.', + description: 'The value of the input.', control: 'date', }, - inputFormat: { - type: 'string', - description: 'The date format to apply on the input.', - control: 'text', - }, - displayFormat: { - type: 'string', - description: - 'Format to display the value in when not editing.\nDefaults to the locale format if not set.', - control: 'text', - }, - spinLoop: { - type: 'boolean', - description: 'Sets whether to loop over the currently spun segment.', - control: 'boolean', - table: { defaultValue: { summary: 'true' } }, - }, - locale: { - type: 'string', - description: - 'Gets/Sets the locale used for formatting the display value.', - control: 'text', - }, readOnly: { type: 'boolean', description: 'Makes the control a readonly field.', @@ -73,9 +39,8 @@ const metadata: Meta = { }, mask: { type: 'string', - description: 'The masked pattern of the component.', + description: 'The mask pattern of the component.', control: 'text', - table: { defaultValue: { summary: 'CCCCCCCCCC' } }, }, prompt: { type: 'string', @@ -124,16 +89,48 @@ const metadata: Meta = { description: 'The label for the control.', control: 'text', }, + inputFormat: { + type: 'string', + description: 'The date format to apply on the input.', + control: 'text', + }, + min: { + type: 'Date', + description: 'The minimum value required for the input to remain valid.', + control: 'date', + }, + max: { + type: 'Date', + description: 'The maximum value required for the input to remain valid.', + control: 'date', + }, + displayFormat: { + type: 'string', + description: + 'Format to display the value in when not editing.\nDefaults to the locale format if not set.', + control: 'text', + }, + spinLoop: { + type: 'boolean', + description: 'Sets whether to loop over the currently spun segment.', + control: 'boolean', + table: { defaultValue: { summary: 'true' } }, + }, + locale: { + type: 'string', + description: + 'Gets/Sets the locale used for formatting the display value.', + control: 'text', + }, }, args: { - spinLoop: true, readOnly: false, - mask: 'CCCCCCCCCC', prompt: '_', required: false, disabled: false, invalid: false, outlined: false, + spinLoop: true, }, }; @@ -141,25 +138,10 @@ export default metadata; interface IgcDateTimeInputArgs { /** The value of the input. */ - value: string | Date; - /** The minimum value required for the input to remain valid. */ - min: Date; - /** The maximum value required for the input to remain valid. */ - max: Date; - /** The date format to apply on the input. */ - inputFormat: string; - /** - * Format to display the value in when not editing. - * Defaults to the locale format if not set. - */ - displayFormat: string; - /** Sets whether to loop over the currently spun segment. */ - spinLoop: boolean; - /** Gets/Sets the locale used for formatting the display value. */ - locale: string; + value: Date; /** Makes the control a readonly field. */ readOnly: boolean; - /** The masked pattern of the component. */ + /** The mask pattern of the component. */ mask: string; /** The prompt symbol to use for unfilled parts of the mask pattern. */ prompt: string; @@ -177,6 +159,21 @@ interface IgcDateTimeInputArgs { placeholder: string; /** The label for the control. */ label: string; + /** The date format to apply on the input. */ + inputFormat: string; + /** The minimum value required for the input to remain valid. */ + min: Date; + /** The maximum value required for the input to remain valid. */ + max: Date; + /** + * Format to display the value in when not editing. + * Defaults to the locale format if not set. + */ + displayFormat: string; + /** Sets whether to loop over the currently spun segment. */ + spinLoop: boolean; + /** Gets/Sets the locale used for formatting the display value. */ + locale: string; } type Story = StoryObj; diff --git a/tsconfig.json b/tsconfig.json index 520976e0bb..6950fde565 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" } From a19a9da486bebb0acaca1763fdf1887e20692c7a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 2 Jun 2026 16:35:34 +0300 Subject: [PATCH 6/6] feat: Added predefined swatches to color picker --- src/components/color-picker/color-picker.ts | 46 ++++++++++++++++++- .../themes/color-picker.base.scss | 24 +++++++++- .../themes/picker-canvas.base.scss | 7 +-- stories/color-picker.stories.ts | 27 +++++++++++ 4 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index dd578a2645..2222106de3 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -16,7 +16,12 @@ 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 { asNumber, stopPropagation } from '../common/util.js'; +import { + asNumber, + getElementFromPath, + isEmpty, + stopPropagation, +} from '../common/util.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; @@ -125,6 +130,10 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( return this._formValue.value; } + /** Pre-defined color swatches. */ + @property({ attribute: false }) + public swatches: string[] = []; + /** * Sets the color format for the string value. * @attr @@ -293,6 +302,16 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( navigator.clipboard.writeText(this.value); } + private _handleSwatchClick(event: Event): void { + const color = getElementFromPath('button[part="swatch"]', event)?.ariaLabel; + + if (color) { + this.value = color; + this._syncCanvasPosition(); + this._emitColorPickedEvent(); + } + } + protected _renderFormatRadios() { return html` + ${this.swatches.map( + (color) => html` + + ` + )} +
+ ` + : nothing; + } + + private _renderPicker() { return html`
@@ -470,8 +509,11 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin(
${this._renderHueSlider()}${this._renderAlphaSlider()} ${this._renderFormats()}${this._renderColorInputs()} +
+
${this._renderEyeDropperButton()}${this._renderCopyButton()}
+ ${this._renderSwatches()}
`; diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index 3925bec244..b8b1cf8f8d 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -118,9 +118,9 @@ [part='picker'] { display: grid; padding: 0.25rem; - min-width: rem(370px); + min-width: rem(300px); min-height: 12rem; - grid-template-rows: 2fr 1fr; + grid-template-rows: 1.5fr 1fr; row-gap: 0.5rem; box-shadow: var(--ig-elevation-3); } @@ -128,6 +128,26 @@ [part='copy']::part(base) { background-color: var(--current-color); } + + [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; + } } [part='inputs'] { diff --git a/src/components/color-picker/themes/picker-canvas.base.scss b/src/components/color-picker/themes/picker-canvas.base.scss index 536137509b..58c3ac6f60 100644 --- a/src/components/color-picker/themes/picker-canvas.base.scss +++ b/src/components/color-picker/themes/picker-canvas.base.scss @@ -8,9 +8,10 @@ display: flex; position: relative; height: 100%; - background-image: linear-gradient(rgb(0 0 0 / 0%), #000), + background-image: + linear-gradient(rgb(0 0 0 / 0%), #000), linear-gradient(90deg, #fff, currentColor); - cursor: pointer; + cursor: crosshair; } [part='marker'] { @@ -19,5 +20,5 @@ height: 0.75rem; border: 1px solid #fff; border-radius: 50%; - cursor: pointer; + cursor: crosshair; } diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index b6549f32d2..4232c146e6 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -130,6 +130,33 @@ export const InitialValue: Story = { }, }; +export const CustomSwatches: Story = { + parameters: { + actions: { + handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], + }, + }, + + render: () => html` + + `, +}; + export const Form: Story = { argTypes: disableStoryControls(metadata), render: () => html`