diff --git a/src/lib/client/toast.svelte.ts b/src/lib/client/toast.svelte.ts new file mode 100644 index 0000000..f51adad --- /dev/null +++ b/src/lib/client/toast.svelte.ts @@ -0,0 +1,25 @@ +export type ToastKind = 'info' | 'error' | 'success'; +export type Toast = { id: number; kind: ToastKind; message: string }; + +class ToastStore { + toasts = $state([]); + private nextId = 1; + private readonly dismissMs = 3000; + + private push(kind: ToastKind, message: string): number { + const id = this.nextId++; + this.toasts = [...this.toasts, { id, kind, message }]; + setTimeout(() => this.dismiss(id), this.dismissMs); + return id; + } + + info(message: string): number { return this.push('info', message); } + error(message: string): number { return this.push('error', message); } + success(message: string): number { return this.push('success', message); } + + dismiss(id: number): void { + this.toasts = this.toasts.filter((t) => t.id !== id); + } +} + +export const toastStore = new ToastStore(); diff --git a/src/lib/components/Toast.svelte b/src/lib/components/Toast.svelte new file mode 100644 index 0000000..f3959f3 --- /dev/null +++ b/src/lib/components/Toast.svelte @@ -0,0 +1,55 @@ + + +
+ {#each toastStore.toasts as t (t.id)} +
+ {t.message} + +
+ {/each} +
+ + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 381841c..1e9d8f4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -12,6 +12,7 @@ import SearchLoader from '$lib/components/SearchLoader.svelte'; import SearchFilter from '$lib/components/SearchFilter.svelte'; import UpdateToast from '$lib/components/UpdateToast.svelte'; + import Toast from '$lib/components/Toast.svelte'; import type { SearchHit } from '$lib/server/recipes/search-local'; import type { WebHit } from '$lib/server/search/searxng'; @@ -211,6 +212,7 @@ }); + diff --git a/tests/unit/toast-store.test.ts b/tests/unit/toast-store.test.ts new file mode 100644 index 0000000..667ead1 --- /dev/null +++ b/tests/unit/toast-store.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('toast store', () => { + beforeEach(async () => { + vi.useFakeTimers(); + const mod = await import('../../src/lib/client/toast.svelte'); + mod.toastStore.toasts = []; + }); + + it('queues toasts with auto-dismiss', async () => { + const { toastStore } = await import('../../src/lib/client/toast.svelte'); + toastStore.info('Hello'); + expect(toastStore.toasts.length).toBe(1); + expect(toastStore.toasts[0].message).toBe('Hello'); + expect(toastStore.toasts[0].kind).toBe('info'); + + vi.advanceTimersByTime(3000); + expect(toastStore.toasts.length).toBe(0); + }); + + it('supports error kind and manual dismiss', async () => { + const { toastStore } = await import('../../src/lib/client/toast.svelte'); + const id = toastStore.error('Boom'); + expect(toastStore.toasts[0].kind).toBe('error'); + toastStore.dismiss(id); + expect(toastStore.toasts.length).toBe(0); + }); + + it('allows multiple concurrent toasts', async () => { + const { toastStore } = await import('../../src/lib/client/toast.svelte'); + toastStore.info('A'); + toastStore.info('B'); + expect(toastStore.toasts.length).toBe(2); + }); +});