diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index def62c884f..c0a5f79f7e 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -1,5 +1,5 @@ import { assert, describe, it } from "vitest"; -import { shouldHideCollapsedToastContent } from "./toast.logic"; +import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; describe("shouldHideCollapsedToastContent", () => { it("keeps a single visible toast readable", () => { @@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { assert.equal(shouldHideCollapsedToastContent(1, 3), true); }); }); + +describe("buildVisibleToastLayout", () => { + it("computes indices and offsets from the visible subset", () => { + const visibleToasts = [{ id: "a", height: 48 }, { id: "b", height: 72 }, { id: "c", height: 24 }]; + + const layout = buildVisibleToastLayout(visibleToasts); + + assert.equal(layout.frontmostHeight, 48); + assert.deepEqual( + layout.items.map(({ toast, visibleIndex, offsetY }) => ({ + id: toast.id, + visibleIndex, + offsetY, + })), + [ + { id: "a", visibleIndex: 0, offsetY: 0 }, + { id: "b", visibleIndex: 1, offsetY: 48 }, + { id: "c", visibleIndex: 2, offsetY: 120 }, + ], + ); + }); + + it("treats missing heights as zero", () => { + const layout = buildVisibleToastLayout([{ id: "a" }, { id: "b", height: undefined }, { id: "c", height: 30 }]); + + assert.equal(layout.frontmostHeight, 0); + assert.deepEqual( + layout.items.map(({ toast, offsetY }) => ({ + id: toast.id, + offsetY, + })), + [ + { id: "a", offsetY: 0 }, + { id: "b", offsetY: 0 }, + { id: "c", offsetY: 0 }, + ], + ); + }); +}); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index 500203033a..eaa4e0db4f 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -7,3 +7,40 @@ export function shouldHideCollapsedToastContent( if (visibleToastCount <= 1) return false; return visibleToastIndex > 0; } + +type ToastWithHeight = { + height?: number | null | undefined; +}; + +type VisibleToastLayoutItem = { + toast: TToast; + visibleIndex: number; + offsetY: number; +}; + +export function buildVisibleToastLayout( + visibleToasts: readonly (TToast & ToastWithHeight)[], +): { + frontmostHeight: number; + items: VisibleToastLayoutItem[]; +} { + let offsetY = 0; + + return { + frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), + items: visibleToasts.map((toast, visibleIndex) => { + const item = { + toast, + visibleIndex, + offsetY, + }; + + offsetY += normalizeToastHeight(toast.height); + return item; + }), + }; +} + +function normalizeToastHeight(height: number | null | undefined): number { + return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index f7035d8747..768a083e2e 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,7 +1,7 @@ "use client"; import { Toast } from "@base-ui/react/toast"; -import { useEffect } from "react"; +import { useEffect, type CSSProperties } from "react"; import { useParams } from "@tanstack/react-router"; import { ThreadId } from "@t3tools/contracts"; import { @@ -14,7 +14,7 @@ import { import { cn } from "~/lib/utils"; import { buttonVariants } from "~/components/ui/button"; -import { shouldHideCollapsedToastContent } from "./toast.logic"; +import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; type ThreadToastData = { threadId?: ThreadId | null; @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const visibleToasts = toasts.filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId), ); + const visibleToastLayout = buildVisibleToastLayout(visibleToasts); useEffect(() => { const activeToastIds = new Set(toasts.map((toast) => toast.id)); @@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { )} data-position={position} data-slot="toast-viewport" + style={ + { + "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`, + } as CSSProperties + } > - {visibleToasts.map((toast, visibleIndex) => { + {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const hideCollapsedContent = shouldHideCollapsedToastContent( visibleIndex, - visibleToasts.length, + visibleToastLayout.items.length, ); return ( @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { )} data-position={position} key={toast.id} + style={ + { + "--toast-index": visibleIndex, + "--toast-offset-y": `${offsetY}px`, + } as CSSProperties + } swipeDirection={ position.includes("center") ? [isTop ? "up" : "down"]