| 1 | import 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 | */ |
| 8 | export 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 | |
| 19 | export 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 | |
| 29 | type ToastWithHeight = { |
| 30 | height?: number | null | undefined; |
| 31 | }; |
| 32 | |
| 33 | type ToastWithTransitionStatus = { |
| 34 | transitionStatus?: "starting" | "ending" | undefined; |
| 35 | }; |
| 36 | |
| 37 | type ToastWithLayoutProps = ToastWithHeight & ToastWithTransitionStatus; |
| 38 | |
| 39 | type VisibleToastLayoutItem<TToast extends object> = { |
| 40 | toast: TToast; |
| 41 | visibleIndex: number; |
| 42 | offsetY: number; |
| 43 | }; |
| 44 | |
| 45 | export 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 | |
| 104 | function normalizeToastHeight(height: number | null | undefined): number { |
| 105 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; |
| 106 | } |
| 107 | |
| 108 | export 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 | } |