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<TToast extends object> = { toast: TToast; visibleIndex: number; offsetY: number;}; export function buildVisibleToastLayout<TToast extends object>( visibleToasts: readonly (TToast & ToastWithLayoutProps)[],): { frontmostHeight: number; items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[];} { // 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<TToast & ToastWithLayoutProps>[] = 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;}
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<TToast extends object> = {
toast: TToast;
visibleIndex: number;
offsetY: number;
};
export function buildVisibleToastLayout<TToast extends object>(
visibleToasts: readonly (TToast & ToastWithLayoutProps)[],
): {
frontmostHeight: number;
items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[];
} {
// 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<TToast & ToastWithLayoutProps>[] = 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;
}