feat(shared-ui,shared-stores): add FavoriteButton component and toggleField utility

FavoriteButton: reusable heart/star/pin toggle with filled/outline states,
accessible labels, configurable colors. toggleField: generic boolean field
toggle for Dexie records (isFavorite, isPinned, etc.) with timestamp.
Includes 11 tests (6 FavoriteButton + 5 toggleField).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 16:16:23 +02:00
parent d49a3d727d
commit ead4e71af5
7 changed files with 219 additions and 1 deletions

View file

@ -45,3 +45,10 @@ export {
type LocalTagGroup,
} from './tags-local.svelte';
export { createTagLinkOps, type TagLinkOps, type TagLinkOpsConfig } from './tag-links';
export { toggleField } from './toggle-field';
export {
createGuestMode,
type GuestMode,
type GuestModeOptions,
type GuestModeNotification,
} from './guest-mode.svelte';

View file

@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { toggleField } from './toggle-field';
function createMockTable(records: Record<string, Record<string, unknown>>) {
return {
get: vi.fn(async (id: string) => records[id] ?? null),
update: vi.fn(async (id: string, changes: Record<string, unknown>) => {
if (records[id]) Object.assign(records[id], changes);
return 1;
}),
};
}
describe('toggleField', () => {
it('toggles false to true', async () => {
const table = createMockTable({ '1': { id: '1', isFavorite: false } });
const result = await toggleField(table as never, '1', 'isFavorite');
expect(result).toBe(true);
expect(table.update).toHaveBeenCalledWith('1', expect.objectContaining({ isFavorite: true }));
});
it('toggles true to false', async () => {
const table = createMockTable({ '1': { id: '1', isFavorite: true } });
const result = await toggleField(table as never, '1', 'isFavorite');
expect(result).toBe(false);
expect(table.update).toHaveBeenCalledWith('1', expect.objectContaining({ isFavorite: false }));
});
it('sets updatedAt timestamp', async () => {
const table = createMockTable({ '1': { id: '1', isPinned: false } });
await toggleField(table as never, '1', 'isPinned');
const call = table.update.mock.calls[0][1] as Record<string, unknown>;
expect(call.updatedAt).toBeTruthy();
expect(typeof call.updatedAt).toBe('string');
});
it('throws if record not found', async () => {
const table = createMockTable({});
await expect(toggleField(table as never, 'missing', 'isFavorite')).rejects.toThrow(
'Record missing not found'
);
});
it('treats undefined as false', async () => {
const table = createMockTable({ '1': { id: '1' } });
const result = await toggleField(table as never, '1', 'isFavorite');
expect(result).toBe(true);
});
});

View file

@ -0,0 +1,42 @@
/**
* Generic boolean field toggle for Dexie tables.
*
* Standardizes the common pattern of toggling a boolean field (isFavorite, isPinned, etc.)
* on a record in IndexedDB. Reads current value, flips it, updates with timestamp.
*
* @example
* ```typescript
* import { toggleField } from '@manacore/shared-stores';
* import { db } from '$lib/data/database';
*
* // In your store:
* async function toggleFavorite(id: string) {
* return toggleField(db.table('contacts'), id, 'isFavorite');
* }
* ```
*/
import type { Table } from 'dexie';
/**
* Toggle a boolean field on a Dexie record.
* @returns The new value of the field after toggling.
*/
export async function toggleField<T>(
table: Table<T, string>,
id: string,
field: string
): Promise<boolean> {
const record = await table.get(id);
if (!record) throw new Error(`Record ${id} not found`);
const current = !!(record as Record<string, unknown>)[field];
const newValue = !current;
await table.update(id, {
[field]: newValue,
updatedAt: new Date().toISOString(),
} as Partial<T>);
return newValue;
}

View file

@ -2,7 +2,15 @@
export { Text, Button, Badge, Card } from './atoms';
// Molecules
export { Toggle, Input, Select, Textarea, Checkbox, FilterDropdown } from './molecules';
export {
Toggle,
Input,
Select,
Textarea,
Checkbox,
FilterDropdown,
FavoriteButton,
} from './molecules';
export type { SelectOption, FilterDropdownOption } from './molecules';
// Stats

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { Heart, Star, PushPin } from '@manacore/shared-icons';
/**
* Reusable favorite/pin toggle button.
* Renders a heart, star, or pin icon that toggles between filled and outline.
*/
interface Props {
active: boolean;
onclick: () => void;
/** Icon variant */
variant?: 'heart' | 'star' | 'pin';
/** Icon size in pixels */
size?: number;
/** Active color (CSS color) */
activeColor?: string;
/** Inactive color (CSS color) */
inactiveColor?: string;
/** Extra CSS classes on the button */
class?: string;
/** Accessible label */
label?: string;
}
let {
active,
onclick,
variant = 'heart',
size = 18,
activeColor = variant === 'pin' ? 'var(--color-primary, #3b82f6)' : '#ef4444',
inactiveColor = 'currentColor',
class: className = '',
label,
}: Props = $props();
const defaultLabel = $derived(
variant === 'pin'
? active
? 'Loslösen'
: 'Anpinnen'
: active
? 'Favorit entfernen'
: 'Favorit'
);
const icons = { heart: Heart, star: Star, pin: PushPin };
const Icon = $derived(icons[variant]);
</script>
<button
type="button"
{onclick}
class="inline-flex items-center justify-center rounded-md p-1 transition-colors hover:bg-black/5 dark:hover:bg-white/10 {className}"
aria-label={label ?? defaultLabel}
title={label ?? defaultLabel}
>
<Icon
{size}
weight={active ? 'fill' : 'regular'}
class="transition-colors"
style="color: {active ? activeColor : inactiveColor}"
/>
</button>

View file

@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FavoriteButton from './FavoriteButton.svelte';
describe('FavoriteButton', () => {
it('renders with heart icon by default', () => {
const { container } = render(FavoriteButton, {
props: { active: false, onclick: vi.fn() },
});
expect(container.querySelector('button')).toBeInTheDocument();
});
it('has correct aria-label when inactive', () => {
render(FavoriteButton, {
props: { active: false, onclick: vi.fn() },
});
expect(screen.getByRole('button', { name: 'Favorit' })).toBeInTheDocument();
});
it('has correct aria-label when active', () => {
render(FavoriteButton, {
props: { active: true, onclick: vi.fn() },
});
expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
});
it('calls onclick when clicked', async () => {
const onclick = vi.fn();
render(FavoriteButton, { props: { active: false, onclick } });
await fireEvent.click(screen.getByRole('button'));
expect(onclick).toHaveBeenCalledOnce();
});
it('uses pin labels for pin variant', () => {
render(FavoriteButton, {
props: { active: false, onclick: vi.fn(), variant: 'pin' },
});
expect(screen.getByRole('button', { name: 'Anpinnen' })).toBeInTheDocument();
});
it('uses custom label when provided', () => {
render(FavoriteButton, {
props: { active: false, onclick: vi.fn(), label: 'Custom' },
});
expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument();
});
});

View file

@ -4,6 +4,7 @@ export { default as Select } from './Select.svelte';
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 type { SelectOption } from './Select.types';
export type { FilterDropdownOption } from './FilterDropdown.types';