refactor(web): pushToast store + ToastHost; 8 sites migrated (W3)

This commit is contained in:
2026-05-08 05:45:34 -04:00
parent 970752a153
commit 617477b702
12 changed files with 229 additions and 223 deletions
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest';
// Each test loads a fresh module so module-scope state (_toast, _timer,
// _next) doesn't leak between cases. vi.useFakeTimers() controls the
// auto-clear timeout deterministically; we never depend on real wall time.
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('pushToast / clearToast', () => {
test('pushToast sets value with default variant info', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('hello');
expect(mod.toast.value).not.toBeNull();
expect(mod.toast.value?.message).toBe('hello');
expect(mod.toast.value?.variant).toBe('info');
});
test('pushToast respects explicit variant error', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('boom', 'error');
expect(mod.toast.value?.variant).toBe('error');
expect(mod.toast.value?.message).toBe('boom');
});
test('pushToast auto-clears after default 5000ms', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('temporary');
expect(mod.toast.value).not.toBeNull();
vi.advanceTimersByTime(4999);
expect(mod.toast.value).not.toBeNull();
vi.advanceTimersByTime(1);
expect(mod.toast.value).toBeNull();
});
test('pushToast respects custom duration', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('quick', 'info', 1000);
vi.advanceTimersByTime(999);
expect(mod.toast.value).not.toBeNull();
vi.advanceTimersByTime(1);
expect(mod.toast.value).toBeNull();
});
test('a second push replaces the first and only the second timer fires', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('first');
vi.advanceTimersByTime(2000);
mod.pushToast('second');
expect(mod.toast.value?.message).toBe('second');
// 4000ms after the second push, only 6000ms have elapsed total —
// the first push's original 5000ms timer must NOT have fired
// (which would have cleared the second toast).
vi.advanceTimersByTime(4000);
expect(mod.toast.value?.message).toBe('second');
// Now run out the second's clock.
vi.advanceTimersByTime(1000);
expect(mod.toast.value).toBeNull();
});
test('clearToast resets value and timer', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('present');
expect(mod.toast.value).not.toBeNull();
mod.clearToast();
expect(mod.toast.value).toBeNull();
// Timer was cleared — advancing past the original duration must not
// re-clear or otherwise touch state in a way that would surface here.
vi.advanceTimersByTime(10_000);
expect(mod.toast.value).toBeNull();
});
test('each push increments the id (drives {#key} re-mount)', async () => {
const mod = await import('./toast.svelte');
mod.pushToast('a');
const firstId = mod.toast.value?.id;
mod.pushToast('b');
const secondId = mod.toast.value?.id;
mod.pushToast('c');
const thirdId = mod.toast.value?.id;
expect(firstId).toBeDefined();
expect(secondId).toBeDefined();
expect(thirdId).toBeDefined();
expect(secondId!).toBeGreaterThan(firstId!);
expect(thirdId!).toBeGreaterThan(secondId!);
});
});
+43
View File
@@ -0,0 +1,43 @@
// Shared toast store. Pages call `pushToast(message, variant?)`; a single
// <ToastHost /> mounted in +layout.svelte renders the result. Replacing a
// live toast cancels its timer so the new message gets the full duration,
// and bumps `id` so the host's {#key} block re-mounts the node — important
// for screen readers, which won't re-announce text changes inside an
// already-live region.
type ToastVariant = 'info' | 'error';
interface Toast {
id: number;
message: string;
variant: ToastVariant;
}
let _toast = $state<Toast | null>(null);
let _timer: ReturnType<typeof setTimeout> | null = null;
let _next = 0;
export const toast = {
get value(): Toast | null {
return _toast;
}
};
export function pushToast(
message: string,
variant: ToastVariant = 'info',
durationMs = 5000
): void {
if (_timer) clearTimeout(_timer);
_toast = { id: ++_next, message, variant };
_timer = setTimeout(() => {
_toast = null;
_timer = null;
}, durationMs);
}
export function clearToast(): void {
if (_timer) clearTimeout(_timer);
_timer = null;
_toast = null;
}