Add reusable toast notification system
Introduces a runes-based toast module (`$lib/toast.svelte.ts`) with success/info/warning/error variants, auto-dismiss, optional action button, plus a `Toaster` viewport mounted in the root layout. Migrates the lone `alert(e.message)` call site (stores delete) to `toast.error()`. Backlog updated to remove the now-completed foundational item and rewrite dependent items to reference the shipped API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { getToasts, dismissToast, type ToastVariant } from '$lib/toast.svelte';
|
||||
|
||||
const toasts = $derived(getToasts());
|
||||
|
||||
const variantClasses: Record<ToastVariant, string> = {
|
||||
success: 'bg-primary text-white',
|
||||
info: 'bg-gray-800 text-white',
|
||||
warning: 'bg-amber-500 text-white',
|
||||
error: 'bg-danger text-white'
|
||||
};
|
||||
|
||||
const roleFor = (variant: ToastVariant) =>
|
||||
variant === 'error' || variant === 'warning' ? 'alert' : 'status';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex flex-col items-center gap-2 px-4 toaster-safe"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{#each toasts as t (t.id)}
|
||||
<div
|
||||
role={roleFor(t.variant)}
|
||||
class="pointer-events-auto flex w-full max-w-md items-center gap-3 rounded-lg px-4 py-3 shadow-lg {variantClasses[
|
||||
t.variant
|
||||
]}"
|
||||
>
|
||||
<span class="flex-1 text-sm font-medium">{t.message}</span>
|
||||
{#if t.action}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
t.action!.onClick();
|
||||
dismissToast(t.id);
|
||||
}}
|
||||
class="rounded px-2 py-1 text-sm font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
onclick={() => dismissToast(t.id)}
|
||||
class="text-lg leading-none opacity-80 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toaster-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
variant?: ToastVariant;
|
||||
/** Auto-dismiss after this many ms. Pass 0 to keep the toast until dismissed manually. */
|
||||
duration?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION_MS = 4000;
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
let nextId = 0;
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function getToasts(): Toast[] {
|
||||
return toasts;
|
||||
}
|
||||
|
||||
export function showToast(message: string, options: ToastOptions = {}): number {
|
||||
const id = ++nextId;
|
||||
toasts.push({
|
||||
id,
|
||||
message,
|
||||
variant: options.variant ?? 'info',
|
||||
action: options.action
|
||||
});
|
||||
|
||||
const duration = options.duration ?? DEFAULT_DURATION_MS;
|
||||
if (duration > 0) {
|
||||
const handle = setTimeout(() => dismissToast(id), duration);
|
||||
timers.set(id, handle);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
const handle = timers.get(id);
|
||||
if (handle !== undefined) {
|
||||
clearTimeout(handle);
|
||||
timers.delete(id);
|
||||
}
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
export function clearAllToasts(): void {
|
||||
for (const handle of timers.values()) clearTimeout(handle);
|
||||
timers.clear();
|
||||
toasts = [];
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'success' }),
|
||||
info: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'info' }),
|
||||
warning: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'warning' }),
|
||||
error: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'error' }),
|
||||
dismiss: dismissToast
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { clearToken } from '$lib/api';
|
||||
import { initAuth, isLoggedIn } from '$lib/auth.svelte';
|
||||
import Toaster from '$lib/Toaster.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -60,6 +61,8 @@
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<style>
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
|
||||
interface Store {
|
||||
id: number;
|
||||
@@ -50,7 +51,7 @@
|
||||
await api(`/api/stores/${id}`, { method: 'DELETE' });
|
||||
stores = stores.filter((s) => s.id !== id);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
clearAllToasts,
|
||||
dismissToast,
|
||||
getToasts,
|
||||
showToast,
|
||||
toast
|
||||
} from '$lib/toast.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllToasts();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('showToast()', () => {
|
||||
it('adds a toast with default info variant', () => {
|
||||
const id = showToast('hello');
|
||||
|
||||
const toasts = getToasts();
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0]).toMatchObject({ id, message: 'hello', variant: 'info' });
|
||||
});
|
||||
|
||||
it('returns unique ids per call', () => {
|
||||
const a = showToast('a');
|
||||
const b = showToast('b');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('auto-dismisses after the default duration', () => {
|
||||
showToast('temporary');
|
||||
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('respects a custom duration', () => {
|
||||
showToast('quick', { duration: 1000 });
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not auto-dismiss when duration is 0', () => {
|
||||
showToast('persistent', { duration: 0 });
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('attaches an action handler', () => {
|
||||
const onClick = vi.fn();
|
||||
showToast('undo me', { action: { label: 'Undo', onClick } });
|
||||
|
||||
const t = getToasts()[0];
|
||||
expect(t.action?.label).toBe('Undo');
|
||||
t.action?.onClick();
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissToast()', () => {
|
||||
it('removes the matching toast and cancels its timer', () => {
|
||||
const id = showToast('bye', { duration: 1000 });
|
||||
|
||||
dismissToast(id);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
|
||||
// If the timer were still active and tried to dismiss again, no harm — just verify state stable.
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op for an unknown id', () => {
|
||||
showToast('still here');
|
||||
dismissToast(9999);
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast.* helpers', () => {
|
||||
it.each([
|
||||
['success', 'success'],
|
||||
['info', 'info'],
|
||||
['warning', 'warning'],
|
||||
['error', 'error']
|
||||
] as const)('toast.%s sets variant to %s', (method, variant) => {
|
||||
toast[method]('msg');
|
||||
expect(getToasts()[0].variant).toBe(variant);
|
||||
});
|
||||
|
||||
it('toast.dismiss removes the toast', () => {
|
||||
const id = toast.error('oops', { duration: 0 });
|
||||
toast.dismiss(id);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllToasts()', () => {
|
||||
it('removes every toast', () => {
|
||||
showToast('a');
|
||||
showToast('b');
|
||||
showToast('c');
|
||||
|
||||
clearAllToasts();
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user