feat(pwa): Toast-Store + Renderer
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m19s
Toast-Queue als $state-Store mit Auto-Dismiss nach 3 s und manuellem dismiss(id). Drei Kinds: info/error/success (Farbe). Renderer als <Toast /> 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) <noreply@anthropic.com>
This commit is contained in:
25
src/lib/client/toast.svelte.ts
Normal file
25
src/lib/client/toast.svelte.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type ToastKind = 'info' | 'error' | 'success';
|
||||
export type Toast = { id: number; kind: ToastKind; message: string };
|
||||
|
||||
class ToastStore {
|
||||
toasts = $state<Toast[]>([]);
|
||||
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();
|
||||
55
src/lib/components/Toast.svelte
Normal file
55
src/lib/components/Toast.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { toastStore } from '$lib/client/toast.svelte';
|
||||
</script>
|
||||
|
||||
<div class="toasts" aria-live="polite" aria-atomic="true">
|
||||
{#each toastStore.toasts as t (t.id)}
|
||||
<div class="toast" class:error={t.kind === 'error'} class:success={t.kind === 'success'}>
|
||||
<span class="msg">{t.message}</span>
|
||||
<button class="close" aria-label="Schließen" onclick={() => toastStore.dismiss(t.id)}>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
|
||||
max-width: min(92vw, 480px);
|
||||
}
|
||||
.toast.error { background: #c53030; }
|
||||
.toast.success { background: #2b6a3d; }
|
||||
.close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.close:hover { opacity: 1; }
|
||||
</style>
|
||||
@@ -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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
<UpdateToast />
|
||||
|
||||
|
||||
35
tests/unit/toast-store.test.ts
Normal file
35
tests/unit/toast-store.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user