Skip to content

feat: Added color picker component - #1973

Draft
rkaraivanov wants to merge 32 commits into
masterfrom
rkaraivanov/color-picker
Draft

feat: Added color picker component#1973
rkaraivanov wants to merge 32 commits into
masterfrom
rkaraivanov/color-picker

Conversation

@rkaraivanov

Copy link
Copy Markdown
Member

No description provided.

rkaraivanov and others added 20 commits November 14, 2025 10:10
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.
Comment thread src/components/color-picker/common.ts Dismissed
Comment thread src/components/color-picker/common.ts Dismissed
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.
Copilot AI review requested due to automatic review settings July 21, 2026 12:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 supporting igc-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.

Comment thread src/components/color-picker/converters.ts Outdated
Comment thread src/components/color-picker/converters.ts
Comment thread src/components/color-picker/color-picker.ts Outdated
Comment thread src/components/color-picker/color-picker.ts Outdated
Comment thread stories/color-picker.stories.ts Outdated
Comment thread src/components/color-picker/color-picker.ts
Comment thread src/components/color-picker/color-picker.ts
Copilot AI review requested due to automatic review settings July 29, 2026 06:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 emit igcColorPicked for 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 from toHSV() 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 sets ctx.fillStyle = colorString directly. For invalid strings, canvas keeps the previous fillStyle, 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 both CanvasRenderingContext2D and OffscreenCanvasRenderingContext2D so callers/tests can fall back to a normal <canvas> context when OffscreenCanvas isn’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 in parseColor(), 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 (no for) 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>`

Comment thread src/components/color-picker/model.ts
Copilot AI review requested due to automatic review settings July 29, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 explicit requestUpdate() 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">, but igc-button is 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 fallback 0, so result.alpha becomes 0 (transparent) instead of 1 (opaque). This makes parsing rgb(...) incorrectly produce transparent colors in environments where ctx.fillStyle serializes as rgb(...) 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.clipboard can 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 full rect.width/rect.height and 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 };
  }

Copilot AI review requested due to automatic review settings July 29, 2026 11:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 any for 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 requires none, a length, or auto <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 requires none, a length, or auto <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 explicit type. When the color picker is used inside a <form>, clicking a swatch can submit the form (default button type is submit).
                <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

  • IgcColorPickerEventMap declares igcInput and igcChange, but the component does not emit either event anywhere (only igcColorPicked). 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.
Copilot AI review requested due to automatic review settings July 30, 2026 16:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 anchor igc-input should reference the helper/validation container via aria-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 via aria-describedby. Without that, screen readers may not announce helper/validation text (contrast with e.g. combo.ts which wires aria-describedby to 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. Use currentColor so the icon follows the component/theme foreground color.
            stroke="#0F172A"

src/components/color-picker/color-picker.ts:391

  • navigator.clipboard is not available in all environments (e.g. non-secure contexts). Calling navigator.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 (no addThemingController(this, all) and no per-theme style aggregator like other components use, e.g. src/components/select/select.ts imports all from ./themes/themes.js and calls addThemingController). 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.
Copilot AI review requested due to automatic review settings July 30, 2026 16:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 showAlpha is false, the template still renders an empty <div part="alpha-row">, which (together with the grid row-gap in color-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 no themes/themes.js import), unlike other components such as src/components/divider/divider.ts (constructor calls addThemingController(this, all) and imports all from ./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).EyeDropper introduces any into the codebase. This can be typed safely with an unknown cast 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-canvas component 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');

Copilot AI review requested due to automatic review settings July 31, 2026 06:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 background style even when color is an empty string. Because background is a shorthand, this clears the checkered background-image applied via the empty part, 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 introduces any into the component code. Since this is a public component and the codebase otherwise avoids any, 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.ts currently 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 no themes/themes.ts in src/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);
  }

Copilot AI review requested due to automatic review settings August 7, 2026 07:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() sets result.alpha to 0 when parsing an rgb(...) string that matches RGBA_RE without an alpha component, because the last capture can be an empty string and asNumber('') falls back to 0. This would incorrectly treat opaque rgb(...) inputs as fully transparent in environments where ctx.fillStyle normalizes to rgb(...) 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-color CSS 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 any for the EyeDropper API. Using globalThis as any defeats type-safety and conflicts with the project guideline to avoid any. 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();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants