web/src/components/toast.tsx
5.9 KB · sha256:e184cc6ebb77f26c5eca34ccd1276eb1e05669318bf5e9240bb966eafb4527ec
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { CheckCircle2, Loader2, X, XCircle } from "lucide-react";
import { cn } from "../lib/cn";
export type ToastVariant = "info" | "success" | "error" | "loading";
export interface Toast {
id: string;
variant: ToastVariant;
title: string;
description?: string;
/** Auto-dismiss after this many ms. 0 = sticky. Default 4000 (0 for loading). */
duration?: number;
}
interface ToastApi {
toast: (t: Omit<Toast, "id">) => string;
/** Replace or create. Pass an id to update an existing toast. */
upsert: (t: Toast) => void;
dismiss: (id: string) => void;
success: (title: string, opts?: Partial<Toast>) => string;
error: (title: string, opts?: Partial<Toast>) => string;
loading: (title: string, opts?: Partial<Toast>) => string;
}
const Ctx = createContext<ToastApi | null>(null);
let counter = 0;
function newId() {
counter += 1;
return `t${Date.now()}_${counter}`;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<Toast[]>([]);
const timers = useRef<Map<string, number>>(new Map());
const dismiss = useCallback((id: string) => {
const handle = timers.current.get(id);
if (handle) {
clearTimeout(handle);
timers.current.delete(id);
}
setItems((prev) => prev.filter((t) => t.id !== id));
}, []);
const schedule = useCallback(
(t: Toast) => {
const dur =
t.duration ?? (t.variant === "loading" ? 0 : t.variant === "error" ? 6000 : 4000);
if (dur <= 0) return;
const handle = window.setTimeout(() => dismiss(t.id), dur);
timers.current.set(t.id, handle);
},
[dismiss],
);
const upsert = useCallback(
(t: Toast) => {
// If updating, clear the prior timer so the new variant's lifetime applies.
const prior = timers.current.get(t.id);
if (prior) {
clearTimeout(prior);
timers.current.delete(t.id);
}
setItems((prev) => {
const idx = prev.findIndex((x) => x.id === t.id);
if (idx === -1) return [...prev, t];
const next = prev.slice();
next[idx] = t;
return next;
});
schedule(t);
},
[schedule],
);
const toast = useCallback(
(t: Omit<Toast, "id">) => {
const id = newId();
upsert({ id, ...t });
return id;
},
[upsert],
);
const success = useCallback(
(title: string, opts: Partial<Toast> = {}) =>
opts.id
? (upsert({ variant: "success", title, ...opts, id: opts.id }), opts.id)
: toast({ variant: "success", title, ...opts }),
[toast, upsert],
);
const error = useCallback(
(title: string, opts: Partial<Toast> = {}) =>
opts.id
? (upsert({ variant: "error", title, ...opts, id: opts.id }), opts.id)
: toast({ variant: "error", title, ...opts }),
[toast, upsert],
);
const loading = useCallback(
(title: string, opts: Partial<Toast> = {}) =>
toast({ variant: "loading", title, ...opts }),
[toast],
);
useEffect(() => {
return () => {
timers.current.forEach((h) => clearTimeout(h));
timers.current.clear();
};
}, []);
return (
<Ctx.Provider value={{ toast, upsert, dismiss, success, error, loading }}>
{children}
<ToastViewport items={items} dismiss={dismiss} />
</Ctx.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useToast requires <ToastProvider>");
return ctx;
}
// Imperative escape hatch so non-component code (the global mutation
// onError in __root.tsx) can fire toasts without lugging the hook through.
let externalApi: ToastApi | null = null;
export function getToastApi(): ToastApi | null {
return externalApi;
}
export function _bindToastApi(api: ToastApi | null): void {
externalApi = api;
}
function ToastViewport({
items,
dismiss,
}: {
items: Toast[];
dismiss: (id: string) => void;
}) {
return (
<div
aria-live="polite"
className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-[min(380px,calc(100vw-2rem))] flex-col gap-2"
>
{items.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
))}
</div>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
return (
<div
role="status"
className={cn(
"pointer-events-auto bg-surface border-border flex items-start gap-3 rounded-xl border p-3 shadow-lg ring-1 ring-black/5 dark:ring-white/5",
"animate-in slide-in-from-right-4 fade-in",
)}
>
<ToastIcon variant={toast.variant} />
<div className="min-w-0 flex-1">
<div className="text-fg text-sm font-medium">{toast.title}</div>
{toast.description && (
<div className="text-fg-muted mt-0.5 text-xs">{toast.description}</div>
)}
</div>
{toast.variant !== "loading" && (
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss"
className="text-fg-subtle hover:text-fg shrink-0 rounded-md p-0.5"
>
<X size={14} />
</button>
)}
</div>
);
}
function ToastIcon({ variant }: { variant: ToastVariant }) {
if (variant === "loading") {
return (
<Loader2
size={18}
className="text-brand-600 dark:text-brand-300 mt-0.5 shrink-0 animate-spin"
/>
);
}
if (variant === "success") {
return (
<CheckCircle2
size={18}
className="mt-0.5 shrink-0 text-emerald-600 dark:text-emerald-300"
/>
);
}
if (variant === "error") {
return (
<XCircle
size={18}
className="mt-0.5 shrink-0 text-red-600 dark:text-red-300"
/>
);
}
return (
<div className="bg-brand-500 mt-1.5 h-2 w-2 shrink-0 rounded-full" />
);
}