Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e9f0112
feat: Added color picker component
rkaraivanov Nov 14, 2025
0545c92
fix: Stylelint auto-fix for SCSS files in color-picker component
rkaraivanov Nov 14, 2025
d0019f8
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Nov 28, 2025
14fc703
Merge branch 'master' into rkaraivanov/color-picker
kdinev Feb 10, 2026
c67ec5b
Merge branch 'master' into rkaraivanov/color-picker
kdinev May 12, 2026
6b94da8
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov May 26, 2026
003ea7f
refactor: Cleaned up color syncing logic in color-picker component
rkaraivanov May 26, 2026
3454a37
Merge branch 'rkaraivanov/color-picker' of https://github.com/IgniteU…
rkaraivanov May 26, 2026
8f11e47
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov May 26, 2026
6dbefd3
feat: Use EyeDropper API for color picking where supported
rkaraivanov May 26, 2026
c384860
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov May 28, 2026
decc7e2
refactor: Use modern color formats and update color picker component
rkaraivanov May 28, 2026
feeeb37
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Jun 2, 2026
a19a9da
feat: Added predefined swatches to color picker
rkaraivanov Jun 2, 2026
08a81e2
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Jun 22, 2026
09db9b1
refactor: Align color picker width with design specifications
rkaraivanov Jun 22, 2026
5c1760e
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Jun 23, 2026
227b4e1
feat(color-picker): support empty/undefined color values
rkaraivanov Jun 23, 2026
b5735fd
test: color-picker empty value handling
rkaraivanov Jun 23, 2026
1ce4b89
chore: fix styleint error
rkaraivanov Jun 23, 2026
8202b68
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Jun 25, 2026
cb4b008
feat: added input mode for the color picker component
rkaraivanov Jun 25, 2026
0a6cdf8
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Jul 21, 2026
8aa2b80
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov Jul 29, 2026
0193fea
fix: Addressed PR review comments
rkaraivanov Jul 29, 2026
7eb3424
fix: More bug fixes and improvements to the color picker component
rkaraivanov Jul 29, 2026
00f0751
feat: Added additional keybinding for opening and closing the picker
rkaraivanov Jul 29, 2026
bcd073a
feat: Finalize color-picker component
rkaraivanov Jul 30, 2026
108b35b
fix: ARIA improvements for color picker component
rkaraivanov Jul 30, 2026
68722e0
feat: Disabled state and external label association
rkaraivanov Jul 31, 2026
faf04d1
Merge branch 'master' into rkaraivanov/color-picker
rkaraivanov Aug 7, 2026
25f7483
Merge branch 'master' into rkaraivanov/color-picker
desig9stein Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
737 changes: 737 additions & 0 deletions src/components/color-picker/color-picker.spec.ts

Large diffs are not rendered by default.

760 changes: 760 additions & 0 deletions src/components/color-picker/color-picker.ts

Large diffs are not rendered by default.

211 changes: 211 additions & 0 deletions src/components/color-picker/common.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { expect } from '@open-wc/testing';

import { isValidColor, 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]);
expect(result.alpha).to.equal(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 are rejected before parsing, always returning the
// deterministic default result.
const result = parseColor('not-a-color', ctx);

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 are rejected before parsing, always returning
// the deterministic default result.
const result = parseColor('#zzz', ctx);

expect(result.value).to.deep.equal([0, 0, 0]);
expect(result.alpha).to.equal(1);
});

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);
});
});
});

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;
});
});
104 changes: 104 additions & 0 deletions src/components/color-picker/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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;
const HEX_WITHOUT_HASH_RE = /^[0-9a-f]{3,4}$|^[0-9a-f]{6}$|^[0-9a-f]{8}$/i;

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;
}

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 = normalized;
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;
}

/**
* 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';
Comment thread
rkaraivanov marked this conversation as resolved.
Dismissed
ctx.fillStyle = colorString;
const onBlack = ctx.fillStyle;

ctx.fillStyle = '#fff';
Comment thread
rkaraivanov marked this conversation as resolved.
Dismissed
ctx.fillStyle = colorString;
const onWhite = ctx.fillStyle;

return onBlack === onWhite;
}
Loading