1import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts";
2
3/**
4 * Base UI toast updates omit `undefined` fields, so callers that need to remove
5 * an action must pass a defined `actionProps` whose `children` are empty.
6 * Treat that payload (and missing children) as "no visible action".
7 */
8export function hasVisibleToastAction(actionProps: unknown): boolean {
9 if (actionProps == null || typeof actionProps !== "object") {
10 return false;
11 }
12 if (!("children" in actionProps)) {
13 return false;
14 }
15 const children = actionProps.children;
16 return children != null && children !== false && children !== "";
17}
18
19export function shouldHideCollapsedToastContent(
20 visibleToastIndex: number,
21 visibleToastCount: number,
22): boolean {
23 // Keep the front-most toast readable even if Base UI marks it as "behind"
24 // due to toasts hidden by thread filtering.
25 if (visibleToastCount <= 1) return false;
26 return visibleToastIndex > 0;
27}
28
29type ToastWithHeight = {
30 height?: number | null | undefined;
31};
32
33type ToastWithTransitionStatus = {
34 transitionStatus?: "starting" | "ending" | undefined;
35};
36
37type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus;
38
39type VisibleToastLayoutItem<TToast extends object> = {
40 toast: TToast;
41 visibleIndex: number;
42 offsetY: number;
43};
44
45export function buildVisibleToastLayout<TToast extends object>(
46 visibleToasts: readonly (TToast & ToastWithLayoutProps)[],
47): {
48 frontmostHeight: number;
49 items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[];
50} {
51 // Two parallel cursors:
52 // - `full*` advances on every toast, so an ending toast keeps the slot it
53 // occupied before dismissal and its data-ending-style exit transform
54 // originates from the correct position (critical for dismissing a
55 // non-front toast in the expanded stack — otherwise it would snap to
56 // Y=0 and slide off diagonally).
57 // - `live*` advances only on non-ending toasts, so live toasts reflow
58 // past the vacated slot in parallel with the exit animation instead of
59 // waiting for it to finish (which caused a visible "stop and bump").
60 let fullIndex = 0;
61 let fullOffsetY = 0;
62 let liveIndex = 0;
63 let liveOffsetY = 0;
64
65 const items: VisibleToastLayoutItem<TToast & ToastWithLayoutProps>[] = visibleToasts.map(
66 (toast) => {
67 const height = normalizeToastHeight(toast.height);
68
69 if (toast.transitionStatus === "ending") {
70 const item = {
71 toast,
72 visibleIndex: fullIndex,
73 offsetY: fullOffsetY,
74 };
75 fullOffsetY += height;
76 fullIndex += 1;
77 return item;
78 }
79
80 const item = {
81 toast,
82 visibleIndex: liveIndex,
83 offsetY: liveOffsetY,
84 };
85
86 fullOffsetY += height;
87 fullIndex += 1;
88 liveOffsetY += height;
89 liveIndex += 1;
90 return item;
91 },
92 );
93
94 // Frontmost height should reflect the first non-ending (live) toast so the
95 // stack sizes to what's actually staying on screen.
96 const frontmostLiveToast = visibleToasts.find((toast) => toast.transitionStatus !== "ending");
97
98 return {
99 frontmostHeight: normalizeToastHeight(frontmostLiveToast?.height),
100 items,
101 };
102}
103
104function normalizeToastHeight(height: number | null | undefined): number {
105 return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0;
106}
107
108export function shouldRenderThreadScopedToast(
109 data:
110 | {
111 threadRef?: ScopedThreadRef | null;
112 threadId?: ThreadId | null;
113 }
114 | undefined,
115 activeThreadRef: ScopedThreadRef | null,
116): boolean {
117 if (data?.threadRef) {
118 return (
119 activeThreadRef !== null &&
120 data.threadRef.environmentId === activeThreadRef.environmentId &&
121 data.threadRef.threadId === activeThreadRef.threadId
122 );
123 }
124
125 const toastThreadId = data?.threadId;
126 if (!toastThreadId) {
127 return true;
128 }
129
130 return activeThreadRef?.threadId === toastThreadId;
131}