mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
d49a3d727d
commit
ead4e71af5
7 changed files with 219 additions and 1 deletions
|
|
@ -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';
|
||||
|
|
|
|||
49
packages/shared-stores/src/toggle-field.test.ts
Normal file
49
packages/shared-stores/src/toggle-field.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
packages/shared-stores/src/toggle-field.ts
Normal file
42
packages/shared-stores/src/toggle-field.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
64
packages/shared-ui/src/molecules/FavoriteButton.svelte
Normal file
64
packages/shared-ui/src/molecules/FavoriteButton.svelte
Normal 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>
|
||||
47
packages/shared-ui/src/molecules/FavoriteButton.test.ts
Normal file
47
packages/shared-ui/src/molecules/FavoriteButton.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue