From e9f0112f07fd7bbb50f968438b37f26d981d1799 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Fri, 14 Nov 2025 10:10:12 +0200 Subject: [PATCH 01/17] 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 02/17] 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; } From 003ea7f5a8ff6a8a7d956d58e006ffd347004282 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 26 May 2026 13:26:50 +0300 Subject: [PATCH 03/17] 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 6c25a5260..aa66a0761 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 295109867..db9a7ac60 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 96a888ad9..951341eab 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 04/17] 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 aa66a0761..dda8b3123 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 05/17] 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 dda8b3123..dd578a264 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 011faa1b1..8bdebd434 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 0fd08a4df..8c3bb7e68 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 db9a7ac60..32e0ffef4 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 d9d707441..3925bec24 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 951341eab..b6549f32d 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 b770c25dc..5c2f30daa 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 520976e0b..6950fde56 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 06/17] 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 dd578a264..2222106de 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 3925bec24..b8b1cf8f8 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 536137509..58c3ac6f6 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 b6549f32d..4232c146e 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` From 09db9b1d11b2e12859d3e57f986606de954b3515 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 22 Jun 2026 18:36:31 +0300 Subject: [PATCH 07/17] refactor: Align color picker width with design specifications --- src/components/color-picker/color-picker.ts | 361 ++++++++---------- .../themes/color-picker.base.scss | 30 +- stories/color-picker.stories.ts | 9 + 3 files changed, 188 insertions(+), 212 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 2222106de..4f48a09fd 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -1,7 +1,7 @@ import { html, nothing, type PropertyValues } from 'lit'; -import { property, query, state } from 'lit/decorators.js'; +import { property, state } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; +import { createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import IgcButtonComponent from '../button/button.js'; import IgcIconButtonComponent from '../button/icon-button.js'; @@ -22,11 +22,13 @@ import { isEmpty, stopPropagation, } from '../common/util.js'; +import IgcDividerComponent from '../divider/divider.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; import type { IgcRadioChangeEventArgs } from '../radio/radio.js'; -import IgcRadioGroupComponent from '../radio-group/radio-group.js'; +import IgcSelectComponent from '../select/select.js'; +import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; import { ColorModel } from './model.js'; import IgcPickerCanvasComponent, { type IgcPickerCanvasEventMap, @@ -69,10 +71,12 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( IgcInputComponent, IgcPopoverComponent, IgcFocusTrapComponent, - IgcRadioGroupComponent, + IgcSelectComponent, IgcPickerCanvasComponent, + IgcDividerComponent, IgcButtonComponent, - IgcIconButtonComponent + IgcIconButtonComponent, + IgcVisuallyHiddenComponent ); } @@ -87,27 +91,17 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( initialValue: '', }); + private readonly _alphaRef = createRef(); + private readonly _anchorRef = createRef(); + private readonly _canvasRef = createRef(); + private readonly _hueRef = createRef(); + private _supportsEyeDropper = 'EyeDropper' in globalThis; private _color = ColorModel.default(); - @state({ hasChanged: () => true }) + @state() 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 @@ -136,18 +130,31 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( /** * Sets the color format for the string value. - * @attr + * + * @attr format + * @default 'hex' */ @property() public format: 'hex' | 'rgb' | 'hsl' = 'hex'; /** * Whether to hide the format picker buttons. - * @attr + * + * @attr hide-formats + * @default false */ - @property({ type: Boolean, attribute: 'hide-formats', reflect: true }) + @property({ type: Boolean, reflect: true, attribute: 'hide-formats' }) public hideFormats = false; + /** + * Whether to show the alpha slider and input. + * + * @attr show-alpha + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'show-alpha' }) + public showAlpha = false; + constructor() { super(); @@ -178,7 +185,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( protected async _onEscapeKey(): Promise { if (await this._hide(true)) { - this._input.focus(); + this._anchorRef.value?.focus(); } } @@ -192,15 +199,23 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _handleHueValueChange(event: Event): void { stopPropagation(event); - this._color.h = this._hueSlider.valueAsNumber; + this._color.h = this._hueRef.value?.valueAsNumber ?? 0; + this._updateColor(); + this._emitColorPickedEvent(); + } + + private _handleAlphaSliderValueChange(event: Event): void { + stopPropagation(event); + + this._color.alpha = (this._alphaRef.value?.valueAsNumber ?? 0) / 100; this._updateColor(); this._emitColorPickedEvent(); } - private _handleAlphaValueChange(event: Event): void { + private _handleAlphaInputChange(event: CustomEvent): void { stopPropagation(event); - this._color.alpha = this._alphaSlider.valueAsNumber / 100; + this._color.alpha = asNumber(event.detail) ?? 0; this._updateColor(); this._emitColorPickedEvent(); } @@ -209,27 +224,30 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; this.style.setProperty('--current-color', this._ownCurrentColor); this._formValue.setValueAndFormState(this._color.asString(this.format)); + this.requestUpdate('_ownCurrentColor'); } private _syncCanvasPosition(): void { - if (!this._canvasPicker || !this.open) return; + if (!this._canvasRef.value || !this.open) return; - const rect = this._canvasPicker.getBoundingClientRect(); + const rect = this._canvasRef.value.getBoundingClientRect(); const { width: markerWidth, height: markerHeight } = - this._canvasPicker.getMarkerDimensions(); + this._canvasRef.value.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; + this._canvasRef.value.x = x; + this._canvasRef.value.y = y; } protected _emitColorPickedEvent(): void { this.emitEvent('igcColorPicked', { detail: this.value }); } - protected _handleFormatChange(event: CustomEvent) { + protected _handleFormatChange( + event: CustomEvent + ): void { stopPropagation(event); this.format = event.detail.value as typeof this.format; @@ -246,43 +264,16 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._updateColor(); } - protected _handleColorInputChange(event: CustomEvent): void { + private _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; - } - } + const color = ColorModel.parse(event.detail); - this._updateColor(); - this._syncCanvasPosition(); + if (color) { + this._color = color; + this._updateColor(); + this._syncCanvasPosition(); + } } private _handleEyeDropperClick(): void { @@ -312,25 +303,28 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } } - protected _renderFormatRadios() { + protected _renderSelect() { return html` - + + + + - Hex - RGB - HSL - + Hex + RGB + HSL + `; } protected _renderFormats() { - return html` - ${cache(this.hideFormats ? nothing : this._renderFormatRadios())} - `; + return html`${cache(this.hideFormats ? nothing : this._renderSelect())}`; } protected _renderGradientArea() { @@ -346,6 +340,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( protected _renderHueSlider() { 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` - - `; + private _renderAlphaRow() { + return this.showAlpha + ? html` + + + + + + ` + : nothing; } - protected _renderColorInputs() { + private _renderEyeDropperButton() { return html` -
- ${cache( - this.format === 'hex' - ? this._renderHexInput() - : this._renderRGBInput() - )} - ${this._renderAlphaInput()} -
+ + 🫳 + `; } - private _renderEyeDropperButton() { - return this._supportsEyeDropper - ? html` - - 👁️ - - ` - : nothing; - } - private _renderCopyButton() { const style = styleMap({ '--current-color': this._color.asString('rgb', true), @@ -486,6 +423,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _renderSwatches() { return !isEmpty(this.swatches) ? html` +
${this.swatches.map( (color) => html` @@ -501,18 +439,39 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( : nothing; } + private _renderInputsRow() { + return html` + ${this._renderFormats()} + + + + + + `; + } + private _renderPicker() { return html` - -
+ +
${this._renderGradientArea()} -
- ${this._renderHueSlider()}${this._renderAlphaSlider()} - ${this._renderFormats()}${this._renderColorInputs()} -
-
- ${this._renderEyeDropperButton()}${this._renderCopyButton()} + +
+ ${this._renderHueSlider()} +
+ ${this._renderCopyButton()} ${this._renderEyeDropperButton()} +
+ +
${this._renderAlphaRow()}
+
${this._renderInputsRow()}
+ ${this._renderSwatches()}
@@ -521,23 +480,27 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( protected override render() { const style = styleMap({ - 'background-color': this._color.asString('rgb', true), + '--background': this._color.asString('rgb', true), }); return html` - - - - - ${this._renderPicker()} - +
+ + ${this.label} + + + + + ${this._renderPicker()} + +
`; } } diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index b8b1cf8f8..16d0f025e 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -45,6 +45,21 @@ width: 100%; } + [part='main-row'], + [part='alpha-row'], + [part='inputs-row'] { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + } + + [part='buttons'] { + --ig-size: 1; + display: flex; + margin-left: 1rem; + } + [part='hue']::-webkit-slider-runnable-track { border-radius: rem(4px); height: 0.5rem; @@ -109,18 +124,12 @@ 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(300px); + width: rem(280px); min-height: 12rem; - grid-template-rows: 1.5fr 1fr; + grid-template-rows: 6fr 1fr; row-gap: 0.5rem; box-shadow: var(--ig-elevation-3); } @@ -149,8 +158,3 @@ cursor: pointer; } } - -[part='inputs'] { - display: flex; - gap: 1rem; -} diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index 4232c146e..3e2e78b48 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -50,6 +50,12 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + showAlpha: { + type: 'boolean', + description: 'Whether to show the alpha slider and input.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, name: { type: 'string', description: 'The name attribute of the control.', @@ -77,6 +83,7 @@ const metadata: Meta = { args: { format: 'hex', hideFormats: false, + showAlpha: false, disabled: false, invalid: false, open: false, @@ -94,6 +101,8 @@ interface IgcColorPickerArgs { format: 'hex' | 'rgb' | 'hsl'; /** Whether to hide the format picker buttons. */ hideFormats: boolean; + /** Whether to show the alpha slider and input. */ + showAlpha: boolean; /** The name attribute of the control. */ name: string; /** The disabled state of the component. */ From 227b4e1a3228a5fa0263bd6fe52e4e0a992374a5 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 23 Jun 2026 20:08:05 +0300 Subject: [PATCH 08/17] feat(color-picker): support empty/undefined color values Introduce an explicit "missing color" sentinel and surface it through the color picker. ColorModel: - Add `ColorModel.empty()` factory and an `isEmpty` getter representing a missing/undefined color. `default()` keeps returning black. - Clear the empty state when any channel (r/g/b/h/s/l/v/alpha) is modified. - `asString()` returns an empty string while empty; `clone()` preserves the empty state and `equals()` accounts for it. - `parse()` returns the empty sentinel for null/undefined/empty/whitespace input. Component: - Initialize the internal color as empty so an unset picker has an empty value. - Render the trigger anchor with a checkered background while the value is empty, via an `empty` shadow part token. - Validate the color value input on commit using the new `isValidColor` helper; empty or invalid input reverts the field to the current color. Styles: - Add a checkered pattern on `[part~='empty']::part(base)`, mirroring the alpha slider track. Add `isValidColor()` and update model, common and component unit tests to cover the empty sentinel, validation and revert behavior. --- .../color-picker/color-picker.spec.ts | 87 ++++++++++++++- src/components/color-picker/color-picker.ts | 101 +++++++++++------- src/components/color-picker/common.spec.ts | 41 ++++++- src/components/color-picker/common.ts | 31 ++++++ src/components/color-picker/model.spec.ts | 44 +++++++- src/components/color-picker/model.ts | 40 ++++++- src/components/color-picker/picker-canvas.ts | 5 +- .../themes/color-picker.base.scss | 26 ++++- 8 files changed, 321 insertions(+), 54 deletions(-) diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index 65c65be18..2ad3726f3 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -2,6 +2,7 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { createFormAssociatedTestBed } from '../common/utils.spec.js'; +import type IgcInputComponent from '../input/input.js'; import IgcColorPickerComponent from './color-picker.js'; async function createDefaultColorPicker() { @@ -10,6 +11,25 @@ async function createDefaultColorPicker() { ); } +function getAnchor(picker: IgcColorPickerComponent): HTMLElement { + return picker.renderRoot.querySelector('[part="anchor"]')!; +} + +function getColorInput(picker: IgcColorPickerComponent): IgcInputComponent { + return picker.renderRoot.querySelector('#color-input')!; +} + +function commitColorInput(input: IgcInputComponent, value: string): void { + input.value = value; + input.dispatchEvent( + new CustomEvent('igcChange', { + detail: value, + bubbles: true, + composed: true, + }) + ); +} + describe('Color picker', () => { before(() => defineComponents(IgcColorPickerComponent)); @@ -52,6 +72,71 @@ describe('Color picker', () => { }); }); + describe('Empty value', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + }); + + it('has an empty value by default', () => { + expect(picker.value).to.equal(''); + }); + + it('renders a checkered anchor when empty', async () => { + expect(getAnchor(picker).hasAttribute('data-empty')).to.be.true; + + picker.value = '#ff0000'; + await elementUpdated(picker); + expect(getAnchor(picker).hasAttribute('data-empty')).to.be.false; + }); + + it('reverts to an empty value for null/undefined/empty', async () => { + for (const value of ['', null, undefined]) { + picker.value = '#ff0000'; + await elementUpdated(picker); + + picker.value = value as unknown as string; + await elementUpdated(picker); + + expect(picker.value).to.equal(''); + expect(getAnchor(picker).hasAttribute('data-empty')).to.be.true; + } + }); + }); + + describe('Color value input', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + picker.open = true; + await elementUpdated(picker); + }); + + it('commits a valid color', async () => { + commitColorInput(getColorInput(picker), '#00ff00'); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + }); + + it('reverts the input on an invalid color', async () => { + const input = getColorInput(picker); + commitColorInput(input, 'not-a-color'); + await elementUpdated(picker); + + expect(picker.value).to.equal('#ff0000'); + expect(input.value).to.equal('#ff0000'); + }); + + it('reverts the input on an empty color', async () => { + const input = getColorInput(picker); + commitColorInput(input, ''); + await elementUpdated(picker); + + expect(picker.value).to.equal('#ff0000'); + expect(input.value).to.equal('#ff0000'); + }); + }); + describe('Form associated', () => { const spec = createFormAssociatedTestBed( html`` @@ -78,7 +163,7 @@ describe('Color picker', () => { spec.setProperties({ value: '#bada55' }); spec.reset(); - expect(spec.element.value).to.equal('#000000'); + expect(spec.element.value).to.equal(''); }); it('reflects disabled ancestor state', () => { diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 4f48a09fd..4e9bd7308 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -16,6 +16,7 @@ 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 { partMap } from '../common/part-map.js'; import { asNumber, getElementFromPath, @@ -26,12 +27,13 @@ import IgcDividerComponent from '../divider/divider.js'; import IgcFocusTrapComponent from '../focus-trap/focus-trap.js'; import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; -import type { IgcRadioChangeEventArgs } from '../radio/radio.js'; import IgcSelectComponent from '../select/select.js'; +import type IgcSelectItemComponent from '../select/select-item.js'; import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; -import { ColorModel } from './model.js'; +import { isValidColor } from './common.js'; +import { ColorModel, getContext } from './model.js'; import IgcPickerCanvasComponent, { - type IgcPickerCanvasEventMap, + type PickerCanvasEventDetail, } from './picker-canvas.js'; import { styles } from './themes/color-picker.base.css.js'; @@ -65,6 +67,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( public static readonly tagName = 'igc-color-picker'; public static styles = styles; + /* blazorSuppress */ public static register(): void { registerComponent( IgcColorPickerComponent, @@ -97,7 +100,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private readonly _hueRef = createRef(); private _supportsEyeDropper = 'EyeDropper' in globalThis; - private _color = ColorModel.default(); + private _color = ColorModel.empty(); @state() private _ownCurrentColor = ''; @@ -174,7 +177,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( protected override updated(properties: PropertyValues): void { if (properties.has('open') || properties.has('value')) { - // Wait till the browser paints and then sync the marker position with the color. + // Wait until the browser paints and then sync the marker position with the color. requestAnimationFrame(() => this._syncCanvasPosition()); } } @@ -220,33 +223,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( 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)); - this.requestUpdate('_ownCurrentColor'); - } - - private _syncCanvasPosition(): void { - if (!this._canvasRef.value || !this.open) return; - - const rect = this._canvasRef.value.getBoundingClientRect(); - const { width: markerWidth, height: markerHeight } = - this._canvasRef.value.getMarkerDimensions(); - - const x = (this._color.s / 100) * rect.width - markerWidth; - const y = ((100 - this._color.v) / 100) * rect.height - markerHeight; - - this._canvasRef.value.x = x; - this._canvasRef.value.y = y; - } - - protected _emitColorPickedEvent(): void { - this.emitEvent('igcColorPicked', { detail: this.value }); - } - protected _handleFormatChange( - event: CustomEvent + event: CustomEvent ): void { stopPropagation(event); @@ -255,7 +233,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } protected _handleCanvasColorPicked( - event: IgcPickerCanvasEventMap['igcColorPicked'] + event: CustomEvent ): void { stopPropagation(event); @@ -267,13 +245,18 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _handleColorInputChange(event: CustomEvent): void { stopPropagation(event); - const color = ColorModel.parse(event.detail); + const input = event.target as IgcInputComponent; - if (color) { - this._color = color; - this._updateColor(); - this._syncCanvasPosition(); + // Commit only valid colors. An empty or invalid value reverts the input + // back to the currently represented color. + if (!isValidColor(event.detail, getContext())) { + input.value = this._color.asString(this.format); + return; } + + this._color = ColorModel.parse(event.detail); + this._updateColor(); + this._syncCanvasPosition(); } private _handleEyeDropperClick(): void { @@ -303,6 +286,31 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } } + private _updateColor(): void { + this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; + this.style.setProperty('--current-color', this._ownCurrentColor); + this._formValue.setValueAndFormState(this._color.asString(this.format)); + this.requestUpdate('_ownCurrentColor'); + } + + private _syncCanvasPosition(): void { + if (!this._canvasRef.value || !this.open) return; + + const rect = this._canvasRef.value.getBoundingClientRect(); + const { width: markerWidth, height: markerHeight } = + this._canvasRef.value.getMarkerDimensions(); + + const x = (this._color.s / 100) * rect.width - markerWidth; + const y = ((100 - this._color.v) / 100) * rect.height - markerHeight; + + this._canvasRef.value.x = x; + this._canvasRef.value.y = y; + } + + protected _emitColorPickedEvent(): void { + this.emitEvent('igcColorPicked', { detail: this.value }); + } + protected _renderSelect() { return html` @@ -312,6 +320,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( @@ -367,12 +377,15 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( @input=${this._handleAlphaSliderValueChange} @change=${stopPropagation} /> + + 🫳 + Pick a color from the screen `; } @@ -407,15 +420,14 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( return html` + Copy color value to clipboard `; } @@ -449,6 +461,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( + > + Open color picker + ${this._renderPicker()}
diff --git a/src/components/color-picker/common.spec.ts b/src/components/color-picker/common.spec.ts index efd8b6800..053a7bc60 100644 --- a/src/components/color-picker/common.spec.ts +++ b/src/components/color-picker/common.spec.ts @@ -1,6 +1,6 @@ import { expect } from '@open-wc/testing'; -import { type ParsedColor, parseColor } from './common.js'; +import { isValidColor, type ParsedColor, parseColor } from './common.js'; function makeTestContext() { try { @@ -172,3 +172,42 @@ describe('parseColor', () => { }); }); }); + +describe('isValidColor', () => { + let ctx: OffscreenCanvasRenderingContext2D | null; + + before(() => { + ctx = makeTestContext(); + }); + + it('should return true for valid hex colors', () => { + expect(isValidColor('#ff0000', ctx)).to.be.true; + expect(isValidColor('#f80', ctx)).to.be.true; + expect(isValidColor('#ff000080', ctx)).to.be.true; + }); + + it('should return true for valid rgb/rgba colors', () => { + expect(isValidColor('rgb(0, 128, 255)', ctx)).to.be.true; + expect(isValidColor('rgba(0, 128, 255, 0.5)', ctx)).to.be.true; + }); + + it('should return true for valid named colors', () => { + expect(isValidColor('red', ctx)).to.be.true; + expect(isValidColor('rebeccapurple', ctx)).to.be.true; + }); + + it('should return false for invalid colors', () => { + expect(isValidColor('not-a-color', ctx)).to.be.false; + expect(isValidColor('#zzz', ctx)).to.be.false; + expect(isValidColor('rgb(300)', ctx)).to.be.false; + }); + + it('should return false for empty or whitespace strings', () => { + expect(isValidColor('', ctx)).to.be.false; + expect(isValidColor(' ', ctx)).to.be.false; + }); + + it('should return false when context is null', () => { + expect(isValidColor('#ff0000', null)).to.be.false; + }); +}); diff --git a/src/components/color-picker/common.ts b/src/components/color-picker/common.ts index 0329fb2e9..420d19332 100644 --- a/src/components/color-picker/common.ts +++ b/src/components/color-picker/common.ts @@ -61,3 +61,34 @@ export function parseColor( return result; } + +/** + * Determines whether a given string is a valid CSS color. + * + * Uses the canvas 2D context to attempt parsing the string against two + * different baseline colors. A valid color resolves to the same computed value + * regardless of the baseline, while an invalid color leaves each baseline + * untouched and therefore produces two different results. + * + * @param colorString - The color string to validate + * @param ctx - Canvas context used for parsing + * @returns `true` if the string is a valid, non-empty CSS color + */ +export function isValidColor( + colorString: string, + ctx: OffscreenCanvasRenderingContext2D | null +): boolean { + if (!colorString?.trim() || !ctx) { + return false; + } + + ctx.fillStyle = '#000'; + ctx.fillStyle = colorString; + const onBlack = ctx.fillStyle; + + ctx.fillStyle = '#fff'; + ctx.fillStyle = colorString; + const onWhite = ctx.fillStyle; + + return onBlack === onWhite; +} diff --git a/src/components/color-picker/model.spec.ts b/src/components/color-picker/model.spec.ts index 8bdebd434..a4d691cef 100644 --- a/src/components/color-picker/model.spec.ts +++ b/src/components/color-picker/model.spec.ts @@ -12,6 +12,36 @@ describe('ColorModel', () => { expect(color.b).to.equal(0); expect(color.alpha).to.equal(1); expect(color.asString('hex')).to.equal('#000000'); + expect(color.isEmpty).to.be.false; + }); + + it('should create an empty color', () => { + const color = ColorModel.empty(); + + expect(color.isEmpty).to.be.true; + expect(color.asString('hex')).to.equal(''); + expect(color.asString('rgb')).to.equal(''); + expect(color.asString('hsl')).to.equal(''); + }); + + it('should clear the empty state when a channel is modified', () => { + const color = ColorModel.empty(); + color.r = 128; + + expect(color.isEmpty).to.be.false; + expect(color.asString('hex')).to.equal('#800000'); + }); + + it('should preserve the empty state when cloned', () => { + const color = ColorModel.empty(); + const clone = color.clone(); + + expect(clone.isEmpty).to.be.true; + expect(clone.equals(color)).to.be.true; + }); + + it('should treat empty and non-empty colors as not equal', () => { + expect(ColorModel.empty().equals(ColorModel.default())).to.be.false; }); it('should create a color from RGB values', () => { @@ -82,13 +112,17 @@ describe('ColorModel', () => { expect(color.b).to.equal(0); }); - it('should handle empty string', () => { + it('should return an empty color for an 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); + expect(color.isEmpty).to.be.true; + expect(color.asString('hex')).to.equal(''); + }); + + it('should return an empty color for a whitespace string', () => { + const color = ColorModel.parse(' '); + + expect(color.isEmpty).to.be.true; }); }); diff --git a/src/components/color-picker/model.ts b/src/components/color-picker/model.ts index 8c3bb7e68..c641598b0 100644 --- a/src/components/color-picker/model.ts +++ b/src/components/color-picker/model.ts @@ -51,6 +51,7 @@ export class ColorModel { private _hsl: HSL; private _hsv: HSV; private _alpha: number; + private _empty = false; /** * Creates a default black color with full opacity. @@ -60,6 +61,19 @@ export class ColorModel { return new ColorModel([0, 0, 0], 1); } + /** + * Creates an empty color, representing a missing/undefined color value. + * An empty color serializes to an empty string and is considered "empty" + * until any of its channels are modified. + * + * @returns A new empty ColorModel instance + */ + public static empty(): ColorModel { + const color = new ColorModel([0, 0, 0], 1); + color._empty = true; + return color; + } + /** * Parses a color string and creates a ColorModel instance. * Supports hex, rgb, rgba, hsl, hsla, and named color formats. @@ -68,6 +82,10 @@ export class ColorModel { * @returns A new ColorModel instance */ public static parse(color: string): ColorModel { + if (!color?.trim()) { + return ColorModel.empty(); + } + const parsed = parseColor(color, getContext()); return new ColorModel(parsed.value, parsed.alpha); } @@ -124,12 +142,18 @@ export class ColorModel { this._alpha = clamp(alpha, 0, 1); } + /** Whether the color represents a missing/undefined value. */ + public get isEmpty(): boolean { + return this._empty; + } + /** Red component (0-255) */ public get r(): number { return this._rgb[0]; } public set r(value: number) { + this._empty = false; this._rgb[0] = clamp(value, 0, 255); this._hsl = converter.rgb.hsl(this._rgb); this._hsv = converter.rgb.hsv(this._rgb); @@ -141,6 +165,7 @@ export class ColorModel { } public set g(value: number) { + this._empty = false; this._rgb[1] = clamp(value, 0, 255); this._hsl = converter.rgb.hsl(this._rgb); this._hsv = converter.rgb.hsv(this._rgb); @@ -152,6 +177,7 @@ export class ColorModel { } public set b(value: number) { + this._empty = false; this._rgb[2] = clamp(value, 0, 255); this._hsl = converter.rgb.hsl(this._rgb); this._hsv = converter.rgb.hsv(this._rgb); @@ -163,6 +189,7 @@ export class ColorModel { } public set h(value: number) { + this._empty = false; this._hsl[0] = clamp(value, 0, 360); this._rgb = converter.hsl.rgb(this._hsl); this._hsv = converter.hsl.hsv(this._hsl); @@ -174,6 +201,7 @@ export class ColorModel { } public set s(value: number) { + this._empty = false; this._hsl[1] = clamp(value, 0, 100); this._rgb = converter.hsl.rgb(this._hsl); this._hsv = converter.hsl.hsv(this._hsl); @@ -185,6 +213,7 @@ export class ColorModel { } public set l(value: number) { + this._empty = false; this._hsl[2] = clamp(value, 0, 100); this._rgb = converter.hsl.rgb(this._hsl); this._hsv = converter.hsl.hsv(this._hsl); @@ -196,6 +225,7 @@ export class ColorModel { } public set v(value: number) { + this._empty = false; this._hsv[2] = clamp(value, 0, 100); this._rgb = converter.hsv.rgb(this._hsv); this._hsl = converter.hsv.hsl(this._hsv); @@ -207,6 +237,7 @@ export class ColorModel { } public set alpha(value: number) { + this._empty = false; this._alpha = clamp(value, 0, 1); } @@ -218,6 +249,10 @@ export class ColorModel { * @returns CSS color string */ public asString(format: ColorFormat, forceAlpha = false): string { + if (this._empty) { + return ''; + } + const hasAlpha = this._alpha < 1 || forceAlpha; switch (format) { case 'hex': { @@ -244,7 +279,9 @@ export class ColorModel { * @returns A new ColorModel instance with the same values */ public clone(): ColorModel { - return new ColorModel([...this._rgb] as RGB, this._alpha); + const color = new ColorModel([...this._rgb] as RGB, this._alpha); + color._empty = this._empty; + return color; } /** @@ -255,6 +292,7 @@ export class ColorModel { */ public equals(other: ColorModel): boolean { return ( + this._empty === other._empty && this._rgb[0] === other._rgb[0] && this._rgb[1] === other._rgb[1] && this._rgb[2] === other._rgb[2] && diff --git a/src/components/color-picker/picker-canvas.ts b/src/components/color-picker/picker-canvas.ts index 32e0ffef4..7e5a95d97 100644 --- a/src/components/color-picker/picker-canvas.ts +++ b/src/components/color-picker/picker-canvas.ts @@ -18,10 +18,7 @@ export interface IgcPickerCanvasEventMap { igcColorPicked: CustomEvent; } -type PickerCanvasEventDetail = { - x: number; - y: number; -}; +export type PickerCanvasEventDetail = { x: number; y: number }; export default class IgcPickerCanvasComponent extends EventEmitterMixin< IgcPickerCanvasEventMap, diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index 16d0f025e..2c286dc62 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -127,7 +127,7 @@ [part='picker'] { display: grid; padding: 0.25rem; - width: rem(280px); + width: rem(300px); min-height: 12rem; grid-template-rows: 6fr 1fr; row-gap: 0.5rem; @@ -138,6 +138,30 @@ background-color: var(--current-color); } + [part~='empty']::part(base) { + background-image: + repeating-linear-gradient( + 45deg, + #aaa 25%, + transparent 25%, + transparent 75%, + #aaa 75%, + #aaa + ), + repeating-linear-gradient( + 45deg, + #aaa 25%, + #fff 25%, + #fff 75%, + #aaa 75%, + #aaa + ); + background-position: + 0 0, + 4px 4px; + background-size: 8px 8px; + } + [part='copy']::part(icon) { --foreground: contrast-color(var(--current-color)); } From b5735fd085eb232352a9e0dfa7fbc0fed8dc390c Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 23 Jun 2026 20:13:02 +0300 Subject: [PATCH 09/17] test: color-picker empty value handling --- src/components/color-picker/color-picker.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index 2ad3726f3..21c272189 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -12,7 +12,11 @@ async function createDefaultColorPicker() { } function getAnchor(picker: IgcColorPickerComponent): HTMLElement { - return picker.renderRoot.querySelector('[part="anchor"]')!; + return picker.renderRoot.querySelector('[part~="anchor"]')!; +} + +function isAnchorEmpty(picker: IgcColorPickerComponent): boolean { + return getAnchor(picker).part.contains('empty'); } function getColorInput(picker: IgcColorPickerComponent): IgcInputComponent { @@ -82,11 +86,11 @@ describe('Color picker', () => { }); it('renders a checkered anchor when empty', async () => { - expect(getAnchor(picker).hasAttribute('data-empty')).to.be.true; + expect(isAnchorEmpty(picker)).to.be.true; picker.value = '#ff0000'; await elementUpdated(picker); - expect(getAnchor(picker).hasAttribute('data-empty')).to.be.false; + expect(isAnchorEmpty(picker)).to.be.false; }); it('reverts to an empty value for null/undefined/empty', async () => { @@ -98,7 +102,7 @@ describe('Color picker', () => { await elementUpdated(picker); expect(picker.value).to.equal(''); - expect(getAnchor(picker).hasAttribute('data-empty')).to.be.true; + expect(isAnchorEmpty(picker)).to.be.true; } }); }); From 1ce4b891df0261629eea714e5cf8ee0658e8193e Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Tue, 23 Jun 2026 20:14:40 +0300 Subject: [PATCH 10/17] chore: fix styleint error --- src/components/color-picker/themes/color-picker.base.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index 2c286dc62..207041eb5 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -56,6 +56,7 @@ [part='buttons'] { --ig-size: 1; + display: flex; margin-left: 1rem; } From cb4b00888a408c5114f327db19b35e55909669b1 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 25 Jun 2026 16:52:01 +0300 Subject: [PATCH 11/17] feat: added input mode for the color picker component Some code reorganization and refactoring was done to support the new input mode. The color picker component now has an input mode that allows users to enter color values directly. The component will handle changes from the input field and update the color value accordingly. --- src/components/color-picker/color-picker.ts | 355 ++++++++++++------ .../themes/color-picker.base.scss | 1 + stories/color-picker.stories.ts | 10 + 3 files changed, 247 insertions(+), 119 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 4e9bd7308..c8d17c069 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -1,6 +1,7 @@ -import { html, nothing, type PropertyValues } from 'lit'; +import { html, nothing, type PropertyValues, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { cache } from 'lit/directives/cache.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import IgcButtonComponent from '../button/button.js'; @@ -19,6 +20,7 @@ import { createFormValueState } from '../common/mixins/forms/form-value.js'; import { partMap } from '../common/part-map.js'; import { asNumber, + bindIf, getElementFromPath, isEmpty, stopPropagation, @@ -83,6 +85,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( ); } + //#region Internal state and properties + protected override readonly _rootClickController = addRootClickController( this, { @@ -95,9 +99,11 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( }); private readonly _alphaRef = createRef(); - private readonly _anchorRef = createRef(); private readonly _canvasRef = createRef(); private readonly _hueRef = createRef(); + private readonly _anchorRef = createRef< + IgcButtonComponent | IgcInputComponent + >(); private _supportsEyeDropper = 'EyeDropper' in globalThis; private _color = ColorModel.empty(); @@ -105,6 +111,10 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( @state() private _ownCurrentColor = ''; + //#endregion + + //#region Public attributes and properties + /** * The label of the component. * @attr label @@ -114,6 +124,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( /** * The value of the component. + * * @attr value */ @property() @@ -127,10 +138,6 @@ 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. * @@ -158,6 +165,25 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( @property({ type: Boolean, reflect: true, attribute: 'show-alpha' }) public showAlpha = false; + /** + * The mode of the color picker. + * + * @attr mode + * @default 'default' + */ + @property() + public mode: 'default' | 'input' = 'default'; + + /** + * Pre-defined color swatches. + */ + @property({ attribute: false }) + public swatches: string[] = []; + + //#endregion + + //#region Lifecycle + constructor() { super(); @@ -182,21 +208,35 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } } + protected override _restoreDefaultValue(): void { + super._restoreDefaultValue(); + this._color = ColorModel.parse(this._formValue.value); + this._updateColor(); + this._syncCanvasPosition(); + } + + //#endregion + + //#region Event handlers + private _handleClosing(): void { this._hide(true); } - protected async _onEscapeKey(): Promise { + private async _onEscapeKey(): Promise { if (await this._hide(true)) { this._anchorRef.value?.focus(); } } - protected override _restoreDefaultValue(): void { - super._restoreDefaultValue(); - this._color = ColorModel.parse(this._formValue.value); + private _handleCanvasColorPicked( + event: CustomEvent + ): void { + stopPropagation(event); + + this._color.s = event.detail.x; + this._color.v = 100 - event.detail.y; this._updateColor(); - this._syncCanvasPosition(); } private _handleHueValueChange(event: Event): void { @@ -223,7 +263,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._emitColorPickedEvent(); } - protected _handleFormatChange( + private _handleFormatChange( event: CustomEvent ): void { stopPropagation(event); @@ -232,16 +272,6 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._updateColor(); } - protected _handleCanvasColorPicked( - event: CustomEvent - ): void { - stopPropagation(event); - - this._color.s = event.detail.x; - this._color.v = 100 - event.detail.y; - this._updateColor(); - } - private _handleColorInputChange(event: CustomEvent): void { stopPropagation(event); @@ -286,6 +316,10 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } } + //#endregion + + //#region Internal methods + private _updateColor(): void { this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; this.style.setProperty('--current-color', this._ownCurrentColor); @@ -307,36 +341,15 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._canvasRef.value.y = y; } - protected _emitColorPickedEvent(): void { + private _emitColorPickedEvent(): void { this.emitEvent('igcColorPicked', { detail: this.value }); } - protected _renderSelect() { - return html` - - - + //#endregion - - Hex - RGB - HSL - - `; - } - - protected _renderFormats() { - return html`${cache(this.hideFormats ? nothing : this._renderSelect())}`; - } + //#region Canvas area rendering - protected _renderGradientArea() { + private _renderCanvasGradient(): TemplateResult { return html` + Copy color value to clipboard + + `; + } + + private _renderEyeDropperButton(): TemplateResult { + return html` + + + + Pick a color from the screen + + `; + } + + private _renderHueRowAndButtons(): TemplateResult { + return html` + ${this._renderHueSlider()} +
+ ${this._renderCopyButton()} ${this._renderEyeDropperButton()} +
+ `; + } + + //#endregion + + //#region Alpha row rendering + + private _renderAlphaRow(): TemplateResult | typeof nothing { return this.showAlpha ? html` + + + + - 🫳 - Pick a color from the screen - + Hex + RGB + HSL + `; } - private _renderCopyButton() { - const style = styleMap({ - '--current-color': this._color.asString('rgb', true), - '--border-color': 'transparent', - }); + private _renderFormats(): TemplateResult { + return html`${cache(this.hideFormats ? nothing : this._renderSelect())}`; + } + private _renderInputsRow(): TemplateResult { return html` - - Copy color value to clipboard - + ${this._renderFormats()} + + + + + `; } - private _renderSwatches() { + //#endregion + + //#region Swatches rendering + + private _renderSwatches(): TemplateResult | typeof nothing { return !isEmpty(this.swatches) ? html` @@ -451,73 +549,92 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( : nothing; } - private _renderInputsRow() { + //#endregion + + //#region Anchor rendering + + private _renderButtonAnchor( + color: string, + parts: ReturnType + ): TemplateResult { return html` - ${this._renderFormats()} - - - + + Open color picker + + `; + } + private _renderInputAnchor( + color: string, + parts: ReturnType + ): TemplateResult { + return html` + > +
+ `; } - private _renderPicker() { + private _renderAnchor( + color: string, + parts: ReturnType + ): TemplateResult { + const isDefaultMode = this.mode === 'default'; + + return isDefaultMode + ? this._renderButtonAnchor(color, parts) + : this._renderInputAnchor(color, parts); + } + + //#endregion + + private _renderPicker(): TemplateResult { return html`
- ${this._renderGradientArea()} - -
- ${this._renderHueSlider()} -
- ${this._renderCopyButton()} ${this._renderEyeDropperButton()} -
-
- + ${this._renderCanvasGradient()} +
${this._renderHueRowAndButtons()}
${this._renderAlphaRow()}
${this._renderInputsRow()}
- ${this._renderSwatches()}
`; } - protected override render() { - const style = styleMap({ - '--background': this._color.asString('rgb', true), - }); + protected override render(): TemplateResult { + const color = this._color.asString('rgb', true); + const parts = partMap({ anchor: true, empty: this._color.isEmpty }); + const isDefaultMode = this.mode === 'default'; return html`
- - ${this.label} - - + ${isDefaultMode + ? html`` + : nothing} - - Open color picker - - ${this._renderPicker()} + ${this._renderAnchor(color, parts)}${this._renderPicker()}
`; diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index 207041eb5..21fd1647a 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -139,6 +139,7 @@ background-color: var(--current-color); } + [part~='empty'], [part~='empty']::part(base) { background-image: repeating-linear-gradient( diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index 3e2e78b48..ffb4db8b4 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -56,6 +56,13 @@ const metadata: Meta = { control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, + mode: { + type: '"default" | "input"', + description: 'The mode of the color picker.', + options: ['default', 'input'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'default' } }, + }, name: { type: 'string', description: 'The name attribute of the control.', @@ -84,6 +91,7 @@ const metadata: Meta = { format: 'hex', hideFormats: false, showAlpha: false, + mode: 'default', disabled: false, invalid: false, open: false, @@ -103,6 +111,8 @@ interface IgcColorPickerArgs { hideFormats: boolean; /** Whether to show the alpha slider and input. */ showAlpha: boolean; + /** The mode of the color picker. */ + mode: 'default' | 'input'; /** The name attribute of the control. */ name: string; /** The disabled state of the component. */ From 0193feaff3d8cf19b66b982942df3b61f30256dc Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 29 Jul 2026 09:40:27 +0300 Subject: [PATCH 12/17] fix: Addressed PR review comments --- src/components/color-picker/color-picker.ts | 12 ++++++++---- src/components/color-picker/converters.ts | 6 ++++-- stories/color-picker.stories.ts | 5 ++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index c8d17c069..d2b1a5ff8 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -290,6 +290,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } private _handleEyeDropperClick(): void { + if (!this._supportsEyeDropper) return; + const eyeDropper = new (globalThis as any).EyeDropper(); eyeDropper @@ -303,7 +305,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } private _handleCopy(): void { - navigator.clipboard.writeText(this.value); + navigator.clipboard.writeText(this.value).catch(() => {}); } private _handleSwatchClick(event: Event): void { @@ -630,9 +632,11 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( return html`
- ${isDefaultMode - ? html`` - : nothing} + ${ + isDefaultMode && this.label + ? html`` + : nothing + } ${this._renderAnchor(color, parts)}${this._renderPicker()} diff --git a/src/components/color-picker/converters.ts b/src/components/color-picker/converters.ts index 4bd77878e..57716d482 100644 --- a/src/components/color-picker/converters.ts +++ b/src/components/color-picker/converters.ts @@ -8,7 +8,9 @@ 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 [r, g, b] = rgb.map((v) => + Math.min(255, Math.max(0, Math.round(v))) + ); const value = (r << 16) + (g << 8) + b; return value.toString(16).padStart(6, '0'); }, @@ -94,7 +96,7 @@ export const converter = Object.freeze({ let t3: number; let val: number; - const t2 = l < 0.5 ? l * (1 + s) : 1 + s - 1 * s; + const t2 = l < 0.5 ? l * (1 + s) : l + s - l * s; const t1 = 2 * l - t2; const rgb: RGB = [0, 0, 0]; diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index ffb4db8b4..c57ff1412 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -1,7 +1,10 @@ import type { Meta, StoryObj } from '@storybook/web-components'; import { html } from 'lit'; -import { IgcColorPickerComponent, defineComponents } from '../src/index.js'; +import { + IgcColorPickerComponent, + defineComponents, +} from 'igniteui-webcomponents'; import { disableStoryControls, formControls, From 7eb34240a752595fb9b07695cb70bb4291cf8de8 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 29 Jul 2026 12:17:14 +0300 Subject: [PATCH 13/17] fix: More bug fixes and improvements to the color picker component --- src/components/color-picker/color-picker.ts | 19 +++++----- src/components/color-picker/common.spec.ts | 20 +++++------ src/components/color-picker/common.ts | 12 ++++++- src/components/color-picker/model.ts | 39 ++++++++++++++------- 4 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index d2b1a5ff8..7f0aff4c2 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -130,7 +130,6 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( @property() public set value(value: string) { this._color = ColorModel.parse(value); - this._formValue.setValueAndFormState(this._color.asString(this.format)); this._updateColor(); } @@ -234,9 +233,9 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( ): void { stopPropagation(event); - this._color.s = event.detail.x; - this._color.v = 100 - event.detail.y; + this._color.setSaturationAndValue(event.detail.x, 100 - event.detail.y); this._updateColor(); + this._emitColorPickedEvent(); } private _handleHueValueChange(event: Event): void { @@ -326,7 +325,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; this.style.setProperty('--current-color', this._ownCurrentColor); this._formValue.setValueAndFormState(this._color.asString(this.format)); - this.requestUpdate('_ownCurrentColor'); + this.requestUpdate(); } private _syncCanvasPosition(): void { @@ -336,8 +335,9 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( const { width: markerWidth, height: markerHeight } = this._canvasRef.value.getMarkerDimensions(); - const x = (this._color.s / 100) * rect.width - markerWidth; - const y = ((100 - this._color.v) / 100) * rect.height - markerHeight; + const [, s, v] = this._color.toHSV(); + const x = (s / 100) * rect.width - markerWidth; + const y = ((100 - v) / 100) * rect.height - markerHeight; this._canvasRef.value.x = x; this._canvasRef.value.y = y; @@ -600,10 +600,9 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _renderAnchor( color: string, - parts: ReturnType + parts: ReturnType, + isDefaultMode: boolean ): TemplateResult { - const isDefaultMode = this.mode === 'default'; - return isDefaultMode ? this._renderButtonAnchor(color, parts) : this._renderInputAnchor(color, parts); @@ -638,7 +637,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( : nothing } - ${this._renderAnchor(color, parts)}${this._renderPicker()} + ${this._renderAnchor(color, parts, isDefaultMode)}${this._renderPicker()}
`; diff --git a/src/components/color-picker/common.spec.ts b/src/components/color-picker/common.spec.ts index 053a7bc60..5a524ced9 100644 --- a/src/components/color-picker/common.spec.ts +++ b/src/components/color-picker/common.spec.ts @@ -61,8 +61,7 @@ describe('parseColor', () => { 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]); + expect(result.alpha).to.equal(1); }); }); @@ -144,22 +143,21 @@ describe('parseColor', () => { 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 + // Invalid colors are rejected before parsing, always returning the + // deterministic default result. 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; + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); }); it('should handle malformed hex colors gracefully', () => { - // Malformed hex colors behave like invalid colors + // Malformed hex colors are rejected before parsing, always returning + // the deterministic default result. 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; + expect(result.value).to.deep.equal([0, 0, 0]); + expect(result.alpha).to.equal(1); }); it('should return correct type', () => { diff --git a/src/components/color-picker/common.ts b/src/components/color-picker/common.ts index 420d19332..16ec2bcfe 100644 --- a/src/components/color-picker/common.ts +++ b/src/components/color-picker/common.ts @@ -4,6 +4,7 @@ import type { RGB } from './converters.js'; export const RGBA_RE = /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i; export const HEX_RE = /.{2}/g; +const HEX_WITHOUT_HASH_RE = /^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i; export interface ParsedColor { value: RGB; @@ -31,8 +32,17 @@ export function parseColor( return result; } + const trimmed = colorString.trim(); + const normalized = HEX_WITHOUT_HASH_RE.test(trimmed) + ? `#${trimmed}` + : trimmed; + + if (!isValidColor(normalized, ctx)) { + return result; + } + // Trigger parsing through canvas context - ctx.fillStyle = colorString; + ctx.fillStyle = normalized; const color = ctx.fillStyle; const rgbaMatch = RGBA_RE.exec(color); diff --git a/src/components/color-picker/model.ts b/src/components/color-picker/model.ts index c641598b0..652091292 100644 --- a/src/components/color-picker/model.ts +++ b/src/components/color-picker/model.ts @@ -1,19 +1,9 @@ import { clamp } from '../common/util.js'; -import { parseColor } from './common.js'; +import { isValidColor, 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; @@ -78,15 +68,20 @@ export class ColorModel { * Parses a color string and creates a ColorModel instance. * Supports hex, rgb, rgba, hsl, hsla, and named color formats. * + * Empty, whitespace-only, or otherwise invalid strings produce an empty + * ColorModel instead of a stale/incorrect color. + * * @param color - The color string to parse * @returns A new ColorModel instance */ public static parse(color: string): ColorModel { - if (!color?.trim()) { + const ctx = getContext(); + + if (!isValidColor(color, ctx)) { return ColorModel.empty(); } - const parsed = parseColor(color, getContext()); + const parsed = parseColor(color, ctx); return new ColorModel(parsed.value, parsed.alpha); } @@ -241,6 +236,24 @@ export class ColorModel { this._alpha = clamp(value, 0, 1); } + /** + * Sets the HSV saturation and value in a single atomic update, preserving + * the current hue and alpha. Intended for the 2D saturation/value picker + * area, where both components change together and setting them through the + * individual `s` (HSL) and `v` (HSV) setters would be both incorrect + * (mixing color spaces) and order-dependent. + * + * @param saturation - HSV saturation (0-100) + * @param value - HSV value (0-100) + */ + public setSaturationAndValue(saturation: number, value: number): void { + this._empty = false; + this._hsv[1] = clamp(saturation, 0, 100); + this._hsv[2] = clamp(value, 0, 100); + this._rgb = converter.hsv.rgb(this._hsv); + this._hsl = converter.hsv.hsl(this._hsv); + } + /** * Converts the color to a CSS color string. * From 00f0751992162a57d7b85fdf5f9b53e36062f548 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 29 Jul 2026 12:33:17 +0300 Subject: [PATCH 14/17] feat: Added additional keybinding for opening and closing the picker --- src/components/color-picker/color-picker.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 7f0aff4c2..6b8cb819a 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -8,6 +8,9 @@ import IgcButtonComponent from '../button/button.js'; import IgcIconButtonComponent from '../button/icon-button.js'; import { addKeybindings, + altKey, + arrowDown, + arrowUp, escapeKey, } from '../common/controllers/key-bindings.js'; import { addRootClickController } from '../common/controllers/root-click.js'; @@ -186,10 +189,10 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( constructor() { super(); - addKeybindings(this, { skip: () => this.disabled }).set( - escapeKey, - this._onEscapeKey - ); + addKeybindings(this, { skip: () => this.disabled }) + .set(escapeKey, this._handleKeyboardClosing) + .set([altKey, arrowDown], this._handleAnchorClick) + .set([altKey, arrowUp], this._handleKeyboardClosing); } protected override update(props: PropertyValues): void { @@ -222,7 +225,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._hide(true); } - private async _onEscapeKey(): Promise { + private async _handleKeyboardClosing(): Promise { if (await this._hide(true)) { this._anchorRef.value?.focus(); } @@ -241,7 +244,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _handleHueValueChange(event: Event): void { stopPropagation(event); - this._color.h = this._hueRef.value?.valueAsNumber ?? 0; + this._color.h = asNumber(this._hueRef.value?.value); this._updateColor(); this._emitColorPickedEvent(); } @@ -249,7 +252,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _handleAlphaSliderValueChange(event: Event): void { stopPropagation(event); - this._color.alpha = (this._alphaRef.value?.valueAsNumber ?? 0) / 100; + this._color.alpha = asNumber(this._alphaRef.value?.value) / 100; this._updateColor(); this._emitColorPickedEvent(); } From bcd073a54e5cc94cc6da2fd4ac8e73f8790ae069 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 30 Jul 2026 19:13:32 +0300 Subject: [PATCH 15/17] feat: Finalize color-picker component - Added additional keybindings for the color picker component. - Added required attribute to the color picker component. - Finalized event handling for the color picker component. - Added validation logic. - Added unit tests for the color picker component. --- .../color-picker/color-picker.spec.ts | 548 +++++++++++++++++- src/components/color-picker/color-picker.ts | 134 ++++- .../color-picker/picker-canvas.spec.ts | 180 ++++++ src/components/color-picker/picker-canvas.ts | 5 + .../themes/color-picker.base.scss | 4 + src/components/color-picker/validators.ts | 6 + stories/color-picker.stories.ts | 172 +++++- 7 files changed, 1006 insertions(+), 43 deletions(-) create mode 100644 src/components/color-picker/picker-canvas.spec.ts create mode 100644 src/components/color-picker/validators.ts diff --git a/src/components/color-picker/color-picker.spec.ts b/src/components/color-picker/color-picker.spec.ts index 21c272189..6d2b405ca 100644 --- a/src/components/color-picker/color-picker.spec.ts +++ b/src/components/color-picker/color-picker.spec.ts @@ -1,9 +1,28 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; - +import { spy, stub } from 'sinon'; +import { + altKey, + arrowDown, + arrowUp, + escapeKey, +} from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; -import { createFormAssociatedTestBed } from '../common/utils.spec.js'; +import { + createFormAssociatedTestBed, + isFocused, + simulateClick, + simulateKeyboard, +} from '../common/utils.spec.js'; +import { + runValidationContainerTests, + type ValidationContainerTestsParams, + ValidityHelpers, +} from '../common/validity-helpers.spec.js'; import type IgcInputComponent from '../input/input.js'; +import type IgcSelectComponent from '../select/select.js'; import IgcColorPickerComponent from './color-picker.js'; +import { ColorModel } from './model.js'; +import type IgcPickerCanvasComponent from './picker-canvas.js'; async function createDefaultColorPicker() { return await fixture( @@ -34,6 +53,26 @@ function commitColorInput(input: IgcInputComponent, value: string): void { ); } +function getHueSlider(picker: IgcColorPickerComponent): HTMLInputElement { + return picker.renderRoot.querySelector('[part="hue"]')!; +} + +function getAlphaSlider(picker: IgcColorPickerComponent): HTMLInputElement { + return picker.renderRoot.querySelector('[part="alpha"]')!; +} + +function getAlphaInput(picker: IgcColorPickerComponent): IgcInputComponent { + return picker.renderRoot.querySelector('#alpha')!; +} + +function getCanvas(picker: IgcColorPickerComponent): IgcPickerCanvasComponent { + return picker.renderRoot.querySelector('igc-picker-canvas')!; +} + +function getFormatSelect(picker: IgcColorPickerComponent): IgcSelectComponent { + return picker.renderRoot.querySelector('#format-select')!; +} + describe('Color picker', () => { before(() => defineComponents(IgcColorPickerComponent)); @@ -131,13 +170,393 @@ describe('Color picker', () => { expect(input.value).to.equal('#ff0000'); }); - it('reverts the input on an empty color', async () => { + it('clears the value on an empty input', async () => { const input = getColorInput(picker); commitColorInput(input, ''); await elementUpdated(picker); - expect(picker.value).to.equal('#ff0000'); - expect(input.value).to.equal('#ff0000'); + expect(picker.value).to.equal(''); + expect(isAnchorEmpty(picker)).to.be.true; + }); + }); + + describe('igcChange', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + await elementUpdated(picker); + }); + + it('emits when the value changed while focus was inside the component', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.value = '#00ff00'; + await elementUpdated(picker); + + picker.dispatchEvent(new FocusEvent('focusout', { relatedTarget: null })); + + expect(eventSpy).calledWith('igcChange', { detail: '#00ff00' }); + }); + + it('does not emit when the value did not change', async () => { + const eventSpy = spy(picker, 'emitEvent'); + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.dispatchEvent(new FocusEvent('focusout', { relatedTarget: null })); + + expect(eventSpy).not.calledWith('igcChange'); + }); + + it('does not emit when focus moves within the component', async () => { + const eventSpy = spy(picker, 'emitEvent'); + const anchor = getAnchor(picker); + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.value = '#00ff00'; + await elementUpdated(picker); + + picker.dispatchEvent( + new FocusEvent('focusout', { relatedTarget: anchor }) + ); + + expect(eventSpy).not.calledWith('igcChange'); + }); + }); + + describe('Color channels', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + picker.open = true; + await elementUpdated(picker); + }); + + it('updates the hue via the hue slider', async () => { + const inputSpy = spy(picker, 'emitEvent'); + const hue = getHueSlider(picker); + + hue.value = '120'; + hue.dispatchEvent(new Event('input', { bubbles: true })); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + expect(inputSpy).calledWith('igcInput', { detail: '#00ff00' }); + }); + + it('updates the alpha via the alpha slider', async () => { + picker.showAlpha = true; + picker.format = 'rgb'; + await elementUpdated(picker); + + const alpha = getAlphaSlider(picker); + alpha.value = '50'; + alpha.dispatchEvent(new Event('input', { bubbles: true })); + await elementUpdated(picker); + + expect(picker.value).to.equal('rgb(255 0 0 / 0.5)'); + }); + + it('updates the alpha via the alpha number input', async () => { + picker.showAlpha = true; + picker.format = 'rgb'; + await elementUpdated(picker); + + const alphaInput = getAlphaInput(picker); + alphaInput.dispatchEvent( + new CustomEvent('igcChange', { + detail: '0.25', + bubbles: true, + composed: true, + }) + ); + await elementUpdated(picker); + + expect(picker.value).to.equal('rgb(255 0 0 / 0.25)'); + }); + + it('picks a color from the canvas using HSV saturation/value', async () => { + const canvas = getCanvas(picker); + + canvas.dispatchEvent( + new CustomEvent('igcColorPicked', { + detail: { x: 50, y: 25 }, + }) + ); + await elementUpdated(picker); + + const expected = ColorModel.parse('#ff0000'); + expected.setSaturationAndValue(50, 75); + + expect(picker.value).to.equal(expected.asString('hex')); + // Hue is preserved by the HSV saturation/value update. + expect(expected.h).to.equal(ColorModel.parse('#ff0000').h); + }); + + it('updates the format via the format select', async () => { + const select = getFormatSelect(picker); + + select.dispatchEvent( + new CustomEvent('igcChange', { + detail: { value: 'rgb' } as unknown as IgcSelectComponent, + bubbles: true, + composed: true, + }) + ); + await elementUpdated(picker); + + expect(picker.format).to.equal('rgb'); + expect(getColorInput(picker).value).to.equal('rgb(255 0 0)'); + }); + }); + + describe('Swatches', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.open = true; + await elementUpdated(picker); + }); + + it('does not render swatches by default', () => { + expect(picker.renderRoot.querySelector('[part="swatches"]')).to.be.null; + }); + + it('renders and selects a swatch', async () => { + picker.swatches = ['#ff0000', '#00ff00']; + await elementUpdated(picker); + + const buttons = picker.renderRoot.querySelectorAll( + 'button[part="swatch"]' + ); + expect(buttons.length).to.equal(2); + expect(buttons[0].ariaLabel).to.equal('#ff0000'); + + const inputSpy = spy(picker, 'emitEvent'); + buttons[1].click(); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + expect(inputSpy).calledWith('igcInput', { detail: '#00ff00' }); + }); + }); + + describe('Copy and EyeDropper', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + picker.value = '#ff0000'; + picker.open = true; + await elementUpdated(picker); + }); + + it('copies the value to the clipboard', async () => { + const writeText = stub(navigator.clipboard, 'writeText').resolves(); + + picker.renderRoot.querySelector('[part="copy"]')!.click(); + + expect(writeText).calledWith('#ff0000'); + writeText.restore(); + }); + + describe('unsupported', () => { + let originalEyeDropper: unknown; + + beforeEach(() => { + originalEyeDropper = (globalThis as any).EyeDropper; + delete (globalThis as any).EyeDropper; + }); + + afterEach(() => { + (globalThis as any).EyeDropper = originalEyeDropper; + }); + + it('disables the eye dropper button', async () => { + picker = await createDefaultColorPicker(); + + const button = picker.renderRoot.querySelector('[part="eye-dropper"]')!; + expect(button.hasAttribute('disabled')).to.be.true; + }); + }); + + describe('supported', () => { + let originalEyeDropper: unknown; + + beforeEach(() => { + originalEyeDropper = (globalThis as any).EyeDropper; + (globalThis as any).EyeDropper = class { + public open() { + return Promise.resolve({ sRGBHex: '#112233' }); + } + }; + }); + + afterEach(() => { + (globalThis as any).EyeDropper = originalEyeDropper; + }); + + it('picks a color via the EyeDropper API', async () => { + picker = await createDefaultColorPicker(); + + const button = picker.renderRoot.querySelector( + '[part="eye-dropper"]' + )!; + expect(button.hasAttribute('disabled')).to.be.false; + + button.click(); + // Let the mocked EyeDropper's promise resolve. + await new Promise((resolve) => setTimeout(resolve)); + await elementUpdated(picker); + + expect(picker.value).to.equal('#112233'); + }); + }); + }); + + describe('Open and close', () => { + beforeEach(async () => { + picker = await createDefaultColorPicker(); + }); + + it('opens and closes via the anchor click', async () => { + const eventSpy = spy(picker, 'emitEvent'); + const anchor = getAnchor(picker); + + simulateClick(anchor); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + expect(eventSpy).calledWith('igcOpening'); + expect(eventSpy).calledWith('igcOpened'); + + eventSpy.resetHistory(); + simulateClick(anchor); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(eventSpy).calledWith('igcClosing'); + expect(eventSpy).calledWith('igcClosed'); + }); + + it('closes and refocuses the anchor on Escape', async () => { + const anchor = getAnchor(picker); + picker.open = true; + await elementUpdated(picker); + + simulateKeyboard(picker, escapeKey); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + expect(isFocused(anchor)).to.be.true; + }); + + it('opens with Alt+ArrowDown and closes with Alt+ArrowUp', async () => { + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); + expect(picker.open).to.be.true; + + simulateKeyboard(picker, [altKey, arrowUp]); + await elementUpdated(picker); + expect(picker.open).to.be.false; + }); + + it('skips keybindings when disabled', async () => { + picker.disabled = true; + await elementUpdated(picker); + + simulateKeyboard(picker, [altKey, arrowDown]); + await elementUpdated(picker); + + expect(picker.open).to.be.false; + }); + }); + + describe('Input mode', () => { + function getInputAnchor(): IgcInputComponent { + return picker.renderRoot.querySelector( + 'igc-input[slot="anchor"]' + )!; + } + + beforeEach(async () => { + picker = await fixture( + html`` + ); + }); + + it('renders an input anchor instead of a button', () => { + expect(picker.renderRoot.querySelector('igc-button')).to.be.null; + expect(getInputAnchor()).to.exist; + }); + + it('opens the popover when the prefix swatch is clicked', async () => { + simulateClick(getAnchor(picker)); + await elementUpdated(picker); + + expect(picker.open).to.be.true; + }); + + it('commits a color via the anchor input', async () => { + commitColorInput(getInputAnchor(), '#00ff00'); + await elementUpdated(picker); + + expect(picker.value).to.equal('#00ff00'); + }); + + it('forwards required/invalid state to the anchor input', async () => { + picker.required = true; + await elementUpdated(picker); + expect(getInputAnchor().required).to.be.true; + + picker.dispatchEvent(new FocusEvent('focusin', { relatedTarget: null })); + picker.dispatchEvent(new FocusEvent('focusout', { relatedTarget: null })); + picker.reportValidity(); + await elementUpdated(picker); + + expect(getInputAnchor().invalid).to.equal(picker.invalid); + }); + }); + + describe('Rendering', () => { + it('renders the label only in default mode with a label set', async () => { + picker = await createDefaultColorPicker(); + expect( + picker.renderRoot.querySelector('[part="label"]')?.textContent + ).to.equal('Choose a color'); + + picker.label = undefined; + await elementUpdated(picker); + expect(picker.renderRoot.querySelector('[part="label"]')).to.be.null; + }); + + it('does not render a label element in input mode', async () => { + picker = await fixture( + html`` + ); + expect(picker.renderRoot.querySelector('[part="label"]')).to.be.null; + }); + + it('reflects disabled onto the button anchor', async () => { + picker = await createDefaultColorPicker(); + picker.disabled = true; + await elementUpdated(picker); + + expect(getAnchor(picker).hasAttribute('disabled')).to.be.true; + }); + + it('hides the format select when hideFormats is set', async () => { + picker = await createDefaultColorPicker(); + picker.open = true; + await elementUpdated(picker); + expect(picker.renderRoot.querySelector('#format-select')).to.exist; + + picker.hideFormats = true; + await elementUpdated(picker); + expect(picker.renderRoot.querySelector('#format-select')).to.be.null; }); }); @@ -178,6 +597,14 @@ describe('Color picker', () => { expect(spec.element.disabled).to.be.false; }); + it('fulfils required constraint', () => { + spec.setProperties({ required: true }); + spec.assertSubmitFails(); + + spec.setProperties({ value: '#bada55' }); + spec.assertSubmitPasses(); + }); + it('fulfils custom constraint', () => { spec.element.setCustomValidity('invalid'); spec.assertSubmitFails(); @@ -186,4 +613,115 @@ describe('Color picker', () => { spec.assertSubmitPasses(); }); }); + + describe('Touched state', () => { + const spec = createFormAssociatedTestBed( + html`` + ); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('marks the control as touched on blur', () => { + // biome-ignore lint/complexity/useLiteralKeys: internal state check + expect((spec.element as any)['_touched']).to.be.false; + + spec.element.dispatchEvent( + new FocusEvent('focusin', { relatedTarget: null }) + ); + spec.element.dispatchEvent( + new FocusEvent('focusout', { relatedTarget: null }) + ); + + // biome-ignore lint/complexity/useLiteralKeys: internal state check + expect((spec.element as any)['_touched']).to.be.true; + }); + + it('clears invalid styles after a form reset', async () => { + spec.setProperties({ required: true }); + await elementUpdated(spec.element); + + spec.element.dispatchEvent( + new FocusEvent('focusin', { relatedTarget: null }) + ); + spec.element.dispatchEvent( + new FocusEvent('focusout', { relatedTarget: null }) + ); + await elementUpdated(spec.element); + + spec.assertSubmitFails(); + await elementUpdated(spec.element); + ValidityHelpers.hasInvalidStyles(spec.element).to.be.true; + + spec.reset(); + await elementUpdated(spec.element); + ValidityHelpers.hasInvalidStyles(spec.element).to.be.false; + }); + }); + + describe('defaultValue', () => { + const defaultValue = '#bada55'; + const spec = createFormAssociatedTestBed(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('correct initial state', () => { + spec.assertIsPristine(); + expect(spec.element.value).to.equal(defaultValue); + }); + + it('is correctly submitted', () => { + spec.assertSubmitHasValue(defaultValue); + }); + + it('is correctly reset on form reset', () => { + spec.setProperties({ value: '#ff0000' }); + + spec.reset(); + expect(spec.element.value).to.equal(defaultValue); + }); + }); + + describe('Validation', () => { + const spec = createFormAssociatedTestBed(html` + + `); + + beforeEach(async () => { + await spec.setup(IgcColorPickerComponent.tagName); + }); + + it('fails required validation', () => { + spec.setProperties({ required: true }); + spec.assertIsPristine(); + spec.assertSubmitFails(); + }); + + it('passes required validation when updating defaultValue', () => { + spec.setProperties({ required: true, defaultValue: '#bada55' }); + spec.assertIsPristine(); + spec.assertSubmitPasses(); + }); + }); + + describe('Validation message slots', () => { + it('renders validation message slots', () => { + const testParameters: ValidationContainerTestsParams[] = + [ + { slots: ['valueMissing'], props: { required: true } }, + { slots: ['customError'] }, + { slots: ['invalid'], props: { required: true } }, + ]; + + runValidationContainerTests(IgcColorPickerComponent, testParameters); + }); + }); }); diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 6b8cb819a..911c3fbf2 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -14,14 +14,16 @@ import { escapeKey, } from '../common/controllers/key-bindings.js'; import { addRootClickController } from '../common/controllers/root-click.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import { IgcBaseComboBoxComponent } from '../common/mixins/combo-box.js'; import type { AbstractConstructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { FormAssociatedMixin } from '../common/mixins/forms/associated.js'; +import { FormAssociatedRequiredMixin } from '../common/mixins/forms/associated-required.js'; import { createFormValueState } from '../common/mixins/forms/form-value.js'; import { partMap } from '../common/part-map.js'; import { + addSafeEventListener, asNumber, bindIf, getElementFromPath, @@ -34,6 +36,7 @@ import IgcInputComponent from '../input/input.js'; import IgcPopoverComponent from '../popover/popover.js'; import IgcSelectComponent from '../select/select.js'; import type IgcSelectItemComponent from '../select/select-item.js'; +import IgcValidationContainerComponent from '../validation-container/validation-container.js'; import IgcVisuallyHiddenComponent from '../visually-hidden/visually-hidden.js'; import { isValidColor } from './common.js'; import { ColorModel, getContext } from './model.js'; @@ -41,6 +44,7 @@ import IgcPickerCanvasComponent, { type PickerCanvasEventDetail, } from './picker-canvas.js'; import { styles } from './themes/color-picker.base.css.js'; +import { colorPickerValidators } from './validators.js'; export interface IgcColorPickerEventMap { igcOpening: CustomEvent; @@ -49,21 +53,56 @@ export interface IgcColorPickerEventMap { igcClosed: CustomEvent; igcInput: CustomEvent; igcChange: CustomEvent; - igcColorPicked: CustomEvent; } +const Slots = setSlots( + 'value-missing', + 'custom-error', + 'invalid', + 'helper-text' +); + /** * Color input component. * + * Lets the user pick a color visually - via an HSV saturation/value canvas, a + * hue slider and an optional alpha slider - or by typing a color string + * (hex, rgb(a), hsl(a) or a named CSS color) directly. Supports pre-defined + * swatches, the native EyeDropper API where available, and two anchor + * presentations: a trigger button (`mode="default"`) or an editable text + * field (`mode="input"`). + * * @element igc-color-picker * + * @slot value-missing - Renders content when the required validation fails. + * @slot custom-error - Renders content when setCustomValidity(message) is set. + * @slot invalid - Renders content when the component is in invalid state (validity.valid = false). + * @slot helper-text - Renders content below the picker. + * * @fires igcOpening - Emitted just before the picker dropdown is open. * @fires igcOpened - Emitted after the picker dropdown is open. * @fires igcClosing - 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. + * @fires igcInput - Emitted when the value of the component is changed. + * @fires igcChange - Emitted when the value of the component is committed. + * + * @csspart anchor - The trigger element that opens the picker (the button in default mode, or the swatch prefix in input mode). + * @csspart empty - Applied alongside `anchor` when no color value is set, rendering a checkered background. + * @csspart label - The label rendered above the anchor in default mode. + * @csspart picker - The popover container holding the canvas, sliders, inputs and swatches. + * @csspart main-row - The row containing the hue slider and the copy/eyedropper buttons. + * @csspart alpha-row - The row containing the alpha slider and input, rendered when `show-alpha` is set. + * @csspart inputs-row - The row containing the format select and the color value input. + * @csspart buttons - The wrapper around the copy and eyedropper buttons. + * @csspart hue - The hue slider. + * @csspart alpha - The alpha slider. + * @csspart copy - The button that copies the current color value to the clipboard. + * @csspart eye-dropper - The button that activates the EyeDropper API. + * @csspart format-select - The select control used to switch the color string format. + * @csspart swatches - The container of the pre-defined color swatches. + * @csspart swatch - An individual color swatch button. */ -export default class IgcColorPickerComponent extends FormAssociatedMixin( +export default class IgcColorPickerComponent extends FormAssociatedRequiredMixin( EventEmitterMixin< IgcColorPickerEventMap, AbstractConstructor @@ -84,12 +123,19 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( IgcDividerComponent, IgcButtonComponent, IgcIconButtonComponent, + IgcValidationContainerComponent, IgcVisuallyHiddenComponent ); } //#region Internal state and properties + protected override get __validators() { + return colorPickerValidators; + } + + protected readonly _slots = addSlotController(this, { slots: Slots }); + protected override readonly _rootClickController = addRootClickController( this, { @@ -110,6 +156,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( private _supportsEyeDropper = 'EyeDropper' in globalThis; private _color = ColorModel.empty(); + private _oldValue = ''; @state() private _ownCurrentColor = ''; @@ -120,13 +167,20 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( /** * The label of the component. + * + * In `mode="input"` this is forwarded to the anchor input's own label + * instead of being rendered as a separate element. * @attr label */ @property() public label?: string; /** - * The value of the component. + * The value of the component, as a CSS color string (hex, rgb(a), hsl(a) + * or a named color). + * + * Setting an empty, whitespace-only or otherwise invalid string clears + * the value. * * @attr value */ @@ -170,6 +224,10 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( /** * The mode of the color picker. * + * In `"default"` mode the anchor is a trigger button. In `"input"` mode + * the anchor is an editable text field with a color swatch prefix that + * also opens the picker. + * * @attr mode * @default 'default' */ @@ -177,7 +235,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( public mode: 'default' | 'input' = 'default'; /** - * Pre-defined color swatches. + * Pre-defined color strings rendered as clickable swatches below the + * picker controls. Clicking a swatch commits its color as the value. */ @property({ attribute: false }) public swatches: string[] = []; @@ -189,6 +248,9 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( constructor() { super(); + addSafeEventListener(this, 'focusin', this._handleFocusIn); + addSafeEventListener(this, 'focusout', this._handleFocusOut); + addKeybindings(this, { skip: () => this.disabled }) .set(escapeKey, this._handleKeyboardClosing) .set([altKey, arrowDown], this._handleAnchorClick) @@ -231,14 +293,31 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( } } + private _handleFocusIn({ relatedTarget }: FocusEvent): void { + if (!this.contains(relatedTarget as Node)) { + this._oldValue = this.value; + } + } + + private _handleFocusOut({ relatedTarget }: FocusEvent): void { + if (this.contains(relatedTarget as Node)) { + return; + } + + this._handleBlur(); + + if (this.value !== this._oldValue) { + this._oldValue = this.value; + this.emitEvent('igcChange', { detail: this.value }); + } + } + private _handleCanvasColorPicked( event: CustomEvent ): void { - stopPropagation(event); - this._color.setSaturationAndValue(event.detail.x, 100 - event.detail.y); this._updateColor(); - this._emitColorPickedEvent(); + this._emitInputEvent(); } private _handleHueValueChange(event: Event): void { @@ -246,7 +325,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._color.h = asNumber(this._hueRef.value?.value); this._updateColor(); - this._emitColorPickedEvent(); + this._emitInputEvent(); } private _handleAlphaSliderValueChange(event: Event): void { @@ -254,7 +333,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._color.alpha = asNumber(this._alphaRef.value?.value) / 100; this._updateColor(); - this._emitColorPickedEvent(); + this._emitInputEvent(); } private _handleAlphaInputChange(event: CustomEvent): void { @@ -262,7 +341,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._color.alpha = asNumber(event.detail) ?? 0; this._updateColor(); - this._emitColorPickedEvent(); + this._emitInputEvent(); } private _handleFormatChange( @@ -278,15 +357,17 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( stopPropagation(event); const input = event.target as IgcInputComponent; + const value = event.detail; + const cleared = !value?.trim(); - // Commit only valid colors. An empty or invalid value reverts the input - // back to the currently represented color. - if (!isValidColor(event.detail, getContext())) { + // A non-empty but invalid value reverts the input back to the currently + // represented color. An empty value clears it. + if (!cleared && !isValidColor(value, getContext())) { input.value = this._color.asString(this.format); return; } - this._color = ColorModel.parse(event.detail); + this._color = cleared ? ColorModel.empty() : ColorModel.parse(value); this._updateColor(); this._syncCanvasPosition(); } @@ -301,7 +382,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( .then((result: { sRGBHex: string }) => { this.value = result.sRGBHex; this._syncCanvasPosition(); - this._emitColorPickedEvent(); + this._emitInputEvent(); }) .catch(() => {}); } @@ -316,7 +397,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( if (color) { this.value = color; this._syncCanvasPosition(); - this._emitColorPickedEvent(); + this._emitInputEvent(); } } @@ -328,6 +409,7 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._ownCurrentColor = `hsl(${this._color.h} 100% 50%)`; this.style.setProperty('--current-color', this._ownCurrentColor); this._formValue.setValueAndFormState(this._color.asString(this.format)); + this._validate(); this.requestUpdate(); } @@ -346,8 +428,8 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( this._canvasRef.value.y = y; } - private _emitColorPickedEvent(): void { - this.emitEvent('igcColorPicked', { detail: this.value }); + private _emitInputEvent(): void { + this.emitEvent('igcInput', { detail: this.value }); } //#endregion @@ -588,7 +670,9 @@ export default class IgcColorPickerComponent extends FormAssociatedMixin( aria-haspopup="dialog" slot="anchor" label=${ifDefined(this.label)} + ?required=${this.required} .value=${this.value} + .invalid=${this.invalid} @igcChange=${this._handleColorInputChange} >
- ${this._renderAnchor(color, parts, isDefaultMode)}${this._renderPicker()} + ${this._renderAnchor(color, parts, isDefaultMode)}${this._renderHelperText()}${this._renderPicker()}
`; diff --git a/src/components/color-picker/picker-canvas.spec.ts b/src/components/color-picker/picker-canvas.spec.ts new file mode 100644 index 000000000..d1427ddb0 --- /dev/null +++ b/src/components/color-picker/picker-canvas.spec.ts @@ -0,0 +1,180 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; + +import { + arrowDown, + arrowLeft, + arrowRight, + arrowUp, +} from '../common/controllers/key-bindings.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { asPercent } from '../common/util.js'; +import { + simulateKeyboard, + simulateLostPointerCapture, + simulatePointerDown, + simulatePointerMove, +} from '../common/utils.spec.js'; +import IgcPickerCanvasComponent from './picker-canvas.js'; + +async function createCanvas() { + return await fixture( + html`` + ); +} + +function getMarker(canvas: IgcPickerCanvasComponent): HTMLDivElement { + return canvas.renderRoot.querySelector('[part="marker"]')!; +} + +describe('Picker canvas', () => { + before(() => defineComponents(IgcPickerCanvasComponent)); + + let canvas: IgcPickerCanvasComponent; + + beforeEach(async () => { + canvas = await createCanvas(); + }); + + describe('Rendering', () => { + it('renders a focusable marker', () => { + const marker = getMarker(canvas); + expect(marker).to.exist; + expect(marker.getAttribute('tabindex')).to.equal('0'); + }); + + it('`getMarkerDimensions()` returns half the marker size', () => { + const rect = getMarker(canvas).getBoundingClientRect(); + const dimensions = canvas.getMarkerDimensions(); + + expect(dimensions.width).to.equal(rect.width / 2); + expect(dimensions.height).to.equal(rect.height / 2); + }); + + it('reflects `x`/`y` into the marker position', async () => { + canvas.x = 40; + canvas.y = 20; + await elementUpdated(canvas); + + const marker = getMarker(canvas); + expect(marker.style.left).to.equal('40px'); + expect(marker.style.top).to.equal('20px'); + }); + + it('sets the host color from `currentColor`', async () => { + canvas.currentColor = 'red'; + await elementUpdated(canvas); + + expect(canvas.style.color).to.equal('red'); + }); + }); + + describe('Keyboard interaction', () => { + it('moves the marker with arrow keys and emits `igcColorPicked`', () => { + const eventSpy = spy(canvas, 'emitEvent'); + + simulateKeyboard(canvas, arrowRight); + expect(canvas.x).to.equal(1); + expect(eventSpy).calledOnce; + + simulateKeyboard(canvas, arrowDown); + expect(canvas.y).to.equal(1); + + simulateKeyboard(canvas, arrowLeft); + expect(canvas.x).to.equal(0); + + simulateKeyboard(canvas, arrowUp); + expect(canvas.y).to.equal(0); + + expect(eventSpy.callCount).to.equal(4); + }); + + it('emits rounded percentages relative to the canvas size', () => { + const eventSpy = spy(canvas, 'emitEvent'); + const { width, height } = canvas.getMarkerDimensions(); + const rect = canvas.getBoundingClientRect(); + + simulateKeyboard(canvas, arrowRight); + + expect(eventSpy).calledWith('igcColorPicked', { + detail: { + x: Math.round(asPercent(1 + width, rect.width)), + y: Math.round(asPercent(height, rect.height)), + }, + bubbles: false, + }); + }); + + it('clamps at the boundaries and stops emitting', async () => { + const { width, height } = canvas.getMarkerDimensions(); + canvas.x = -width; + canvas.y = -height; + await elementUpdated(canvas); + + const eventSpy = spy(canvas, 'emitEvent'); + simulateKeyboard(canvas, arrowLeft); + simulateKeyboard(canvas, arrowUp); + + expect(canvas.x).to.equal(-width); + expect(canvas.y).to.equal(-height); + expect(eventSpy).not.called; + }); + }); + + describe('Pointer interaction', () => { + it('drags the marker on pointer down/move', () => { + const rect = canvas.getBoundingClientRect(); + const { width, height } = canvas.getMarkerDimensions(); + const eventSpy = spy(canvas, 'emitEvent'); + + simulatePointerDown(canvas, { + clientX: rect.x + 50, + clientY: rect.y + 40, + }); + + expect(canvas.x).to.equal(50 - width); + expect(canvas.y).to.equal(40 - height); + expect(eventSpy).calledOnce; + + simulatePointerMove( + canvas, + { clientX: rect.x + 50, clientY: rect.y + 40 }, + { x: 10, y: 5 } + ); + + expect(canvas.x).to.equal(60 - width); + expect(canvas.y).to.equal(45 - height); + }); + + it('stops dragging and focuses the marker on lost pointer capture', () => { + const rect = canvas.getBoundingClientRect(); + + simulatePointerDown(canvas, { + clientX: rect.x + 50, + clientY: rect.y + 40, + }); + simulateLostPointerCapture(canvas); + + const { x, y } = canvas; + simulatePointerMove(canvas, { + clientX: rect.x + 100, + clientY: rect.y + 100, + }); + + expect(canvas.x).to.equal(x); + expect(canvas.y).to.equal(y); + expect(canvas.shadowRoot?.activeElement).to.equal(getMarker(canvas)); + }); + + it('does not bubble the `igcColorPicked` event', () => { + const parentSpy = spy(); + canvas.parentElement?.addEventListener('igcColorPicked', parentSpy); + + simulatePointerDown(canvas, { clientX: 10, clientY: 10 }); + + expect(parentSpy).not.called; + }); + }); +}); diff --git a/src/components/color-picker/picker-canvas.ts b/src/components/color-picker/picker-canvas.ts index 7e5a95d97..4072a7486 100644 --- a/src/components/color-picker/picker-canvas.ts +++ b/src/components/color-picker/picker-canvas.ts @@ -15,6 +15,9 @@ import { addSafeEventListener, asPercent, clamp } from '../common/util.js'; import { styles } from './themes/picker-canvas.base.css.js'; export interface IgcPickerCanvasEventMap { + /** + * Emitted when the color is picked in the canvas. Does not bubble. + */ igcColorPicked: CustomEvent; } @@ -84,6 +87,7 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< x: Math.round(asPercent(x + width, rect.width)), y: Math.round(asPercent(y + height, rect.height)), }, + bubbles: false, }); } } @@ -110,6 +114,7 @@ export default class IgcPickerCanvasComponent extends EventEmitterMixin< x: Math.round(asPercent(x + width, rect.width)), y: Math.round(asPercent(y + height, rect.height)), }, + bubbles: false, }); } } diff --git a/src/components/color-picker/themes/color-picker.base.scss b/src/components/color-picker/themes/color-picker.base.scss index 21fd1647a..b0a41d247 100644 --- a/src/components/color-picker/themes/color-picker.base.scss +++ b/src/components/color-picker/themes/color-picker.base.scss @@ -184,3 +184,7 @@ cursor: pointer; } } + +:host([required]) [part='label']::after { + content: '*'; +} diff --git a/src/components/color-picker/validators.ts b/src/components/color-picker/validators.ts new file mode 100644 index 000000000..f514364b5 --- /dev/null +++ b/src/components/color-picker/validators.ts @@ -0,0 +1,6 @@ +import { requiredValidator, type Validator } from '../common/validators.js'; +import type IgcColorPickerComponent from './color-picker.js'; + +export const colorPickerValidators: Validator[] = [ + requiredValidator, +]; diff --git a/stories/color-picker.stories.ts b/stories/color-picker.stories.ts index c57ff1412..fdbb125d0 100644 --- a/stories/color-picker.stories.ts +++ b/stories/color-picker.stories.ts @@ -25,7 +25,8 @@ const metadata: Meta = { 'igcOpened', 'igcClosing', 'igcClosed', - 'igcColorPicked', + 'igcInput', + 'igcChange', ], }, }, @@ -66,6 +67,13 @@ const metadata: Meta = { control: { type: 'inline-radio' }, table: { defaultValue: { summary: 'default' } }, }, + required: { + type: 'boolean', + description: + 'When set, makes the component a required field for validation.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, name: { type: 'string', description: 'The name attribute of the control.', @@ -95,6 +103,7 @@ const metadata: Meta = { hideFormats: false, showAlpha: false, mode: 'default', + required: false, disabled: false, invalid: false, open: false, @@ -116,6 +125,8 @@ interface IgcColorPickerArgs { showAlpha: boolean; /** The mode of the color picker. */ mode: 'default' | 'input'; + /** When set, makes the component a required field for validation. */ + required: boolean; /** The name attribute of the control. */ name: string; /** The disabled state of the component. */ @@ -130,35 +141,97 @@ 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', }, }; -export const CustomSwatches: Story = { - parameters: { - actions: { - handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], - }, - }, +const rowStyle = + 'display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-start;'; +export const Formats: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` +
+
+

Hex

+ +
+
+

RGB

+ +
+
+

HSL

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

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

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

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

+
+
+

Default

+ +
+
+

Input

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

Empty

+ +
+
+

Disabled

+ +
+
+

Invalid

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

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

+ +

+ No events yet +

+ `; + }, +}; + export const Form: Story = { argTypes: disableStoryControls(metadata), render: () => html` @@ -194,6 +325,13 @@ export const Form: Story = { label="Initial value" value="firebrick" >
+ + ${formControls()} From 108b35b06d5bf855bc1bd6bb8722af21a19ba64b Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Thu, 30 Jul 2026 19:30:03 +0300 Subject: [PATCH 16/17] fix: ARIA improvements for color picker component Some addtional improvements to the color picker component, including: - Added `type="button"` to swatch buttons to prevent form submission when clicked. - Updated ARIA labels for swatch buttons to provide better context for screen readers. - Updated JSDoc comments for events to improve clarity and consistency. --- src/components/color-picker/color-picker.ts | 7 +++++-- src/components/color-picker/picker-canvas.ts | 2 +- src/components/color-picker/themes/color-picker.base.scss | 1 - src/components/color-picker/themes/picker-canvas.base.scss | 1 - stories/color-picker.stories.ts | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/color-picker/color-picker.ts b/src/components/color-picker/color-picker.ts index 911c3fbf2..9fefcfb25 100644 --- a/src/components/color-picker/color-picker.ts +++ b/src/components/color-picker/color-picker.ts @@ -81,7 +81,7 @@ const Slots = setSlots( * * @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 igcClosing - Emitted just before the picker dropdown is closed. * @fires igcClosed - Emitted after closing the picker dropdown. * @fires igcInput - Emitted when the value of the component is changed. * @fires igcChange - Emitted when the value of the component is committed. @@ -388,7 +388,7 @@ export default class IgcColorPickerComponent extends FormAssociatedRequiredMixin } private _handleCopy(): void { - navigator.clipboard.writeText(this.value).catch(() => {}); + navigator.clipboard?.writeText(this.value).catch(() => {}); } private _handleSwatchClick(event: Event): void { @@ -625,6 +625,7 @@ export default class IgcColorPickerComponent extends FormAssociatedRequiredMixin ${this.swatches.map( (color) => html`