From d5c40a4543cbcaf57104e6647379ae19899e41f1 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 16:23:26 +0200 Subject: [PATCH] feat(shared-ui): add generic ColorPicker with standard palettes Extract TagColorPicker logic into generic ColorPicker that accepts custom color palettes. TagColorPicker is now a thin wrapper. Add COLORS_12 and COLORS_16 standard palettes for consistent color selection across all modules (projects, folders, calendars, categories, etc.). 10 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-ui/src/index.ts | 5 ++ .../src/molecules/ColorPicker.constants.ts | 47 +++++++++++ .../src/molecules/ColorPicker.svelte | 83 +++++++++++++++++++ .../src/molecules/ColorPicker.test.ts | 69 +++++++++++++++ packages/shared-ui/src/molecules/index.ts | 2 + .../src/molecules/tags/TagColorPicker.svelte | 65 ++------------- .../src/molecules/tags/TagColorPicker.test.ts | 16 ++-- 7 files changed, 222 insertions(+), 65 deletions(-) create mode 100644 packages/shared-ui/src/molecules/ColorPicker.constants.ts create mode 100644 packages/shared-ui/src/molecules/ColorPicker.svelte create mode 100644 packages/shared-ui/src/molecules/ColorPicker.test.ts diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index db423f8d8..f4b7ecbba 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -10,6 +10,11 @@ export { Checkbox, FilterDropdown, FavoriteButton, + ColorPicker, + COLORS_12, + COLORS_16, + DEFAULT_COLOR, + getRandomColor, } from './molecules'; export type { SelectOption, FilterDropdownOption } from './molecules'; diff --git a/packages/shared-ui/src/molecules/ColorPicker.constants.ts b/packages/shared-ui/src/molecules/ColorPicker.constants.ts new file mode 100644 index 000000000..852446530 --- /dev/null +++ b/packages/shared-ui/src/molecules/ColorPicker.constants.ts @@ -0,0 +1,47 @@ +/** + * Standard color palettes for use with ColorPicker. + */ + +/** 12-color palette (same as TAG_COLORS) — good for tags, labels, categories */ +export const COLORS_12 = [ + '#ef4444', + '#f97316', + '#f59e0b', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#ec4899', + '#64748b', +] as const; + +/** 16-color extended palette — good for projects, clients, folders */ +export const COLORS_16 = [ + '#ef4444', + '#f97316', + '#f59e0b', + '#eab308', + '#84cc16', + '#22c55e', + '#14b8a6', + '#06b6d4', + '#0ea5e9', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#a855f7', + '#d946ef', + '#ec4899', + '#f43f5e', +] as const; + +/** Default color (blue) */ +export const DEFAULT_COLOR = '#3b82f6'; + +/** Get a random color from the 12-color palette */ +export function getRandomColor(): string { + return COLORS_12[Math.floor(Math.random() * COLORS_12.length)]; +} diff --git a/packages/shared-ui/src/molecules/ColorPicker.svelte b/packages/shared-ui/src/molecules/ColorPicker.svelte new file mode 100644 index 000000000..0a271bfc3 --- /dev/null +++ b/packages/shared-ui/src/molecules/ColorPicker.svelte @@ -0,0 +1,83 @@ + + +
+ {#each colors as color} + {@const isSelected = selectedColor?.toLowerCase() === color.toLowerCase()} + + {/each} +
diff --git a/packages/shared-ui/src/molecules/ColorPicker.test.ts b/packages/shared-ui/src/molecules/ColorPicker.test.ts new file mode 100644 index 000000000..111d7567a --- /dev/null +++ b/packages/shared-ui/src/molecules/ColorPicker.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import ColorPicker from './ColorPicker.svelte'; +import { COLORS_12, COLORS_16, DEFAULT_COLOR, getRandomColor } from './ColorPicker.constants'; + +const testColors = ['#ef4444', '#3b82f6', '#22c55e']; + +describe('ColorPicker', () => { + it('renders all provided colors as radio buttons', () => { + render(ColorPicker, { props: { colors: testColors, onColorChange: vi.fn() } }); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(3); + }); + + it('marks selected color as checked', () => { + render(ColorPicker, { + props: { colors: testColors, selectedColor: '#3b82f6', onColorChange: vi.fn() }, + }); + const blue = screen.getByRole('radio', { name: '#3b82f6' }); + expect(blue.getAttribute('aria-checked')).toBe('true'); + }); + + it('calls onColorChange when clicked', async () => { + const onColorChange = vi.fn(); + render(ColorPicker, { props: { colors: testColors, onColorChange } }); + await fireEvent.click(screen.getByRole('radio', { name: '#22c55e' })); + expect(onColorChange).toHaveBeenCalledWith('#22c55e'); + }); + + it('supports keyboard selection', async () => { + const onColorChange = vi.fn(); + render(ColorPicker, { props: { colors: testColors, onColorChange } }); + await fireEvent.keyDown(screen.getByRole('radio', { name: '#ef4444' }), { key: 'Enter' }); + expect(onColorChange).toHaveBeenCalledWith('#ef4444'); + }); + + it('has accessible radiogroup role', () => { + render(ColorPicker, { props: { colors: testColors, onColorChange: vi.fn() } }); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + }); + + it('uses custom label', () => { + render(ColorPicker, { + props: { colors: testColors, onColorChange: vi.fn(), label: 'Pick a color' }, + }); + expect(screen.getByRole('radiogroup', { name: 'Pick a color' })).toBeInTheDocument(); + }); +}); + +describe('Color constants', () => { + it('COLORS_12 has 12 entries', () => { + expect(COLORS_12).toHaveLength(12); + }); + + it('COLORS_16 has 16 entries', () => { + expect(COLORS_16).toHaveLength(16); + }); + + it('DEFAULT_COLOR is blue', () => { + expect(DEFAULT_COLOR).toBe('#3b82f6'); + }); + + it('getRandomColor returns a color from COLORS_12', () => { + const validColors = new Set(COLORS_12); + for (let i = 0; i < 30; i++) { + expect(validColors.has(getRandomColor() as (typeof COLORS_12)[number])).toBe(true); + } + }); +}); diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index d03849767..15e36a774 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -5,6 +5,8 @@ export { default as Textarea } from './Textarea.svelte'; export { default as Checkbox } from './Checkbox.svelte'; export { default as FilterDropdown } from './FilterDropdown.svelte'; export { default as FavoriteButton } from './FavoriteButton.svelte'; +export { default as ColorPicker } from './ColorPicker.svelte'; +export { COLORS_12, COLORS_16, DEFAULT_COLOR, getRandomColor } from './ColorPicker.constants'; export type { SelectOption } from './Select.types'; export type { FilterDropdownOption } from './FilterDropdown.types'; diff --git a/packages/shared-ui/src/molecules/tags/TagColorPicker.svelte b/packages/shared-ui/src/molecules/tags/TagColorPicker.svelte index 1b808faa5..b2f5a9c0f 100644 --- a/packages/shared-ui/src/molecules/tags/TagColorPicker.svelte +++ b/packages/shared-ui/src/molecules/tags/TagColorPicker.svelte @@ -1,7 +1,12 @@ -
- {#each TAG_COLORS as color} - {@const isSelected = selectedColor?.toLowerCase() === color.hex.toLowerCase()} - - {/each} -
+ diff --git a/packages/shared-ui/src/molecules/tags/TagColorPicker.test.ts b/packages/shared-ui/src/molecules/tags/TagColorPicker.test.ts index 125c7cbaf..040e6375a 100644 --- a/packages/shared-ui/src/molecules/tags/TagColorPicker.test.ts +++ b/packages/shared-ui/src/molecules/tags/TagColorPicker.test.ts @@ -11,10 +11,10 @@ describe('TagColorPicker', () => { expect(buttons).toHaveLength(12); }); - it('each button has correct aria-label', () => { + it('each button has hex color as aria-label', () => { render(TagColorPicker, { props: { onColorChange: vi.fn() } }); for (const color of TAG_COLORS) { - expect(screen.getByRole('radio', { name: color.name })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: color.hex })).toBeInTheDocument(); } }); @@ -22,7 +22,7 @@ describe('TagColorPicker', () => { render(TagColorPicker, { props: { selectedColor: DEFAULT_TAG_COLOR, onColorChange: vi.fn() }, }); - const blueBtn = screen.getByRole('radio', { name: 'blue' }); + const blueBtn = screen.getByRole('radio', { name: '#3b82f6' }); expect(blueBtn.getAttribute('aria-checked')).toBe('true'); }); @@ -30,16 +30,16 @@ describe('TagColorPicker', () => { render(TagColorPicker, { props: { selectedColor: '#ef4444', onColorChange: vi.fn() }, }); - const blueBtn = screen.getByRole('radio', { name: 'blue' }); + const blueBtn = screen.getByRole('radio', { name: '#3b82f6' }); expect(blueBtn.getAttribute('aria-checked')).toBe('false'); - const redBtn = screen.getByRole('radio', { name: 'red' }); + const redBtn = screen.getByRole('radio', { name: '#ef4444' }); expect(redBtn.getAttribute('aria-checked')).toBe('true'); }); it('calls onColorChange when a color is clicked', async () => { const onColorChange = vi.fn(); render(TagColorPicker, { props: { onColorChange } }); - const greenBtn = screen.getByRole('radio', { name: 'green' }); + const greenBtn = screen.getByRole('radio', { name: '#22c55e' }); await fireEvent.click(greenBtn); expect(onColorChange).toHaveBeenCalledWith('#22c55e'); }); @@ -47,7 +47,7 @@ describe('TagColorPicker', () => { it('supports keyboard selection with Enter', async () => { const onColorChange = vi.fn(); render(TagColorPicker, { props: { onColorChange } }); - const tealBtn = screen.getByRole('radio', { name: 'teal' }); + const tealBtn = screen.getByRole('radio', { name: '#14b8a6' }); await fireEvent.keyDown(tealBtn, { key: 'Enter' }); expect(onColorChange).toHaveBeenCalledWith('#14b8a6'); }); @@ -55,7 +55,7 @@ describe('TagColorPicker', () => { it('supports keyboard selection with Space', async () => { const onColorChange = vi.fn(); render(TagColorPicker, { props: { onColorChange } }); - const pinkBtn = screen.getByRole('radio', { name: 'pink' }); + const pinkBtn = screen.getByRole('radio', { name: '#ec4899' }); await fireEvent.keyDown(pinkBtn, { key: ' ' }); expect(onColorChange).toHaveBeenCalledWith('#ec4899'); });