import { createContext } from "preact"; import { useState, useCallback, useContext, useRef, useEffect } from "preact/hooks"; import type { ComponentChildren } from "preact"; interface ToastAction { label: string; shortcutKey?: string; onClick: () => void; } interface ToastItem { id: number; message: string; action?: ToastAction; danger?: boolean; duration: number; show: boolean; } interface ToastContextValue { showToast: (message: string, opts?: { duration?: number; danger?: boolean; action?: ToastAction; }) => void; } const ToastContext = createContext({ showToast: () => {} }); export function useToast() { return useContext(ToastContext); } let nextId = 0; export function ToastProvider({ children }: { children: ComponentChildren }) { const [toasts, setToasts] = useState([]); // Stack of actionable toasts (LIFO) — most recent at end const actionStackRef = useRef<{ id: number; action: ToastAction }[]>([]); // Track danger toasts for dismiss shortcut const dangerStackRef = useRef([]); const removeToast = useCallback((id: number) => { setToasts((prev) => prev.map((t) => t.id === id ? { ...t, show: false } : t)); setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, 200); // Remove from stacks actionStackRef.current = actionStackRef.current.filter((a) => a.id !== id); dangerStackRef.current = dangerStackRef.current.filter((i) => i !== id); }, []); const showToast = useCallback((message: string, opts?: { duration?: number; danger?: boolean; action?: ToastAction; }) => { const id = nextId++; const duration = opts?.duration || 2500; const toast: ToastItem = { id, message, action: opts?.action, danger: opts?.danger, duration, show: false, }; if (opts?.action) { actionStackRef.current.push({ id, action: opts.action }); } if (opts?.danger) { dangerStackRef.current.push(id); } setToasts((prev) => { const next = [...prev, toast]; // Limit visible toasts to 5 (allow more for multi-delete) if (next.length > 5) { return next.slice(next.length - 5); } return next; }); // Force reflow then show requestAnimationFrame(() => { setToasts((prev) => prev.map((t) => t.id === id ? { ...t, show: true } : t)); }); setTimeout(() => { removeToast(id); }, duration); }, [removeToast]); // Global keyboard handler for actionable toast shortcuts (LIFO stack) useEffect(() => { const handler = (e: KeyboardEvent) => { const tag = (document.activeElement?.tagName || "").toUpperCase(); if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; if (e.ctrlKey || e.metaKey || e.altKey) return; const key = e.key.toLowerCase(); // 'q' dismisses the most recent danger toast if (key === "q") { const dangerStack = dangerStackRef.current; if (dangerStack.length > 0) { e.preventDefault(); e.stopPropagation(); const id = dangerStack[dangerStack.length - 1]; removeToast(id); return; } } // Action shortcut keys (z for undo, etc) const stack = actionStackRef.current; if (stack.length === 0) return; // Find the most recent (last) actionable toast matching this shortcut key for (let i = stack.length - 1; i >= 0; i--) { const entry = stack[i]; if (entry.action.shortcutKey && key === entry.action.shortcutKey) { e.preventDefault(); e.stopPropagation(); entry.action.onClick(); removeToast(entry.id); return; } } }; document.addEventListener("keydown", handler, true); return () => document.removeEventListener("keydown", handler, true); }, [removeToast]); return ( {children}
{toasts.map((toast) => (
{toast.message} {toast.action && ( )} {toast.danger && ( )}
))}
); }