feat: app-wide toast notifications

Adds a client-side toast store (info/success/error, auto-dismiss with a
sticky option) and a Toaster component mounted in the root layout, wired to
the reserved --z-toast token. Gives mutations a consistent way to surface
success/failure instead of inline-only text or silent catches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 21:36:34 +02:00
parent 0d9505ce9f
commit 7c3c9cf699
8 changed files with 273 additions and 3 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.114.2"
version = "0.115.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.114.2"
version = "0.115.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.114.2",
"version": "0.115.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { toast } from '$lib/toast.svelte';
import X from '@lucide/svelte/icons/x';
// Errors get an assertive `alert` role so screen readers interrupt;
// success/info use the polite `status` role.
const roleFor = (kind: string) => (kind === 'error' ? 'alert' : 'status');
</script>
<div class="toaster" data-testid="toaster">
{#each toast.toasts as t (t.id)}
<div class="toast {t.kind}" role={roleFor(t.kind)} data-testid="toast" data-kind={t.kind}>
<span class="message">{t.message}</span>
<button
type="button"
class="dismiss"
aria-label="Dismiss notification"
onclick={() => toast.dismiss(t.id)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
{/each}
</div>
<style>
.toaster {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--safe-bottom) + var(--space-4));
z-index: var(--z-toast);
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
/* Let clicks fall through the gaps; individual toasts opt back in. */
pointer-events: none;
}
/* Sit above the mobile bottom nav so a toast never hides behind it. */
@media (max-width: 640px) {
.toaster {
bottom: calc(var(--app-bottom-nav-h) + var(--safe-bottom) + var(--space-3));
}
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
max-width: 30rem;
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: var(--radius-md);
background: var(--surface-elevated);
color: var(--text);
box-shadow: var(--shadow-md);
font-size: var(--font-sm);
}
.toast.error {
border-color: var(--danger);
background: var(--danger-soft-bg);
}
.toast.success {
border-color: var(--success);
background: var(--success-soft-bg);
}
.toast.info {
border-color: var(--primary);
}
.message {
flex: 1;
min-width: 0;
}
.dismiss {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.dismiss:hover {
background: rgb(0 0 0 / 0.06);
color: var(--text);
}
</style>

View File

@@ -0,0 +1,38 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import Toaster from './Toaster.svelte';
import { toast } from '$lib/toast.svelte';
afterEach(() => {
cleanup();
toast.clear();
});
describe('Toaster', () => {
it('renders queued toasts with their message', async () => {
toast.info('Saved');
render(Toaster);
expect(await screen.findByText('Saved')).toBeTruthy();
});
it('gives errors an assertive alert role and others a status role', () => {
toast.error('Boom');
toast.success('Yay');
render(Toaster);
const toasts = screen.getAllByTestId('toast');
const error = toasts.find((t) => t.dataset.kind === 'error')!;
const success = toasts.find((t) => t.dataset.kind === 'success')!;
expect(error.getAttribute('role')).toBe('alert');
expect(success.getAttribute('role')).toBe('status');
});
it('removes a toast when its dismiss button is clicked', async () => {
toast.error('Dismiss me');
render(Toaster);
const btn = screen.getByLabelText('Dismiss notification');
btn.click();
await Promise.resolve();
expect(screen.queryByText('Dismiss me')).toBeNull();
expect(toast.toasts).toHaveLength(0);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { toast } from './toast.svelte';
afterEach(() => {
toast.clear();
vi.useRealTimers();
});
describe('toast store', () => {
it('shows a toast with the given message and kind, returning its id', () => {
const id = toast.show('hello', 'info');
expect(toast.toasts).toHaveLength(1);
expect(toast.toasts[0]).toMatchObject({ id, message: 'hello', kind: 'info' });
});
it('stacks multiple toasts in insertion order with distinct ids', () => {
const a = toast.info('first');
const b = toast.error('second');
expect(toast.toasts.map((t) => t.message)).toEqual(['first', 'second']);
expect(a).not.toBe(b);
});
it('exposes kind helpers', () => {
toast.error('boom');
toast.success('yay');
expect(toast.toasts.map((t) => t.kind)).toEqual(['error', 'success']);
});
it('dismisses a toast by id', () => {
const id = toast.info('bye');
toast.dismiss(id);
expect(toast.toasts).toHaveLength(0);
});
it('auto-dismisses after the timeout', () => {
vi.useFakeTimers();
toast.show('temp', 'info', 3000);
expect(toast.toasts).toHaveLength(1);
vi.advanceTimersByTime(3000);
expect(toast.toasts).toHaveLength(0);
});
it('does not auto-dismiss when the timeout is 0 (sticky)', () => {
vi.useFakeTimers();
toast.show('sticky', 'error', 0);
vi.advanceTimersByTime(60_000);
expect(toast.toasts).toHaveLength(1);
});
it('clear() removes everything and cancels pending timers', () => {
vi.useFakeTimers();
toast.info('a');
toast.info('b');
toast.clear();
expect(toast.toasts).toHaveLength(0);
// A previously-scheduled auto-dismiss must not fire against a new toast.
const id = toast.info('c', 0);
vi.advanceTimersByTime(60_000);
expect(toast.toasts).toEqual([{ id, message: 'c', kind: 'info' }]);
});
});

View File

@@ -0,0 +1,64 @@
// App-wide transient notifications (toasts).
//
// A single client-side store; the <Toaster /> in the root layout renders it.
// Mutated only from the browser (event handlers / catch blocks), so the
// module-level singleton can't leak across SSR requests — SSR renders an
// empty list and the client takes over after hydration.
export type ToastKind = 'info' | 'success' | 'error';
export type Toast = { id: number; kind: ToastKind; message: string };
const DEFAULT_TIMEOUT_MS = 5000;
// Errors linger longer — the user may need to read what failed.
const ERROR_TIMEOUT_MS = 8000;
class ToastStore {
toasts = $state<Toast[]>([]);
private nextId = 1;
private timers = new Map<number, ReturnType<typeof setTimeout>>();
/**
* Queue a toast. `timeoutMs = 0` makes it sticky (dismiss only via the
* close button / `dismiss`). Returns the id so callers can dismiss early.
*/
show(message: string, kind: ToastKind = 'info', timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
const id = this.nextId++;
this.toasts = [...this.toasts, { id, kind, message }];
if (timeoutMs > 0) {
this.timers.set(
id,
setTimeout(() => this.dismiss(id), timeoutMs)
);
}
return id;
}
error(message: string, timeoutMs: number = ERROR_TIMEOUT_MS): number {
return this.show(message, 'error', timeoutMs);
}
success(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
return this.show(message, 'success', timeoutMs);
}
info(message: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): number {
return this.show(message, 'info', timeoutMs);
}
dismiss(id: number): void {
const timer = this.timers.get(id);
if (timer) {
clearTimeout(timer);
this.timers.delete(id);
}
this.toasts = this.toasts.filter((t) => t.id !== id);
}
clear(): void {
this.timers.forEach((t) => clearTimeout(t));
this.timers.clear();
this.toasts = [];
}
}
export const toast = new ToastStore();

View File

@@ -10,6 +10,7 @@
import AppBar from '$lib/components/AppBar.svelte';
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
import NavProgress from '$lib/components/NavProgress.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle';
@@ -271,6 +272,8 @@
</div>
{/if}
<Toaster />
<style>
header {
padding: var(--space-3) var(--space-4);