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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 16:23:26 +02:00
parent 4667d5df33
commit d5c40a4543
7 changed files with 222 additions and 65 deletions

View file

@ -10,6 +10,11 @@ export {
Checkbox,
FilterDropdown,
FavoriteButton,
ColorPicker,
COLORS_12,
COLORS_16,
DEFAULT_COLOR,
getRandomColor,
} from './molecules';
export type { SelectOption, FilterDropdownOption } from './molecules';

View file

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

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { Check } from '@manacore/shared-icons';
/**
* Generic color picker with predefined palette.
* Renders a grid of color circles with selection indicator.
*/
interface Props {
/** Available colors (hex strings) */
colors: string[];
/** Currently selected color */
selectedColor?: string;
/** Called when a color is selected */
onColorChange: (color: string) => void;
/** Button size */
size?: 'sm' | 'md' | 'lg';
/** Accessible label for the group */
label?: string;
}
let {
colors,
selectedColor,
onColorChange,
size = 'md',
label = 'Farbe wählen',
}: Props = $props();
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10',
};
const iconSizes = {
sm: 12,
md: 14,
lg: 18,
};
const gapClasses = {
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2.5',
};
function handleKeyDown(e: KeyboardEvent, hex: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onColorChange(hex);
}
}
</script>
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label={label}>
{#each colors as color}
{@const isSelected = selectedColor?.toLowerCase() === color.toLowerCase()}
<button
type="button"
class="
{sizeClasses[size]}
rounded-full
flex items-center justify-center
transition-all duration-150
ring-offset-2 ring-offset-white dark:ring-offset-gray-900
focus:outline-none focus:ring-2 focus:ring-primary
{isSelected ? 'ring-2 ring-black/30 dark:ring-white/50 scale-110' : 'hover:scale-110'}
"
style="background-color: {color}"
onclick={() => onColorChange(color)}
onkeydown={(e) => handleKeyDown(e, color)}
role="radio"
aria-checked={isSelected}
aria-label={color}
title={color}
>
{#if isSelected}
<Check size={iconSizes[size]} weight="bold" class="text-white drop-shadow-sm" />
{/if}
</button>
{/each}
</div>

View file

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

View file

@ -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';

View file

@ -1,7 +1,12 @@
<script lang="ts">
import { Check } from '@manacore/shared-icons';
import ColorPicker from '../ColorPicker.svelte';
import { TAG_COLORS, DEFAULT_TAG_COLOR } from './constants';
/**
* Tag-specific color picker using the standard TAG_COLORS palette.
* Wrapper around the generic ColorPicker.
*/
interface Props {
selectedColor?: string;
onColorChange: (color: string) => void;
@ -10,61 +15,7 @@
let { selectedColor = DEFAULT_TAG_COLOR, onColorChange, size = 'md' }: Props = $props();
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10',
};
const iconSizes = {
sm: 12,
md: 14,
lg: 18,
};
const gapClasses = {
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2.5',
};
function handleColorSelect(hex: string) {
onColorChange(hex);
}
function handleKeyDown(e: KeyboardEvent, hex: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleColorSelect(hex);
}
}
const colors = TAG_COLORS.map((c) => c.hex);
</script>
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label="Tag color">
{#each TAG_COLORS as color}
{@const isSelected = selectedColor?.toLowerCase() === color.hex.toLowerCase()}
<button
type="button"
class="
{sizeClasses[size]}
rounded-full
flex items-center justify-center
transition-all duration-150
ring-offset-2 ring-offset-white dark:ring-offset-gray-900
focus:outline-none focus:ring-2 focus:ring-primary
{isSelected ? 'ring-2 ring-black/30 dark:ring-white/50 scale-110' : 'hover:scale-110'}
"
style="background-color: {color.hex}"
onclick={() => handleColorSelect(color.hex)}
onkeydown={(e) => handleKeyDown(e, color.hex)}
role="radio"
aria-checked={isSelected}
aria-label={color.name}
title={color.name}
>
{#if isSelected}
<Check size={iconSizes[size]} weight="bold" class="text-white drop-shadow-sm" />
{/if}
</button>
{/each}
</div>
<ColorPicker {colors} {selectedColor} {onColorChange} {size} label="Tag color" />

View file

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