Files
sencho/frontend/src/components/ui/toast-store.ts
T
Anso aa181aa932 feat(ui): redesign toast with accent rail and action slot (#693)
* feat(ui): redesign toast with accent rail and action slot

Align toast with the overlay-surface language from the user menu and
notification panel: left accent rail, tracked-mono kicker above the
message, lucide icons at 1.5px stroke, status-tinted progress bar.

Extend the toast store with an optional options object so callers can
pass an inline action button and override the default duration. Guard
removeToast against no-op dismissals so subscribers are not notified
when nothing changed, and cap the buffered queue at 20 to prevent
unbounded growth under spam.

* fix(ui): avoid impure Date.now in toast ref initializer

useRef(Date.now()) re-evaluates the impure call on every render and
trips the react-hooks/purity lint rule, even though only the first
render's value is retained. Initialize to 0 since the auto-dismiss
effect writes startRef.current = Date.now() before any read path
consumes it.
2026-04-19 11:54:36 -04:00

83 lines
1.9 KiB
TypeScript

import { useSyncExternalStore } from 'react';
export type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
export interface ToastAction {
label: string;
onClick: () => void;
}
export interface ToastOptions {
action?: ToastAction;
duration?: number;
}
export interface Toast {
id: string;
type: ToastType;
message: string;
createdAt: number;
action?: ToastAction;
duration?: number;
}
const MAX_BUFFERED = 20;
let toasts: Toast[] = [];
const listeners: Set<() => void> = new Set();
let idCounter = 0;
function notify() {
listeners.forEach((fn) => fn());
}
function addToast(type: ToastType, message: string, opts?: ToastOptions): string {
const id = `toast-${++idCounter}-${Date.now()}`;
const next: Toast[] = [
...toasts,
{
id,
type,
message,
createdAt: Date.now(),
action: opts?.action,
duration: opts?.duration,
},
];
toasts = next.length > MAX_BUFFERED ? next.slice(-MAX_BUFFERED) : next;
notify();
return id;
}
export function removeToast(id: string) {
const next = toasts.filter((t) => t.id !== id);
if (next.length === toasts.length) return;
toasts = next;
notify();
}
function subscribe(callback: () => void) {
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}
function getSnapshot() {
return toasts;
}
export function useToasts() {
return useSyncExternalStore(subscribe, getSnapshot);
}
export const toast = {
success: (message: string, opts?: ToastOptions) => addToast('success', message, opts),
error: (message: string, opts?: ToastOptions) => addToast('error', message, opts),
warning: (message: string, opts?: ToastOptions) => addToast('warning', message, opts),
info: (message: string, opts?: ToastOptions) => addToast('info', message, opts),
loading: (message: string, opts?: Omit<ToastOptions, 'duration'>) =>
addToast('loading', message, opts),
dismiss: (id: string) => removeToast(id),
};