mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 05:39:39 +02:00
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:
parent
4667d5df33
commit
d5c40a4543
7 changed files with 222 additions and 65 deletions
|
|
@ -10,6 +10,11 @@ export {
|
|||
Checkbox,
|
||||
FilterDropdown,
|
||||
FavoriteButton,
|
||||
ColorPicker,
|
||||
COLORS_12,
|
||||
COLORS_16,
|
||||
DEFAULT_COLOR,
|
||||
getRandomColor,
|
||||
} from './molecules';
|
||||
export type { SelectOption, FilterDropdownOption } from './molecules';
|
||||
|
||||
|
|
|
|||
47
packages/shared-ui/src/molecules/ColorPicker.constants.ts
Normal file
47
packages/shared-ui/src/molecules/ColorPicker.constants.ts
Normal 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)];
|
||||
}
|
||||
83
packages/shared-ui/src/molecules/ColorPicker.svelte
Normal file
83
packages/shared-ui/src/molecules/ColorPicker.svelte
Normal 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>
|
||||
69
packages/shared-ui/src/molecules/ColorPicker.test.ts
Normal file
69
packages/shared-ui/src/molecules/ColorPicker.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue