From 0c66bd677ed57efbd98d9399e513f7fea292714b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:21:14 +0200 Subject: [PATCH] feat(pwa): Toast-Store + Renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast-Queue als $state-Store mit Auto-Dismiss nach 3 s und manuellem dismiss(id). Drei Kinds: info/error/success (Farbe). Renderer als im Root-Layout, fix-positioniert oben mittig. Wird vom Offline-Check der Schreib-Aktionen genutzt und später auch für Sync-Abschluss-Meldungen. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/client/toast.svelte.ts | 25 +++++++++++++++ src/lib/components/Toast.svelte | 55 +++++++++++++++++++++++++++++++++ src/routes/+layout.svelte | 2 ++ tests/unit/toast-store.test.ts | 35 +++++++++++++++++++++ 4 files changed, 117 insertions(+) create mode 100644 src/lib/client/toast.svelte.ts create mode 100644 src/lib/components/Toast.svelte create mode 100644 tests/unit/toast-store.test.ts 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); + }); +});