"use client"; import { Toast } from "@base-ui/react/toast"; import { useEffect, useMemo, useState, type CSSProperties, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode, } from "react"; import { useParams } from "@tanstack/react-router"; import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon, CircleAlertIcon, CircleCheckIcon, CopyIcon, InfoIcon, LoaderCircleIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; import { useComposerDraftStore } from "~/composerDraftStore"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { resolveThreadRouteTarget } from "~/threadRoutes"; import { buildVisibleToastLayout, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip"; export type ThreadToastData = { threadRef?: ScopedThreadRef | null; threadId?: ThreadId | null; leadingIcon?: ReactNode; tooltipStyle?: boolean; onClose?: (() => void) | undefined; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; additionalActions?: ReadonlyArray<{ id: string; props: ComponentPropsWithoutRef<"button">; }>; secondaryActionProps?: ComponentPropsWithoutRef<"button">; secondaryActionVariant?: | "default" | "destructive" | "destructive-outline" | "ghost" | "link" | "outline" | "secondary"; /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */ expandableContent?: ReactNode; expandableLabels?: { expand?: string; collapse?: string }; /** When set with `expandableContent`, the summary + label act as one text disclosure (no separate chevron row). */ expandableDescriptionTrigger?: boolean; actionLayout?: "inline" | "stacked-end"; actionVariant?: | "default" | "destructive" | "destructive-outline" | "ghost" | "link" | "outline" | "secondary"; }; const toastManager = Toast.createToastManager(); const anchoredToastManager = Toast.createToastManager(); type ToastId = ReturnType; const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, loading: LoaderCircleIcon, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; /** Visually shorten long error bodies; clipboard copy still uses the full `description` string. */ const ERROR_DESCRIPTION_CLAMP_MIN_CHARS = 180; function errorDescriptionClampClass(type: unknown, description: unknown): string | undefined { if (type !== "error" || typeof description !== "string") { return undefined; } if (description.length < ERROR_DESCRIPTION_CLAMP_MIN_CHARS) { return undefined; } return "line-clamp-4"; } /** Dismiss-only: circular control overlapping the card corner (iOS notification–style). */ const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5"; const toastCornerOrbClass = cn( "inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/60 bg-popover/92 text-muted-foreground shadow-sm outline-none backdrop-blur-sm", "transition-[color,background-color,box-shadow] hover:bg-popover hover:text-foreground", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background", ); function handleToastDismissClick( manager: typeof toastManager | typeof anchoredToastManager, toastId: ToastId, onClose: (() => void) | undefined, ) { onClose?.(); manager.close(toastId); } function CopyErrorButton({ text }: { text: string }) { const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); const label = isCopied ? "Copied error" : "Copy error"; return ( copyToClipboard(text)} type="button" /> } > {isCopied ? : } {label} ); } /** Scrollable cap for long expandable lists (~10rem); keeps the toast from growing without bound. */ const toastExpandablePanelClassName = "mt-2 max-h-40 min-h-0 overflow-y-auto overscroll-contain pr-0.5 select-text"; function ToastExpandableSection({ children, labels, }: { children: ReactNode; labels: { expand?: string; collapse?: string }; }) { const [open, setOpen] = useState(false); const expandLabel = labels.expand ?? "Show details"; const collapseLabel = labels.collapse ?? "Hide details"; return (
{open ?
{children}
: null}
); } function ToastDescriptionAndExpandable({ toastData, toastDescription, toastType, }: { toastData: ThreadToastData | undefined; toastDescription: unknown; toastType: unknown; }) { const expandableContent = toastData?.expandableContent; const labels = toastData?.expandableLabels ?? {}; const descriptionTrigger = toastData?.expandableDescriptionTrigger ?? false; const descriptionClassName = cn( "min-w-0 select-text wrap-break-word text-muted-foreground", errorDescriptionClampClass(toastType, toastDescription), ); const [open, setOpen] = useState(false); if (!expandableContent) { return ; } if (!descriptionTrigger) { return ( <> {expandableContent} ); } const expandLabel = labels.expand ?? "Show details"; const collapseLabel = labels.collapse ?? "Hide details"; const toggle = () => setOpen((v) => !v); const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(); } }; return ( <> } >
{open ? ( ) : ( )}
{open ? collapseLabel : expandLabel}
{open ?
{expandableContent}
: null} ); } type ToastIconComponent = (typeof TOAST_ICONS)[keyof typeof TOAST_ICONS]; interface ToastBodyDescriptor { readonly Icon: ToastIconComponent | null | undefined; readonly stackedActionLayout: boolean; readonly actionVariant: NonNullable; readonly secondaryActionVariant: NonNullable; readonly copyErrorText: string | null; readonly hasTrailingControls: boolean; readonly inlineContentEndPad: string; } function deriveToastBodyDescriptor(toast: { readonly type?: string | undefined; readonly description?: unknown; readonly actionProps?: unknown; readonly data?: ThreadToastData | undefined; }): ToastBodyDescriptor { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const stackedActionLayout = toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; const actionVariant: NonNullable = toast.data?.actionVariant ?? "default"; const secondaryActionVariant: NonNullable = toast.data?.secondaryActionVariant ?? "outline"; const copyErrorText = toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton ? toast.description : null; const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0; const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; const hasTrailingControls = copyErrorText !== null || toast.actionProps !== undefined || hasAdditionalActions || hasSecondaryAction; const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; return { Icon, stackedActionLayout, actionVariant, secondaryActionVariant, copyErrorText, hasTrailingControls, inlineContentEndPad, }; } interface ToastBodyContentProps extends ToastBodyDescriptor { readonly actionProps: { readonly children?: ReactNode } | undefined; readonly toastData: ThreadToastData | undefined; readonly toastDescription: unknown; readonly toastType: unknown; } function ToastBodyContent({ stackedActionLayout, Icon, copyErrorText, actionProps, actionVariant, secondaryActionVariant, hasTrailingControls, toastData, toastDescription, toastType, }: ToastBodyContentProps) { const additionalActions = toastData?.additionalActions ?? []; const secondaryActionProps = toastData?.secondaryActionProps; const leadingIcon = toastData?.leadingIcon; const { className: secondaryActionClassName, ...secondaryActionRest } = secondaryActionProps ?? {}; return ( <>
{leadingIcon ? (
{leadingIcon}
) : Icon ? (
) : null}
{hasTrailingControls ? (
{copyErrorText !== null ? : null} {additionalActions.map(({ id, props: { className, ...props } }) => (
) : null} ); } type ToastPosition = | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right"; interface ToastProviderProps extends Toast.Provider.Props { position?: ToastPosition; } function useActiveThreadRefFromRoute(): ScopedThreadRef | null { const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), }); const activeDraftSession = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); return useMemo(() => { if (routeTarget?.kind === "server") { return routeTarget.threadRef; } if (routeTarget?.kind === "draft" && activeDraftSession) { return { environmentId: activeDraftSession.environmentId, threadId: activeDraftSession.threadId, }; } return null; }, [activeDraftSession, routeTarget]); } function ThreadToastVisibleAutoDismiss({ toastId, dismissAfterVisibleMs, }: { toastId: ToastId; dismissAfterVisibleMs: number | undefined; }) { useEffect(() => { if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; if (typeof window === "undefined" || typeof document === "undefined") return; let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; let startedAtMs: number | null = null; let timeoutId: number | null = null; let closed = false; const clearTimer = () => { if (timeoutId === null) return; window.clearTimeout(timeoutId); timeoutId = null; }; const closeToast = () => { if (closed) return; closed = true; threadToastVisibleTimeoutRemainingMs.delete(toastId); toastManager.close(toastId); }; const pause = () => { if (startedAtMs === null) return; remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); startedAtMs = null; clearTimer(); threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); }; const start = () => { if (closed || startedAtMs !== null) return; if (remainingMs <= 0) { closeToast(); return; } startedAtMs = Date.now(); clearTimer(); timeoutId = window.setTimeout(() => { remainingMs = 0; startedAtMs = null; closeToast(); }, remainingMs); }; const syncTimer = () => { const shouldRun = document.visibilityState === "visible" && document.hasFocus(); if (shouldRun) { start(); return; } pause(); }; syncTimer(); document.addEventListener("visibilitychange", syncTimer); window.addEventListener("focus", syncTimer); window.addEventListener("blur", syncTimer); return () => { document.removeEventListener("visibilitychange", syncTimer); window.removeEventListener("focus", syncTimer); window.removeEventListener("blur", syncTimer); pause(); clearTimer(); }; }, [dismissAfterVisibleMs, toastId]); return null; } function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { return ( {children} ); } function Toasts({ position }: { position: ToastPosition }) { const { toasts } = Toast.useToastManager(); const activeThreadRef = useActiveThreadRefFromRoute(); const isTop = position.startsWith("top"); const visibleToasts = toasts.filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef), ); const visibleToastLayout = buildVisibleToastLayout(visibleToasts); useEffect(() => { const activeToastIds = new Set(toasts.map((toast) => toast.id)); for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { if (!activeToastIds.has(toastId)) { threadToastVisibleTimeoutRemainingMs.delete(toastId); } } }, [toasts]); return ( {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { const hideCollapsedContent = shouldHideCollapsedToastContent( visibleIndex, visibleToastLayout.items.length, ); const bodyDescriptor = deriveToastBodyDescriptor(toast); const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor; return ( 0 ? "not-data-expanded:[--toast-calc-height:var(--toast-frontmost-height)] data-expanded:[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]" : "[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]", "[--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]", // Root height: never `min-h-(--toast-height)` — Base UI measures height by briefly forcing // `height: auto` on this node; an old `min-height` from `--toast-height` blocks shrinking, // so `recalculateHeight` keeps the inflated value after an expandable closes. // Behind + collapsed: fixed peek. Otherwise natural height (expand/collapse, hover stack). visibleIndex > 0 ? "not-data-expanded:h-(--toast-calc-height) data-expanded:h-auto" : "h-auto", // Define offset-y variable "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]", "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]", // Default state transform "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]", // Limited state "data-limited:opacity-0", // Expanded stack "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]", // Starting and ending animations "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]", "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]", "data-[position*=top]:data-[position*=right]:data-starting-style:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:opacity-0", // Ending animations (direction-aware) "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]", "data-[position*=top]:data-[position*=right]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", // Ending animations (expanded) "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]", "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]", "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]", )} data-position={position} key={toast.id} style={ { "--toast-index": visibleIndex, "--toast-offset-y": `${offsetY}px`, } as CSSProperties } swipeDirection={ position.includes("center") ? [isTop ? "up" : "down"] : position.includes("left") ? ["left", isTop ? "up" : "down"] : ["right", isTop ? "up" : "down"] } toast={toast} >
); })}
); } function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) { return ( {children} ); } function AnchoredToasts() { const { toasts } = Toast.useToastManager(); const activeThreadRef = useActiveThreadRefFromRoute(); return ( {toasts .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef)) .map((toast) => { const tooltipStyle = toast.data?.tooltipStyle ?? false; const positionerProps = toast.positionerProps; const bodyDescriptor = deriveToastBodyDescriptor(toast); const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor; if (!positionerProps?.anchor) { return null; } return ( {tooltipStyle ? ( ) : ( <>
)}
); })}
); } export { stackedThreadToast } from "./toastHelpers"; export type { StackedThreadToastOptions } from "./toastHelpers"; export { ToastProvider, type ToastPosition, toastManager, AnchoredToastProvider, anchoredToastManager, };