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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.114.2"
|
version = "0.115.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.114.2"
|
version = "0.115.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.114.2",
|
"version": "0.115.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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 AppBar from '$lib/components/AppBar.svelte';
|
||||||
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
|
||||||
import NavProgress from '$lib/components/NavProgress.svelte';
|
import NavProgress from '$lib/components/NavProgress.svelte';
|
||||||
|
import Toaster from '$lib/components/Toaster.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import UserCircle from '@lucide/svelte/icons/user-circle';
|
import UserCircle from '@lucide/svelte/icons/user-circle';
|
||||||
@@ -271,6 +272,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
header {
|
header {
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
|
|||||||
Reference in New Issue
Block a user