import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; export function shouldHideCollapsedToastContent( visibleToastIndex: number, visibleToastCount: number, ): boolean { // Keep the front-most toast readable even if Base UI marks it as "behind" // due to toasts hidden by thread filtering. if (visibleToastCount <= 1) return false; return visibleToastIndex > 0; } type ToastWithHeight = { height?: number | null | undefined; }; type ToastWithTransitionStatus = { transitionStatus?: "starting" | "ending" | undefined; }; type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus; type VisibleToastLayoutItem = { toast: TToast; visibleIndex: number; offsetY: number; }; export function buildVisibleToastLayout( visibleToasts: readonly (TToast & ToastWithLayoutProps)[], ): { frontmostHeight: number; items: VisibleToastLayoutItem[]; } { // Two parallel cursors: // - `full*` advances on every toast, so an ending toast keeps the slot it // occupied before dismissal and its data-ending-style exit transform // originates from the correct position (critical for dismissing a // non-front toast in the expanded stack — otherwise it would snap to // Y=0 and slide off diagonally). // - `live*` advances only on non-ending toasts, so live toasts reflow // past the vacated slot in parallel with the exit animation instead of // waiting for it to finish (which caused a visible "stop and bump"). let fullIndex = 0; let fullOffsetY = 0; let liveIndex = 0; let liveOffsetY = 0; const items: VisibleToastLayoutItem[] = visibleToasts.map( (toast) => { const height = normalizeToastHeight(toast.height); if (toast.transitionStatus === "ending") { const item = { toast, visibleIndex: fullIndex, offsetY: fullOffsetY, }; fullOffsetY += height; fullIndex += 1; return item; } const item = { toast, visibleIndex: liveIndex, offsetY: liveOffsetY, }; fullOffsetY += height; fullIndex += 1; liveOffsetY += height; liveIndex += 1; return item; }, ); // Frontmost height should reflect the first non-ending (live) toast so the // stack sizes to what's actually staying on screen. const frontmostLiveToast = visibleToasts.find((toast) => toast.transitionStatus !== "ending"); return { frontmostHeight: normalizeToastHeight(frontmostLiveToast?.height), items, }; } function normalizeToastHeight(height: number | null | undefined): number { return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; } export function shouldRenderThreadScopedToast( data: | { threadRef?: ScopedThreadRef | null; threadId?: ThreadId | null; } | undefined, activeThreadRef: ScopedThreadRef | null, ): boolean { if (data?.threadRef) { return ( activeThreadRef !== null && data.threadRef.environmentId === activeThreadRef.environmentId && data.threadRef.threadId === activeThreadRef.threadId ); } const toastThreadId = data?.threadId; if (!toastThreadId) { return true; } return activeThreadRef?.threadId === toastThreadId; }