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