import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { Alert, type AlertAction } from "./alert"; export type UhuruAlertTone = "info" | "success" | "warning" | "error" | "destructive"; export type UhuruAlertPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; export type UhuruAlertConfig = { dismissible?: boolean; duration?: number; gap?: number | string; maxWidth?: number | string; minWidth?: number | string; offset?: number | string; position?: UhuruAlertPosition; stackLimit?: number; zIndex?: number; }; export type UhuruAlertInput = { action?: AlertAction; description?: ReactNode; dismissible?: boolean; duration?: number; id?: string; tone?: UhuruAlertTone; title: string; }; export type UhuruAlertEntry = UhuruAlertInput & { createdAt: number; id: string; }; type ResolvedUhuruAlertConfig = Required> & { maxWidth: number | string; minWidth: number | string; offset: number | string; }; type UhuruAlertContextValue = { alert: (input: string | UhuruAlertInput) => string; alerts: UhuruAlertEntry[]; clearAlerts: () => void; config: ResolvedUhuruAlertConfig; dismissAlert: (id: string) => void; }; const defaultAlertConfig: ResolvedUhuruAlertConfig = { dismissible: false, duration: 5000, gap: 12, maxWidth: 420, minWidth: 280, offset: 24, position: "top-right", stackLimit: 3, zIndex: 70, }; const UhuruAlertContext = createContext(null); function toCssSize(value: number | string) { return typeof value === "number" ? `${value}px` : value; } function resolveAlertConfig(config?: UhuruAlertConfig): ResolvedUhuruAlertConfig { return { dismissible: config?.dismissible ?? defaultAlertConfig.dismissible, duration: config?.duration ?? defaultAlertConfig.duration, gap: config?.gap ?? defaultAlertConfig.gap, maxWidth: config?.maxWidth ?? defaultAlertConfig.maxWidth, minWidth: config?.minWidth ?? defaultAlertConfig.minWidth, offset: config?.offset ?? defaultAlertConfig.offset, position: config?.position ?? defaultAlertConfig.position, stackLimit: config?.stackLimit ?? defaultAlertConfig.stackLimit, zIndex: config?.zIndex ?? defaultAlertConfig.zIndex, }; } function positionStyles(position: UhuruAlertPosition) { switch (position) { case "top-left": return { alignItems: "flex-start", justifyContent: "flex-start" } as const; case "top-center": return { alignItems: "center", justifyContent: "flex-start" } as const; case "top-right": return { alignItems: "flex-end", justifyContent: "flex-start" } as const; case "bottom-left": return { alignItems: "flex-start", justifyContent: "flex-end" } as const; case "bottom-center": return { alignItems: "center", justifyContent: "flex-end" } as const; case "bottom-right": return { alignItems: "flex-end", justifyContent: "flex-end" } as const; default: return { alignItems: "flex-end", justifyContent: "flex-start" } as const; } } export function UhuruAlertProvider({ children, config, }: { children: ReactNode; config?: UhuruAlertConfig; }) { const resolvedConfig = useMemo(() => resolveAlertConfig(config), [config]); const [alerts, setAlerts] = useState([]); const timeoutsRef = useRef(new Map>()); const nextIdRef = useRef(1); const clearTimeoutFor = useCallback((id: string) => { const timeoutId = timeoutsRef.current.get(id); if (timeoutId !== undefined) { globalThis.clearTimeout(timeoutId); timeoutsRef.current.delete(id); } }, []); const dismissAlert = useCallback( (id: string) => { clearTimeoutFor(id); setAlerts((current) => current.filter((entry) => entry.id !== id)); }, [clearTimeoutFor], ); const clearAlerts = useCallback(() => { for (const timeoutId of timeoutsRef.current.values()) { globalThis.clearTimeout(timeoutId); } timeoutsRef.current.clear(); setAlerts([]); }, []); const alert = useCallback( (input: string | UhuruAlertInput) => { const payload = typeof input === "string" ? { title: input } : input; const id = payload.id ?? `uhuru-alert-${nextIdRef.current++}`; const duration = payload.duration ?? resolvedConfig.duration; clearTimeoutFor(id); setAlerts((current) => { const nextEntry: UhuruAlertEntry = { ...payload, createdAt: Date.now(), id, }; const deduped = current.filter((entry) => entry.id !== id); const next = [...deduped, nextEntry]; return next.length > resolvedConfig.stackLimit ? next.slice(next.length - resolvedConfig.stackLimit) : next; }); if (duration > 0) { const timeoutId = globalThis.setTimeout(() => { dismissAlert(id); }, duration); timeoutsRef.current.set(id, timeoutId); } return id; }, [clearTimeoutFor, dismissAlert, resolvedConfig.duration, resolvedConfig.stackLimit], ); useEffect( () => () => { for (const timeoutId of timeoutsRef.current.values()) { globalThis.clearTimeout(timeoutId); } timeoutsRef.current.clear(); }, [], ); const contextValue = useMemo( () => ({ alert, alerts, clearAlerts, config: resolvedConfig, dismissAlert, }), [alert, alerts, clearAlerts, dismissAlert, resolvedConfig], ); return ( {children} ); } export function useUhuruAlert() { const context = useContext(UhuruAlertContext); if (!context) { throw new Error("useUhuruAlert must be used within UhuruProvider."); } return context; } export function UhuruAlertViewport() { const context = useContext(UhuruAlertContext); if (!context || context.alerts.length === 0) { return null; } const { alerts, config, dismissAlert } = context; const viewportPosition = positionStyles(config.position); return (
{alerts.map((entry) => (
dismissAlert(entry.id)} tone={entry.tone} title={entry.title} > {entry.description}
))}
); }