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