Workflow-App/Workflow/components/Toast.tsx

174 lines
5.2 KiB
TypeScript
Raw Normal View History

// components/Toast.tsx
// Tiny in-app toast system. Each toast is a row in a vertical stack
// anchored to the bottom-right. Auto-dismiss after 5s (configurable).
// Includes a 3-second coalesce window — bursty events (e.g. 20 tasks
// flipping to done at once) only show one toast.
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
export type ToastKind = "info" | "success" | "error" | "warn";
export interface Toast {
id: string;
kind: ToastKind;
title: string;
body?: string;
/** Optional click target — e.g. open a task detail panel. */
onClick?: () => void;
/** Auto-dismiss in ms. 0 = sticky. Default 5000. */
duration?: number;
}
interface ToastContextValue {
toast: (t: Omit<Toast, "id">) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const COALESCE_MS = 3000;
const MAX_VISIBLE = 4;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const lastFiredRef = useRef<{ key: string; at: number } | null>(null);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback((t: Omit<Toast, "id">) => {
const key = `${t.kind}|${t.title}|${t.body ?? ""}`;
const now = Date.now();
if (lastFiredRef.current && lastFiredRef.current.key === key && now - lastFiredRef.current.at < COALESCE_MS) {
return;
}
lastFiredRef.current = { key, at: now };
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => {
const next = [...prev, { ...t, id }];
return next.slice(-MAX_VISIBLE);
});
}, []);
return (
<ToastContext.Provider value={{ toast, dismiss }}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
// Safe no-op so non-provider contexts (e.g. tests) don't crash.
return { toast: () => {}, dismiss: () => {} };
}
return ctx;
}
function ToastViewport({
toasts,
onDismiss,
}: {
toasts: Toast[];
onDismiss: (id: string) => void;
}) {
return (
<div
aria-live="polite"
aria-atomic="true"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-[min(360px,calc(100vw-2rem))]"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => onDismiss(t.id)} />
))}
</div>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const duration = toast.duration ?? 5000;
useEffect(() => {
if (duration <= 0) return;
const t = setTimeout(onDismiss, duration);
return () => clearTimeout(t);
}, [duration, onDismiss]);
const palette = {
info: "border-blue-300 bg-blue-50 text-blue-900",
success: "border-green-300 bg-green-50 text-green-900",
error: "border-red-300 bg-red-50 text-red-900",
warn: "border-amber-300 bg-amber-50 text-amber-900",
}[toast.kind];
const icon = {
info: "ℹ",
success: "✅",
error: "❌",
warn: "⚠",
}[toast.kind];
const inner = (
<div
role={toast.onClick ? "button" : undefined}
onClick={toast.onClick}
className={`rounded-md border ${palette} px-4 py-3 shadow-sm text-sm flex gap-3 items-start ${toast.onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""}`}
>
<span className="text-base leading-tight">{icon}</span>
<div className="flex-1 min-w-0">
<p className="font-semibold">{toast.title}</p>
{toast.body ? <p className="text-xs mt-0.5 opacity-80">{toast.body}</p> : null}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
aria-label="Dismiss"
className="text-xs opacity-60 hover:opacity-100"
>
✕
</button>
</div>
);
return inner;
}
// ─────────────────────────────────────────────────────────────────
// Browser notifications — request permission on first prompt.
// ─────────────────────────────────────────────────────────────────
export async function maybeRequestNotificationPermission(): Promise<boolean> {
if (typeof window === "undefined") return false;
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;
const result = await Notification.requestPermission();
return result === "granted";
}
export function fireBrowserNotification(title: string, options: NotificationOptions = {}) {
if (typeof window === "undefined") return;
if (!("Notification" in window)) return;
if (Notification.permission !== "granted") return;
try {
new Notification(title, options);
} catch {
// Some browsers throw on focus-tab notifications — ignore.
}
}