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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.114.2"
|
||||
version = "0.115.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.114.2"
|
||||
version = "0.115.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.114.2",
|
||||
"version": "0.115.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
104
frontend/src/lib/components/Toaster.svelte
Normal file
104
frontend/src/lib/components/Toaster.svelte
Normal 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>
|
||||
38
frontend/src/lib/components/Toaster.svelte.test.ts
Normal file
38
frontend/src/lib/components/Toaster.svelte.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
frontend/src/lib/toast.svelte.test.ts
Normal file
61
frontend/src/lib/toast.svelte.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
64
frontend/src/lib/toast.svelte.ts
Normal file
64
frontend/src/lib/toast.svelte.ts
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user