Files
electric-cherry/src/lib/ui/toast.ts
T
josh 8ac420fe61 Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
2026-09-19 21:34:34 -04:00

68 lines
2.7 KiB
TypeScript

import { writable } from 'svelte/store';
// Small global toast queue.
//
// Any part of the UI can drop a message and it appears in the corner for a
// couple of seconds, then quietly dismisses. Replaces the ad-hoc "flash"
// strings scattered across pages when the message doesn't need to live inline
// with a form.
export type ToastVariant = 'info' | 'success' | 'warning' | 'danger';
export interface Toast {
id: number;
message: string;
variant: ToastVariant;
ttl: number;
href?: string;
}
export const toasts = writable<Toast[]>([]);
export interface Notice extends Toast { createdAt: string; read: boolean; }
export const notificationHistory = writable<Notice[]>([]);
const historyKey = 'electric-cherry:notifications';
export function startNotificationHistory(): () => void {
try {
const saved: unknown = JSON.parse(sessionStorage.getItem(historyKey) ?? '[]');
if (Array.isArray(saved)) {
const valid = saved.filter((item): item is Notice => item && typeof item.id === 'number' && Number.isSafeInteger(item.id)
&& typeof item.message === 'string' && ['info', 'success', 'warning', 'danger'].includes(item.variant)
&& typeof item.createdAt === 'string' && Number.isFinite(Date.parse(item.createdAt))
&& typeof item.read === 'boolean' && (!item.href || (typeof item.href === 'string' && item.href.startsWith('/') && !item.href.startsWith('//')))).slice(0, 50);
notificationHistory.set(valid);
nextId = Math.max(nextId, ...valid.map(item => item.id + 1));
}
} catch { /* Notifications still work when browser storage is unavailable. */ }
return notificationHistory.subscribe(items => { try { sessionStorage.setItem(historyKey, JSON.stringify(items)); } catch { /* Optional persistence. */ } });
}
export function markNotificationsRead() { notificationHistory.update(items => items.map(item => ({ ...item, read: true }))); }
export function clearNotifications() { notificationHistory.set([]); }
let nextId = 1;
export function toast(
message: string,
opts: { variant?: ToastVariant; ttl?: number; href?: string } = {}
): void {
const t: Toast = {
id: nextId++,
message,
variant: opts.variant ?? 'info',
ttl: opts.ttl ?? 3500,
href: opts.href
};
toasts.update((arr) => [...arr, t]);
if (t.href || t.ttl === 0 || t.variant !== 'info') {
notificationHistory.update(items => [{ ...t, createdAt: new Date().toISOString(), read: false }, ...items].slice(0, 50));
}
if (t.ttl > 0) {
setTimeout(() => dismiss(t.id), t.ttl);
}
}
export function dismiss(id: number): void {
toasts.update((arr) => arr.filter((t) => t.id !== id));
}