| author | |
| committer | |
| log | 24122b17629451e4614d34f3b771769e87b99d79 |
| tree | 020c606cb970fb5162cabd1918d660cdc1afde47 |
| parent | 081a6dacdbb623da0b560360384d58cb95a484e5 |
- derive toast indices and Y offsets from the filtered visible toasts
- pass frontmost height and per-toast layout vars to the viewport/item styles
- add unit tests for visible toast layout calculations and height normalization3 files changed, 93 insertions(+), 5 deletions(-)
apps/web/src/components/ui/toast.logic.test.ts+40-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { assert, describe, it } from "vitest"; |
| 2 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 2 | import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 3 | 3 | |
| 4 | 4 | describe("shouldHideCollapsedToastContent", () => { |
| 5 | 5 | it("keeps a single visible toast readable", () => { |
| ... | ... | @@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { |
| 14 | 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); |
| 15 | 15 | }); |
| 16 | 16 | }); |
| 17 | ||
| 18 | describe("buildVisibleToastLayout", () => { | |
| 19 | it("computes indices and offsets from the visible subset", () => { | |
| 20 | const visibleToasts = [{ id: "a", height: 48 }, { id: "b", height: 72 }, { id: "c", height: 24 }]; | |
| 21 | ||
| 22 | const layout = buildVisibleToastLayout(visibleToasts); | |
| 23 | ||
| 24 | assert.equal(layout.frontmostHeight, 48); | |
| 25 | assert.deepEqual( | |
| 26 | layout.items.map(({ toast, visibleIndex, offsetY }) => ({ | |
| 27 | id: toast.id, | |
| 28 | visibleIndex, | |
| 29 | offsetY, | |
| 30 | })), | |
| 31 | [ | |
| 32 | { id: "a", visibleIndex: 0, offsetY: 0 }, | |
| 33 | { id: "b", visibleIndex: 1, offsetY: 48 }, | |
| 34 | { id: "c", visibleIndex: 2, offsetY: 120 }, | |
| 35 | ], | |
| 36 | ); | |
| 37 | }); | |
| 38 | ||
| 39 | it("treats missing heights as zero", () => { | |
| 40 | const layout = buildVisibleToastLayout([{ id: "a" }, { id: "b", height: undefined }, { id: "c", height: 30 }]); | |
| 41 | ||
| 42 | assert.equal(layout.frontmostHeight, 0); | |
| 43 | assert.deepEqual( | |
| 44 | layout.items.map(({ toast, offsetY }) => ({ | |
| 45 | id: toast.id, | |
| 46 | offsetY, | |
| 47 | })), | |
| 48 | [ | |
| 49 | { id: "a", offsetY: 0 }, | |
| 50 | { id: "b", offsetY: 0 }, | |
| 51 | { id: "c", offsetY: 0 }, | |
| 52 | ], | |
| 53 | ); | |
| 54 | }); | |
| 55 | }); |
apps/web/src/components/ui/toast.logic.ts+37| ... | ... | @@ -7,3 +7,40 @@ export function shouldHideCollapsedToastContent( |
| 7 | 7 | if (visibleToastCount <= 1) return false; |
| 8 | 8 | return visibleToastIndex > 0; |
| 9 | 9 | } |
| 10 | ||
| 11 | type ToastWithHeight = { | |
| 12 | height?: number | null | undefined; | |
| 13 | }; | |
| 14 | ||
| 15 | type VisibleToastLayoutItem<TToast extends object> = { | |
| 16 | toast: TToast; | |
| 17 | visibleIndex: number; | |
| 18 | offsetY: number; | |
| 19 | }; | |
| 20 | ||
| 21 | export function buildVisibleToastLayout<TToast extends object>( | |
| 22 | visibleToasts: readonly (TToast & ToastWithHeight)[], | |
| 23 | ): { | |
| 24 | frontmostHeight: number; | |
| 25 | items: VisibleToastLayoutItem<TToast & ToastWithHeight>[]; | |
| 26 | } { | |
| 27 | let offsetY = 0; | |
| 28 | ||
| 29 | return { | |
| 30 | frontmostHeight: normalizeToastHeight(visibleToasts[0]?.height), | |
| 31 | items: visibleToasts.map((toast, visibleIndex) => { | |
| 32 | const item = { | |
| 33 | toast, | |
| 34 | visibleIndex, | |
| 35 | offsetY, | |
| 36 | }; | |
| 37 | ||
| 38 | offsetY += normalizeToastHeight(toast.height); | |
| 39 | return item; | |
| 40 | }), | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | function normalizeToastHeight(height: number | null | undefined): number { | |
| 45 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : 0; | |
| 46 | } |
apps/web/src/components/ui/toast.tsx+16-4| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | |
| 4 | import { useEffect, type CSSProperties } from "react"; | |
| 5 | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | 7 | import { |
| ... | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | |
| 15 | 15 | import { cn } from "~/lib/utils"; |
| 16 | 16 | import { buttonVariants } from "~/components/ui/button"; |
| 17 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 17 | import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; | |
| 18 | 18 | |
| 19 | 19 | type ThreadToastData = { |
| 20 | 20 | threadId?: ThreadId | null; |
| ... | ... | @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 158 | 158 | const visibleToasts = toasts.filter((toast) => |
| 159 | 159 | shouldRenderForActiveThread(toast.data, activeThreadId), |
| 160 | 160 | ); |
| 161 | const visibleToastLayout = buildVisibleToastLayout(visibleToasts); | |
| 161 | 162 | |
| 162 | 163 | useEffect(() => { |
| 163 | 164 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); |
| ... | ... | @@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 183 | 184 | )} |
| 184 | 185 | data-position={position} |
| 185 | 186 | data-slot="toast-viewport" |
| 187 | style={ | |
| 188 | { | |
| 189 | "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`, | |
| 190 | } as CSSProperties | |
| 191 | } | |
| 186 | 192 | > |
| 187 | {visibleToasts.map((toast, visibleIndex) => { | |
| 193 | {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => { | |
| 188 | 194 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 189 | 195 | const hideCollapsedContent = shouldHideCollapsedToastContent( |
| 190 | 196 | visibleIndex, |
| 191 | visibleToasts.length, | |
| 197 | visibleToastLayout.items.length, | |
| 192 | 198 | ); |
| 193 | 199 | |
| 194 | 200 | return ( |
| ... | ... | @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 241 | 247 | )} |
| 242 | 248 | data-position={position} |
| 243 | 249 | key={toast.id} |
| 250 | style={ | |
| 251 | { | |
| 252 | "--toast-index": visibleIndex, | |
| 253 | "--toast-offset-y": `${offsetY}px`, | |
| 254 | } as CSSProperties | |
| 255 | } | |
| 244 | 256 | swipeDirection={ |
| 245 | 257 | position.includes("center") |
| 246 | 258 | ? [isTop ? "up" : "down"] |