From ead4e71af536be241880711827d1817fe7b30a74 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 16:16:23 +0200 Subject: [PATCH] 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) --- packages/shared-stores/src/index.ts | 7 ++ .../shared-stores/src/toggle-field.test.ts | 49 ++++++++++++++ packages/shared-stores/src/toggle-field.ts | 42 ++++++++++++ packages/shared-ui/src/index.ts | 10 ++- .../src/molecules/FavoriteButton.svelte | 64 +++++++++++++++++++ .../src/molecules/FavoriteButton.test.ts | 47 ++++++++++++++ packages/shared-ui/src/molecules/index.ts | 1 + 7 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 packages/shared-stores/src/toggle-field.test.ts create mode 100644 packages/shared-stores/src/toggle-field.ts create mode 100644 packages/shared-ui/src/molecules/FavoriteButton.svelte create mode 100644 packages/shared-ui/src/molecules/FavoriteButton.test.ts diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index 8b5d8856a..5e06b5203 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -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'; diff --git a/packages/shared-stores/src/toggle-field.test.ts b/packages/shared-stores/src/toggle-field.test.ts new file mode 100644 index 000000000..e473bb4bc --- /dev/null +++ b/packages/shared-stores/src/toggle-field.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { toggleField } from './toggle-field'; + +function createMockTable(records: Record>) { + return { + get: vi.fn(async (id: string) => records[id] ?? null), + update: vi.fn(async (id: string, changes: Record) => { + 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; + 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); + }); +}); diff --git a/packages/shared-stores/src/toggle-field.ts b/packages/shared-stores/src/toggle-field.ts new file mode 100644 index 000000000..a6cb48883 --- /dev/null +++ b/packages/shared-stores/src/toggle-field.ts @@ -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( + table: Table, + id: string, + field: string +): Promise { + const record = await table.get(id); + if (!record) throw new Error(`Record ${id} not found`); + + const current = !!(record as Record)[field]; + const newValue = !current; + + await table.update(id, { + [field]: newValue, + updatedAt: new Date().toISOString(), + } as Partial); + + return newValue; +} diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index cf5faaae7..db423f8d8 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -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 diff --git a/packages/shared-ui/src/molecules/FavoriteButton.svelte b/packages/shared-ui/src/molecules/FavoriteButton.svelte new file mode 100644 index 000000000..400eeeb0e --- /dev/null +++ b/packages/shared-ui/src/molecules/FavoriteButton.svelte @@ -0,0 +1,64 @@ + + + diff --git a/packages/shared-ui/src/molecules/FavoriteButton.test.ts b/packages/shared-ui/src/molecules/FavoriteButton.test.ts new file mode 100644 index 000000000..e0e00c330 --- /dev/null +++ b/packages/shared-ui/src/molecules/FavoriteButton.test.ts @@ -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(); + }); +}); diff --git a/packages/shared-ui/src/molecules/index.ts b/packages/shared-ui/src/molecules/index.ts index 95d7a947b..d03849767 100644 --- a/packages/shared-ui/src/molecules/index.ts +++ b/packages/shared-ui/src/molecules/index.ts @@ -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';