feat: Added color picker component - #1973
Conversation
…I/igniteui-webcomponents into rkaraivanov/color-picker
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.
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.
There was a problem hiding this comment.
Pull request overview
Adds a new igc-color-picker web component to the Ignite UI Web Components library, including its internal color model/converters, styles, Storybook story, and unit tests.
Changes:
- Introduces the
IgcColorPickerComponent(and supportingigc-picker-canvas) with theming and interactions (hue/SV selection, alpha, swatches, copy, eyedropper). - Adds a color parsing/model layer (
ColorModel, converters, validation helpers) plus unit tests. - Wires the component into the public exports and the “define all components” registration list, and adds a Storybook story.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.json | Extends lit plugin global attributes (adds inert). |
| stories/color-picker.stories.ts | Adds Storybook coverage for the new color picker. |
| src/index.ts | Exports IgcColorPickerComponent from the package entry. |
| src/components/common/definitions/defineAllComponents.ts | Registers IgcColorPickerComponent in the “define all” list. |
| src/components/color-picker/themes/picker-canvas.base.scss | Styles for the SV picker canvas and marker. |
| src/components/color-picker/themes/color-picker.base.scss | Styles for the overall color picker UI (sliders, buttons, swatches). |
| src/components/color-picker/picker-canvas.ts | New SV picker surface with pointer + keyboard interactions and events. |
| src/components/color-picker/model.ts | ColorModel implementation and context helper for parsing. |
| src/components/color-picker/model.spec.ts | Unit tests for ColorModel. |
| src/components/color-picker/converters.ts | RGB/HSL/HSV conversion utilities. |
| src/components/color-picker/common.ts | Color parsing + validation helpers. |
| src/components/color-picker/common.spec.ts | Unit tests for parsing/validation helpers. |
| src/components/color-picker/color-picker.ts | Main igc-color-picker component implementation. |
| src/components/color-picker/color-picker.spec.ts | Component-level tests incl. a11y and form association. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (7)
src/components/color-picker/color-picker.ts:240
- The picker canvas interaction is updating
ColorModel.s, which is documented/implemented as HSL saturation. The 2D picker gradient (white→hue, transparent→black) is an HSV-style S/V square, so this produces incorrect colors. Update the model using HSV saturation/value (preserving current hue/alpha) and emitigcColorPickedfor canvas drags as well.
this._color.s = event.detail.x;
this._color.v = 100 - event.detail.y;
this._updateColor();
}
src/components/color-picker/color-picker.ts:340
- Canvas marker syncing also uses
this._color.s(HSL saturation). To keep the marker position consistent with the HSV S/V square, compute the position fromtoHSV()instead.
const x = (this._color.s / 100) * rect.width - markerWidth;
const y = ((100 - this._color.v) / 100) * rect.height - markerHeight;
src/components/color-picker/common.ts:36
parseColor()currently setsctx.fillStyle = colorStringdirectly. For invalid strings, canvas keeps the previousfillStyle, making parsing non-deterministic (tests currently allow this), and "hex without #" isn’t reliably supported. Normalize hex strings without#and validate before parsing to make results deterministic.
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;
src/components/color-picker/common.ts:80
isValidColor()should accept bothCanvasRenderingContext2DandOffscreenCanvasRenderingContext2Dso callers/tests can fall back to a normal<canvas>context whenOffscreenCanvasisn’t available.
export function isValidColor(
colorString: string,
ctx: OffscreenCanvasRenderingContext2D | null
): boolean {
src/components/color-picker/common.spec.ts:66
- This test currently allows a non-deterministic alpha result because invalid input can reuse the previous canvas
fillStyle. After normalizing/validating inparseColor(), this should always parse as a 6-digit hex with alpha = 1.
// Note: Canvas may add alpha channel for some hex formats
expect(result.alpha).to.be.oneOf([0.5, 1]);
});
stories/color-picker.stories.ts:1
- All other stories import Storybook types from
@storybook/web-components-vite(e.g.stories/input.stories.ts:7). To match the project’s Storybook setup, use the same package here.
import type { Meta, StoryObj } from '@storybook/web-components';
src/components/color-picker/color-picker.ts:637
<label for="trigger">won’t associate with<igc-button>because custom elements aren’t labelable; this also implies a relationship that assistive tech won’t honor. Prefer a plain label element (nofor) and rely on the button’s accessible name (e.g. its visually-hidden text / aria-label).
? html`<label part="label" for="trigger">${this.label}</label>`
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
src/components/color-picker/color-picker.ts:329
_updateColor()sets a@state()property (_ownCurrentColor), which already schedules an update. The explicitrequestUpdate()is redundant and can cause extra work.
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();
}
src/components/color-picker/color-picker.ts:637
- In default mode the visible label is rendered as
<label for="trigger">, butigc-buttonis not a labelable form control, so the label isn't actually associated with the trigger. Using a non-label element avoids incorrect semantics here.
isDefaultMode && this.label
? html`<label part="label" for="trigger">${this.label}</label>`
: nothing
src/components/color-picker/common.ts:54
- When the canvas normalizes a color to
rgb(...)(no alpha), the regex capture for alpha is an empty string.asNumber('')returns the fallback0, soresult.alphabecomes0(transparent) instead of1(opaque). This makes parsingrgb(...)incorrectly produce transparent colors in environments wherectx.fillStyleserializes asrgb(...)instead of hex.
if (rgbaMatch) {
const [r, g, b, a] = rgbaMatch.slice(3).map((part) => asNumber(part));
result.value = [r, g, b];
result.alpha = a ?? 1;
src/components/color-picker/color-picker.ts:308
navigator.clipboardcan be unavailable (e.g. insecure context, permissions, older browsers). As written, this can throw synchronously before the.catch()runs. Guarding with optional chaining avoids runtime errors.
This issue also appears in the following locations of the same file:
- line 324
- line 635
private _handleCopy(): void {
navigator.clipboard.writeText(this.value).catch(() => {});
}
src/components/color-picker/picker-canvas.ts:36
@query('div')will break if another<div>is ever added to the template. Querying the marker by a stable selector (its part) makes this more robust.
@query('div')
private readonly _marker?: HTMLDivElement;
src/components/color-picker/picker-canvas.ts:138
getMarkerDimensions()returns half the marker width/height (rect.width / 2), but the name implies full dimensions. This is easy to misuse and leaks an implementation detail as a public API. Consider either returning the fullrect.width/rect.heightand letting callers compute the offset, or rename the method to reflect that it returns half-dimensions (and update call sites accordingly).
public getMarkerDimensions(): { width: number; height: number } {
const rect = this._marker?.getBoundingClientRect();
return rect
? { width: rect.width / 2, height: rect.height / 2 }
: { width: 0, height: 0 };
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (8)
src/components/color-picker/color-picker.ts:298
- Avoid using
anyfor the EyeDropper constructor. You can type the constructor shape and guard at runtime, which keeps the code type-safe while still supporting optional availability.
private _handleEyeDropperClick(): void {
if (!this._supportsEyeDropper) return;
const eyeDropper = new (globalThis as any).EyeDropper();
stories/color-picker.stories.ts:1
- This story imports Storybook types from
@storybook/web-components, but the rest of the repo’s stories use@storybook/web-components-vite(e.g.stories/input.stories.ts:6-8). Mixing the packages can break typing/build in this workspace.
import type { Meta, StoryObj } from '@storybook/web-components';
src/components/color-picker/themes/picker-canvas.base.scss:9
contain-intrinsic-size: auto;is not a valid value (the property requiresnone, a length, orauto <length>). As-is, it will be ignored by the browser, making the intent unclear.
:host {
contain-intrinsic-size: auto;
content-visibility: auto;
contain: strict;
display: flex;
src/components/color-picker/themes/color-picker.base.scss:9
contain-intrinsic-size: auto;is not a valid value (the property requiresnone, a length, orauto <length>). As-is, it will be ignored by the browser, making the intent unclear.
:host {
content-visibility: auto;
contain-intrinsic-size: auto;
contain: strict;
src/components/color-picker/picker-canvas.ts:36
@query('div')is brittle because any future wrapper<div>would change which element is returned. Querying by part makes the dependency explicit and resilient.
@query('div')
private readonly _marker?: HTMLDivElement;
src/components/color-picker/color-picker.ts:549
- The swatches are rendered as plain
<button>elements without an explicittype. When the color picker is used inside a<form>, clicking a swatch can submit the form (default button type issubmit).
<button
part="swatch"
aria-label="${color}"
style="background-color: ${color}"
></button>
src/components/color-picker/color-picker.ts:62
- Typo in the event doc: use “Emitted” instead of “Emitter”.
* @fires igcClosing - Emitter just before the picker dropdown is closed.
src/components/color-picker/color-picker.ts:51
IgcColorPickerEventMapdeclaresigcInputandigcChange, but the component does not emit either event anywhere (onlyigcColorPicked). This is part of the public API surface and can mislead consumers and typings.
igcInput: CustomEvent<string>;
igcChange: CustomEvent<string>;
- 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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (6)
src/components/color-picker/color-picker.ts:674
- Same issue in
mode="input": the anchorigc-inputshould reference the helper/validation container viaaria-describedby="color-picker-helper-text"so helper/validation messages are announced.
aria-haspopup="dialog"
slot="anchor"
label=${ifDefined(this.label)}
?required=${this.required}
.value=${this.value}
stories/color-picker.stories.ts:1
- This story imports Storybook types from
@storybook/web-components, but the other stories in this repo use@storybook/web-components-vite(e.g.stories/checkbox.stories.ts:1,stories/select.stories.ts:6). Using the non-vite package can break typing/build in the current Storybook setup.
import type { Meta, StoryObj } from '@storybook/web-components';
src/components/color-picker/color-picker.ts:655
- The helper text/validation container is rendered with id
color-picker-helper-text, but the default-mode anchor (igc-button) does not reference it viaaria-describedby. Without that, screen readers may not announce helper/validation text (contrast with e.g.combo.tswhich wiresaria-describedbyto its helper text id).
This issue also appears on line 670 of the same file.
id="trigger"
aria-haspopup="dialog"
part=${parts}
slot="anchor"
style=${bindIf(color, styleMap({ '--background': color }))}
src/components/color-picker/color-picker.ts:508
- The EyeDropper icon SVG hard-codes
stroke="#0F172A", which can become invisible/low-contrast in dark themes. UsecurrentColorso the icon follows the component/theme foreground color.
stroke="#0F172A"
src/components/color-picker/color-picker.ts:391
navigator.clipboardis not available in all environments (e.g. non-secure contexts). Callingnavigator.clipboard.writeText(...)without a guard can throw a TypeError before the Promise.catch()runs.
navigator.clipboard.writeText(this.value).catch(() => {});
src/components/color-picker/color-picker.ts:48
- The new component currently only provides base styles (
themes/color-picker.base.scss+picker-canvas.base.scss) and does not hook into the project theming system (noaddThemingController(this, all)and no per-theme style aggregator like other components use, e.g.src/components/select/select.tsimportsallfrom./themes/themes.jsand callsaddThemingController). This will likely make the color picker ignore light/dark and design-system themes.
import { styles } from './themes/color-picker.base.css.js';
import { colorPickerValidators } from './validators.js';
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
src/components/color-picker/color-picker.ts:679
- In
mode="input", the anchor<igc-input>is not disabled when the color picker is disabled, so users can still type/commit values on a control that should be inert.
label=${ifDefined(this.label)}
?required=${this.required}
.value=${this.value}
.invalid=${this.invalid}
@igcChange=${this._handleColorInputChange}
src/components/color-picker/color-picker.ts:717
- When
showAlphais false, the template still renders an empty<div part="alpha-row">, which (together with the gridrow-gapincolor-picker.base.scss) leaves extra vertical spacing for a row that isn’t actually shown.
<div part="alpha-row">${this._renderAlphaRow()}</div>
src/components/color-picker/color-picker.ts:46
- The component doesn’t appear to participate in the project theming system (no
addThemingController(this, all)usage and nothemes/themes.jsimport), unlike other components such assrc/components/divider/divider.ts(constructor callsaddThemingController(this, all)and importsallfrom./themes/themes.js). Without this, the color picker won’t react to theme-provider changes and will likely look inconsistent across themes.
import { styles } from './themes/color-picker.base.css.js';
src/components/color-picker/color-picker.ts:388
(globalThis as any).EyeDropperintroducesanyinto the codebase. This can be typed safely with anunknowncast and a minimal constructor interface, while still keeping runtime feature detection.
private _handleEyeDropperClick(): void {
if (!this._supportsEyeDropper) return;
const eyeDropper = new (globalThis as any).EyeDropper();
src/components/color-picker/picker-canvas.spec.ts:46
- The new
igc-picker-canvascomponent has keyboard/focus behavior but its spec file is missing the standard accessibility audit (shadowDom.to.be.accessible()), which is expected across this repo’s component tests.
describe('Rendering', () => {
it('renders a focusable marker', () => {
const marker = getMarker(canvas);
expect(marker).to.exist;
expect(marker.getAttribute('tabindex')).to.equal('0');
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/components/color-picker/color-picker.ts:697
- In input mode, the prefix swatch always sets an inline
backgroundstyle even whencoloris an empty string. Becausebackgroundis a shorthand, this clears the checkeredbackground-imageapplied via theemptypart, so the empty-state indicator won’t render correctly.
<div
slot="prefix"
part=${parts}
style=${styleMap({ padding: '1rem', background: color })}
@click=${this._handleAnchorClick}
src/components/color-picker/color-picker.ts:386
_handleEyeDropperClick()uses(globalThis as any).EyeDropper(), which introducesanyinto the component code. Since this is a public component and the codebase otherwise avoidsany, it’s better to use an explicit constructor type assertion here.
if (!this._supportsEyeDropper) return;
const eyeDropper = new (globalThis as any).EyeDropper();
eyeDropper
.open()
.then((result: { sRGBHex: string }) => {
this.value = result.sRGBHex;
src/components/color-picker/picker-canvas.spec.ts:41
- New component test suites in this repo typically include an accessibility audit (
shadowDom.to.be.accessible()/lightDom.to.be.accessible()).picker-canvas.spec.tscurrently exercises behavior but doesn’t run an a11y check, so regressions (e.g. focusable marker name/role issues) could slip in unnoticed.
describe('Rendering', () => {
src/components/color-picker/color-picker.ts:522
- The EyeDropper icon path hard-codes
stroke="#0F172A", which bypasses theming and can produce low-contrast rendering in dark themes. Other inline SVGs in the codebase rely on CSS/currentColor instead (e.g.src/components/checkbox/checkbox.ts:66-68).
<path
d="M15 11.25L16.5 12.75L17.25 12V8.75798L19.5264 8.14802C20.019 8.01652 20.4847 7.75778 20.8712 7.37132C22.0428 6.19975 22.0428 4.30025 20.8712 3.12868C19.6996 1.95711 17.8001 1.95711 16.6286 3.12868C16.2421 3.51509 15.9832 3.98069 15.8517 4.47324L15.2416 6.74998H12L11.25 7.49998L12.75 8.99999M15 11.25L6.53033 19.7197C6.19077 20.0592 5.73022 20.25 5.25 20.25C4.76978 20.25 4.30924 20.4408 3.96967 20.7803L3 21.75L2.25 21L3.21967 20.0303C3.55923 19.6908 3.75 19.2302 3.75 18.75C3.75 18.2698 3.94077 17.8092 4.28033 17.4697L12.75 8.99999M15 11.25L12.75 8.99999"
stroke="#0F172A"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
src/components/color-picker/color-picker.ts:260
- This new public component doesn’t appear to hook into the project’s theming system (no
addThemingController(this, all)and nothemes/themes.tsinsrc/components/color-picker/themes/). Most user-facing components do this to ensure consistent theme switching (e.g.src/components/input/input.ts:5-21). Without it, the color picker may not pick up theme tokens/variants consistently.
constructor() {
super();
addSafeEventListener(this, 'focusin', this._handleFocusIn);
addSafeEventListener(this, 'focusout', this._handleFocusOut);
addKeybindings(this, { skip: () => this.disabled })
.set(escapeKey, this._handleKeyboardClosing)
.set([altKey, arrowDown], this._handleAnchorClick)
.set([altKey, arrowUp], this._handleKeyboardClosing);
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/components/color-picker/common.ts:54
parseColor()setsresult.alphato0when parsing anrgb(...)string that matchesRGBA_REwithout an alpha component, because the last capture can be an empty string andasNumber('')falls back to0. This would incorrectly treat opaquergb(...)inputs as fully transparent in environments wherectx.fillStylenormalizes torgb(...)instead of hex.
if (rgbaMatch) {
const [r, g, b, a] = rgbaMatch.slice(3).map((part) => asNumber(part));
result.value = [r, g, b];
result.alpha = a ?? 1;
} else {
src/components/color-picker/color-picker.ts:414
_updateColor()sets the host--current-colorCSS variable to a hue-only HSL string. This variable is also used by the alpha slider track/thumbnail styling (var(--current-color)), so changing saturation/value won’t be reflected in the alpha UI and can make the slider misleading.
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));
src/components/color-picker/color-picker.ts:382
- Avoid
anyfor the EyeDropper API. UsingglobalThis as anydefeats type-safety and conflicts with the project guideline to avoidany. A small local constructor type keeps this code type-safe without changing runtime behavior.
private _handleEyeDropperClick(): void {
if (!this._supportsEyeDropper) return;
const eyeDropper = new (globalThis as any).EyeDropper();
No description provided.