import { createContext, useContext, createSignal, createEffect, onCleanup, For, Show, JSXElement } from "solid-js"; import { Icon } from "./Icons.tsx"; export type ToastType = "success" | "error" | "warning" | "info" | "generic"; export type ToastPosition = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center"; export interface ToastConfig { message: string; type?: ToastType; duration?: number | null; // in milliseconds dismissible?: boolean; showProgress?: boolean; } interface ToastInstance extends ToastConfig { id: string; } interface ToastContextValue { addToast: (config: ToastConfig) => string; removeToast: (id: string) => void; success: (message: string, options?: Partial) => string; error: (message: string, options?: Partial) => string; warning: (message: string, options?: Partial) => string; info: (message: string, options?: Partial) => string; generic: (message: string, options?: Partial) => string; } interface ToastProviderProps { position?: ToastPosition; maxToasts?: number; children?: JSXElement; } const ToastContext = createContext(null); export function useToast(): ToastContextValue { const context = useContext(ToastContext); if (!context) { throw new Error("useToast must be used within a ToastProvider"); } return context; } const TOAST_TYPE_ICONS: Record = { success: "circle-check", error: "circle-xmark", warning: "triangle-exclamation", info: "circle-info", generic: null, }; const CONTAINER_BASE = "fixed z-[200] flex flex-col gap-2"; const CONTAINER_POSITIONS: Record = { "top-right": "top-4 right-4", "top-left": "top-4 left-4", "bottom-right": "bottom-4 right-4 flex-col-reverse", "bottom-left": "bottom-4 left-4 flex-col-reverse", "top-center": "top-4 left-1/2 -translate-x-1/2", "bottom-center": "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse", }; const TOAST_BASE = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"; const TOAST_TYPE_BORDER: Record = { success: "border-l-green-700", error: "border-l-red-700", warning: "border-l-yellow-500", info: "border-l-sky-800", generic: "border-l-neutral-400", }; const TOAST_ICON_COLOR: Record = { success: "text-green-600 dark:text-green-400", error: "text-red-600 dark:text-red-400", warning: "text-yellow-600", info: "text-sky-700 dark:text-sky-400", generic: "", }; const DEFAULT_DURATION = 5000; let toastCounter = 0; function generateId(): string { return "toast-" + (++toastCounter) + "-" + Date.now(); } interface ToastItemProps { toast: ToastInstance; onDismiss: (id: string) => void; } function ToastItem(props: ToastItemProps) { const type = () => props.toast.type ?? "info"; const duration = () => (props.toast.duration !== undefined ? props.toast.duration : DEFAULT_DURATION); const dismissible = () => props.toast.dismissible !== false; const showProgress = () => props.toast.showProgress !== false; let timeoutRef: ReturnType | null = null; let animationFrameRef: number | null = null; let startTime = Date.now(); const [isExiting, setIsExiting] = createSignal(false); const [progress, setProgress] = createSignal(100); const handleDismiss = () => { setIsExiting(true); setTimeout(() => props.onDismiss(props.toast.id), 150); }; createEffect(() => { const dur = duration(); if (dur !== null && dur > 0) { timeoutRef = setTimeout(handleDismiss, dur); startTime = Date.now(); const updateProgress = () => { const elapsed = Date.now() - startTime; const remaining = Math.max(0, 100 - (elapsed / dur) * 100); setProgress(remaining); if (remaining > 0) { animationFrameRef = requestAnimationFrame(updateProgress); } }; animationFrameRef = requestAnimationFrame(updateProgress); } onCleanup(() => { if (timeoutRef) { clearTimeout(timeoutRef); } if (animationFrameRef) { cancelAnimationFrame(animationFrameRef); } }); }); const icon = () => TOAST_TYPE_ICONS[type()]; const shouldShowProgress = () => showProgress() && duration() !== null && duration()! > 0; const toastClass = () => { let c = TOAST_BASE + " " + TOAST_TYPE_BORDER[type()]; if (isExiting()) c += " opacity-0 translate-x-2"; return c; }; return (