217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
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;
|
|
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<ToastConfig>) => string;
|
|
error: (message: string, options?: Partial<ToastConfig>) => string;
|
|
warning: (message: string, options?: Partial<ToastConfig>) => string;
|
|
info: (message: string, options?: Partial<ToastConfig>) => string;
|
|
generic: (message: string, options?: Partial<ToastConfig>) => string;
|
|
}
|
|
|
|
interface ToastProviderProps {
|
|
position?: ToastPosition;
|
|
maxToasts?: number;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(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<ToastType, string | null> = {
|
|
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<ToastPosition, string> = {
|
|
"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-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out";
|
|
const TOAST_TYPE_BORDER: Record<ToastType, string> = {
|
|
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<ToastType, string> = {
|
|
success: "text-green-600",
|
|
error: "text-red-600",
|
|
warning: "text-yellow-600",
|
|
info: "text-sky-700",
|
|
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 ?? DEFAULT_DURATION;
|
|
const dismissible = () => props.toast.dismissible !== false;
|
|
const showProgress = () => props.toast.showProgress !== false;
|
|
|
|
let timeoutRef: ReturnType<typeof setTimeout> | 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 (
|
|
<div class={toastClass()} role="alert">
|
|
<div class="flex items-start gap-3 p-4">
|
|
<Show when={icon()}>
|
|
<Icon icon={icon()} size={20} class={"shrink-0 mt-0.5 " + TOAST_ICON_COLOR[type()]}/>
|
|
</Show>
|
|
<div class="flex-1 text-sm text-neutral-800">{props.toast.message}</div>
|
|
<Show when={dismissible()}>
|
|
<button onclick={handleDismiss} class="shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors" aria-label="Dismiss">
|
|
<Icon icon="xmark" size={16}/>
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
<Show when={shouldShowProgress()}>
|
|
<div class="h-1 w-full bg-neutral-100">
|
|
<div class="h-full bg-neutral-300" style={{ width: progress() + "%" }}/>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToastProvider(props: ToastProviderProps) {
|
|
const [toasts, setToasts] = createSignal<ToastInstance[]>([]);
|
|
|
|
const position = () => props.position ?? "bottom-right";
|
|
const maxToasts = () => props.maxToasts ?? 5;
|
|
|
|
const removeToast = (id: string) => {
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
};
|
|
|
|
const addToast = (config: ToastConfig): string => {
|
|
const id = generateId();
|
|
const newToast: ToastInstance = { ...config, id };
|
|
setToasts((prev) => {
|
|
const updated = [...prev, newToast];
|
|
if (updated.length > maxToasts()) {
|
|
return updated.slice(-maxToasts());
|
|
}
|
|
return updated;
|
|
});
|
|
return id;
|
|
};
|
|
|
|
const success = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "success", ...options });
|
|
const error = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "error", ...options });
|
|
const warning = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "warning", ...options });
|
|
const info = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "info", ...options });
|
|
const generic = (message: string, options?: Partial<ToastConfig>) => addToast({ message, type: "generic", ...options });
|
|
|
|
const value: ToastContextValue = {
|
|
addToast,
|
|
removeToast,
|
|
success,
|
|
error,
|
|
warning,
|
|
info,
|
|
generic,
|
|
};
|
|
|
|
return (
|
|
<ToastContext.Provider value={value}>
|
|
{props.children}
|
|
<div class={CONTAINER_BASE + " " + CONTAINER_POSITIONS[position()]} aria-live="polite" aria-label="Notifications">
|
|
<For each={toasts()}>{(toast) => (
|
|
<ToastItem toast={toast} onDismiss={removeToast}/>
|
|
)}</For>
|
|
</div>
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|