| 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,5 +1,5 @@ |
| 1 | import { assert, describe, it } from "vitest"; | 1 | import { assert, describe, it } from "vitest"; |
| 2 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | 2 | import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; |
| 3 | 3 | ||
| 4 | describe("shouldHideCollapsedToastContent", () => { | 4 | describe("shouldHideCollapsedToastContent", () => { |
| 5 | it("keeps a single visible toast readable", () => { | 5 | it("keeps a single visible toast readable", () => { |
| ... | @@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { | ... | @@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => { |
| 14 | assert.equal(shouldHideCollapsedToastContent(1, 3), true); | 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,3 +7,40 @@ export function shouldHideCollapsedToastContent( |
| 7 | if (visibleToastCount <= 1) return false; | 7 | if (visibleToastCount <= 1) return false; |
| 8 | return visibleToastIndex > 0; | 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,7 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { Toast } from "@base-ui/react/toast"; | 3 | import { Toast } from "@base-ui/react/toast"; |
| 4 | import { useEffect } from "react"; | 4 | import { useEffect, type CSSProperties } from "react"; |
| 5 | import { useParams } from "@tanstack/react-router"; | 5 | import { useParams } from "@tanstack/react-router"; |
| 6 | import { ThreadId } from "@t3tools/contracts"; | 6 | import { ThreadId } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| ... | @@ -14,7 +14,7 @@ import { | ... | @@ -14,7 +14,7 @@ import { |
| 14 | 14 | ||
| 15 | import { cn } from "~/lib/utils"; | 15 | import { cn } from "~/lib/utils"; |
| 16 | import { buttonVariants } from "~/components/ui/button"; | 16 | import { buttonVariants } from "~/components/ui/button"; |
| 17 | import { shouldHideCollapsedToastContent } from "./toast.logic"; | 17 | import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic"; |
| 18 | 18 | ||
| 19 | type ThreadToastData = { | 19 | type ThreadToastData = { |
| 20 | threadId?: ThreadId | null; | 20 | threadId?: ThreadId | null; |
| ... | @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 158 | const visibleToasts = toasts.filter((toast) => | 158 | const visibleToasts = toasts.filter((toast) => |
| 159 | shouldRenderForActiveThread(toast.data, activeThreadId), | 159 | shouldRenderForActiveThread(toast.data, activeThreadId), |
| 160 | ); | 160 | ); |
| 161 | const visibleToastLayout = buildVisibleToastLayout(visibleToasts); | ||
| 161 | 162 | ||
| 162 | useEffect(() => { | 163 | useEffect(() => { |
| 163 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); | 164 | const activeToastIds = new Set(toasts.map((toast) => toast.id)); |
| ... | @@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 183 | )} | 184 | )} |
| 184 | data-position={position} | 185 | data-position={position} |
| 185 | data-slot="toast-viewport" | 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 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; | 194 | const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; |
| 189 | const hideCollapsedContent = shouldHideCollapsedToastContent( | 195 | const hideCollapsedContent = shouldHideCollapsedToastContent( |
| 190 | visibleIndex, | 196 | visibleIndex, |
| 191 | visibleToasts.length, | 197 | visibleToastLayout.items.length, |
| 192 | ); | 198 | ); |
| 193 | 199 | ||
| 194 | return ( | 200 | return ( |
| ... | @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { | ... | @@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { |
| 241 | )} | 247 | )} |
| 242 | data-position={position} | 248 | data-position={position} |
| 243 | key={toast.id} | 249 | key={toast.id} |
| 250 | style={ | ||
| 251 | { | ||
| 252 | "--toast-index": visibleIndex, | ||
| 253 | "--toast-offset-y": `${offsetY}px`, | ||
| 254 | } as CSSProperties | ||
| 255 | } | ||
| 244 | swipeDirection={ | 256 | swipeDirection={ |
| 245 | position.includes("center") | 257 | position.includes("center") |
| 246 | ? [isTop ? "up" : "down"] | 258 | ? [isTop ? "up" : "down"] |