Files
webmail/components/ui/toast.tsx
T
Linus Rath cd5fe770e4 feat: show file uploads as a toast like every other notice
Uploads in Files had their own card with a thick progress bar, larger
corners and a heavier shadow, so they looked unlike the toasts around
them.

Toasts can now show work in progress: the thin line at the bottom fills
with progress instead of counting down, and the toast stays until it is
updated or removed. Uploads use it, with the file and percentage below
the title and Cancel as the toast action.
2026-10-11 16:38:05 +02:00

245 lines
7.6 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;
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;
}
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]);
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();
dismiss();
} 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={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onClick={() => {
if (toast.onClick && !toast.action) {
toast.onClick();
dismiss();
}
}}
>
{/* 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();
dismiss();
}}
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"
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
);
}