-
Notifications
You must be signed in to change notification settings - Fork 10
feat: Added color picker component #1973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
rkaraivanov
wants to merge
32
commits into
master
Choose a base branch
from
rkaraivanov/color-picker
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,861
−1
Draft
Changes from 28 commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
e9f0112
feat: Added color picker component
rkaraivanov 0545c92
fix: Stylelint auto-fix for SCSS files in color-picker component
rkaraivanov d0019f8
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 14fc703
Merge branch 'master' into rkaraivanov/color-picker
kdinev c67ec5b
Merge branch 'master' into rkaraivanov/color-picker
kdinev 6b94da8
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 003ea7f
refactor: Cleaned up color syncing logic in color-picker component
rkaraivanov 3454a37
Merge branch 'rkaraivanov/color-picker' of https://github.com/IgniteU…
rkaraivanov 8f11e47
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 6dbefd3
feat: Use EyeDropper API for color picking where supported
rkaraivanov c384860
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov decc7e2
refactor: Use modern color formats and update color picker component
rkaraivanov feeeb37
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov a19a9da
feat: Added predefined swatches to color picker
rkaraivanov 08a81e2
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 09db9b1
refactor: Align color picker width with design specifications
rkaraivanov 5c1760e
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 227b4e1
feat(color-picker): support empty/undefined color values
rkaraivanov b5735fd
test: color-picker empty value handling
rkaraivanov 1ce4b89
chore: fix styleint error
rkaraivanov 8202b68
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov cb4b008
feat: added input mode for the color picker component
rkaraivanov 0a6cdf8
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 8aa2b80
Merge remote-tracking branch 'origin/master' into rkaraivanov/color-p…
rkaraivanov 0193fea
fix: Addressed PR review comments
rkaraivanov 7eb3424
fix: More bug fixes and improvements to the color picker component
rkaraivanov 00f0751
feat: Added additional keybinding for opening and closing the picker
rkaraivanov bcd073a
feat: Finalize color-picker component
rkaraivanov 108b35b
fix: ARIA improvements for color picker component
rkaraivanov 68722e0
feat: Disabled state and external label association
rkaraivanov faf04d1
Merge branch 'master' into rkaraivanov/color-picker
rkaraivanov 25f7483
Merge branch 'master' into rkaraivanov/color-picker
desig9stein File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| ctx.fillStyle = colorString; | ||
| const onBlack = ctx.fillStyle; | ||
|
|
||
| ctx.fillStyle = '#fff'; | ||
|
rkaraivanov marked this conversation as resolved.
Dismissed
|
||
| ctx.fillStyle = colorString; | ||
| const onWhite = ctx.fillStyle; | ||
|
|
||
| return onBlack === onWhite; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.