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:
Josh Rogers
2026-05-07 22:08:52 -05:00
parent 6780fb366e
commit 88c24b03ca
6 changed files with 256 additions and 14 deletions
+58
View File
@@ -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>
+75
View File
@@ -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
View File
@@ -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);
+2 -1
View File
@@ -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);
});
});