mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type ToastType = "success" | "error" | "info" | "warning";
|
|
|
|
export interface ToastAction {
|
|
label: string;
|
|
onClick: () => void;
|
|
}
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
type: ToastType;
|
|
title: string;
|
|
message?: string;
|
|
duration?: number;
|
|
onClick?: () => void;
|
|
/**
|
|
* Called when the user closes the toast: the close button, a click on the
|
|
* toast or on one of its actions. Not called when it times out.
|
|
*/
|
|
onDismiss?: () => void;
|
|
icon?: React.ReactNode;
|
|
action?: ToastAction;
|
|
secondaryAction?: ToastAction;
|
|
/**
|
|
* A third action. Three buttons do not fit beside the text, so a toast
|
|
* that has one lays its actions out on a row of their own.
|
|
*/
|
|
tertiaryAction?: ToastAction;
|
|
/**
|
|
* Work in progress, e.g. an upload: 0..1, or null while the amount is
|
|
* unknown. The bottom line then shows progress instead of a countdown,
|
|
* and the toast stays until it is updated or removed.
|
|
*/
|
|
progress?: number | null;
|
|
/**
|
|
* Hovering pauses the countdown so a toast can be read. Off for a toast
|
|
* whose action stops working at a fixed time, like Undo send: there the
|
|
* countdown has to match the time that is left.
|
|
*/
|
|
pauseOnHover?: boolean;
|
|
}
|
|
|
|
interface ToastProps {
|
|
toast: Toast;
|
|
onClose: (id: string) => void;
|
|
}
|
|
|
|
const icons = {
|
|
success: CheckCircle,
|
|
error: AlertCircle,
|
|
info: Info,
|
|
warning: AlertTriangle,
|
|
};
|
|
|
|
// Only errors and warnings carry colour; success and info stay neutral
|
|
// (repos/branding/APP.md).
|
|
const iconStyles = {
|
|
success: "text-muted-foreground",
|
|
error: "text-red-600 dark:text-red-400",
|
|
info: "text-muted-foreground",
|
|
warning: "text-amber-600 dark:text-amber-400",
|
|
};
|
|
|
|
const progressBarStyles = {
|
|
success: "bg-muted-foreground",
|
|
error: "bg-destructive",
|
|
info: "bg-muted-foreground",
|
|
warning: "bg-warning",
|
|
};
|
|
|
|
export function ToastItem({ toast, onClose }: ToastProps) {
|
|
const Icon = icons[toast.type];
|
|
const [exiting, setExiting] = useState(false);
|
|
const [paused, setPaused] = useState(false);
|
|
const remainingRef = useRef(toast.duration ?? 5000);
|
|
const startRef = useRef(Date.now());
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const isProgress = toast.progress !== undefined;
|
|
|
|
// A toast updated in place (a finished upload) starts a fresh countdown.
|
|
const lastDurationRef = useRef(toast.duration);
|
|
if (lastDurationRef.current !== toast.duration) {
|
|
lastDurationRef.current = toast.duration;
|
|
remainingRef.current = toast.duration ?? 5000;
|
|
}
|
|
|
|
const dismiss = useCallback(() => {
|
|
setExiting(true);
|
|
setTimeout(() => onClose(toast.id), 280);
|
|
}, [onClose, toast.id]);
|
|
|
|
const dismissByUser = useCallback(() => {
|
|
try {
|
|
toast.onDismiss?.();
|
|
} catch {
|
|
// A failing listener must not keep the toast on screen.
|
|
}
|
|
dismiss();
|
|
}, [toast, dismiss]);
|
|
|
|
useEffect(() => {
|
|
if (!toast.duration || toast.duration <= 0 || isProgress) return;
|
|
|
|
if (paused) {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
remainingRef.current = remainingRef.current - (Date.now() - startRef.current);
|
|
return;
|
|
}
|
|
|
|
startRef.current = Date.now();
|
|
timerRef.current = setTimeout(dismiss, remainingRef.current);
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
};
|
|
}, [toast.duration, paused, dismiss, isProgress]);
|
|
|
|
const actionButton = (action: ToastAction) => (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
action.onClick();
|
|
dismissByUser();
|
|
} catch {
|
|
// Don't close toast on error so user can retry
|
|
}
|
|
}}
|
|
className={cn(
|
|
"text-[13px] font-semibold text-foreground px-1.5 py-0.5 rounded-md transition-colors",
|
|
"underline underline-offset-[3px] hover:bg-foreground/5"
|
|
)}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"toast-item group relative flex items-start gap-2.5 w-[360px] max-w-[calc(100vw-2.5rem)] rounded-md",
|
|
"bg-popover text-popover-foreground border border-border",
|
|
"shadow-[0_6px_16px_rgb(0,0,0,0.1)] dark:shadow-[0_6px_16px_rgb(0,0,0,0.4)]",
|
|
"overflow-hidden pt-3 pe-2.5 pb-3.5 ps-3.5",
|
|
exiting ? "toast-exit" : "toast-enter",
|
|
toast.onClick && !toast.action && "cursor-pointer"
|
|
)}
|
|
onMouseEnter={() => { if (toast.pauseOnHover !== false) setPaused(true); }}
|
|
onMouseLeave={() => setPaused(false)}
|
|
onClick={() => {
|
|
if (toast.onClick && !toast.action) {
|
|
toast.onClick();
|
|
dismissByUser();
|
|
}
|
|
}}
|
|
>
|
|
{/* Icon */}
|
|
{toast.icon !== undefined ? (
|
|
toast.icon
|
|
) : isProgress ? (
|
|
<Loader2 className="w-[18px] h-[18px] flex-shrink-0 mt-px text-muted-foreground animate-spin" />
|
|
) : (
|
|
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0 mt-px", iconStyles[toast.type])} />
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0 pt-px">
|
|
<p className="text-[13.5px] font-medium text-foreground leading-snug">{toast.title}</p>
|
|
{toast.message && (
|
|
<p className="text-[12.5px] mt-0.5 text-muted-foreground leading-snug">{toast.message}</p>
|
|
)}
|
|
{toast.tertiaryAction && (
|
|
<div className="flex flex-wrap items-center gap-1 mt-1.5 -ms-1.5">
|
|
{actionButton(toast.tertiaryAction)}
|
|
{toast.secondaryAction && actionButton(toast.secondaryAction)}
|
|
{toast.action && actionButton(toast.action)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!toast.tertiaryAction && (toast.action || toast.secondaryAction) && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{toast.secondaryAction && actionButton(toast.secondaryAction)}
|
|
{toast.action && actionButton(toast.action)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Close button */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
dismissByUser();
|
|
}}
|
|
className={cn(
|
|
"flex-shrink-0 p-1 rounded-md transition-all",
|
|
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
|
|
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
|
)}
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{/* Progress line: the same line as the countdown, filled by the work done */}
|
|
{isProgress && (
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={toast.progress == null ? undefined : Math.round(toast.progress * 100)}
|
|
>
|
|
<div
|
|
className={cn("h-full opacity-60 transition-[width] duration-300", progressBarStyles[toast.type], toast.progress == null && "w-1/3 animate-pulse")}
|
|
style={toast.progress == null ? undefined : { width: `${Math.max(0, Math.min(1, toast.progress)) * 100}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Countdown line */}
|
|
{!isProgress && !!toast.duration && toast.duration > 0 && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]">
|
|
<div
|
|
className={cn("h-full opacity-60", progressBarStyles[toast.type])}
|
|
style={{
|
|
animation: `toast-progress ${toast.duration}ms linear forwards`,
|
|
animationPlayState: paused ? "paused" : "running",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed bottom-5 right-5 z-[99999] flex flex-col-reverse gap-2.5 [html[data-composer-open]_&]:bottom-20"
|
|
role="status"
|
|
aria-live="polite"
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
{toasts.map((toast) => (
|
|
<div key={toast.id} style={{ pointerEvents: "auto" }}>
|
|
<ToastItem toast={toast} onClose={onClose} />
|
|
</div>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|