authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 09:28:30-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 10:01:46-07:00
log24122b17629451e4614d34f3b771769e87b99d79
tree020c606cb970fb5162cabd1918d660cdc1afde47
parent081a6dacdbb623da0b560360384d58cb95a484e5

Stabilize toast stacking offsets to prevent hover flicker

- 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 normalization

3 files changed, 93 insertions(+), 5 deletions(-)

apps/web/src/components/ui/toast.logic.test.ts+40-1
......@@ -1,5 +1,5 @@
11import { assert, describe, it } from "vitest";
2import { shouldHideCollapsedToastContent } from "./toast.logic";
2import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
33
44describe("shouldHideCollapsedToastContent", () => {
55 it("keeps a single visible toast readable", () => {
......@@ -14,3 +14,42 @@ describe("shouldHideCollapsedToastContent", () => {
1414 assert.equal(shouldHideCollapsedToastContent(1, 3), true);
1515 });
1616});
17
18describe("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(
77 if (visibleToastCount <= 1) return false;
88 return visibleToastIndex > 0;
99}
10
11type ToastWithHeight = {
12 height?: number | null | undefined;
13};
14
15type VisibleToastLayoutItem<TToast extends object> = {
16 toast: TToast;
17 visibleIndex: number;
18 offsetY: number;
19};
20
21export 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
44function 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 @@
11"use client";
22
33import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";
4import { useEffect, type CSSProperties } from "react";
55import { useParams } from "@tanstack/react-router";
66import { ThreadId } from "@t3tools/contracts";
77import {
......@@ -14,7 +14,7 @@ import {
1414
1515import { cn } from "~/lib/utils";
1616import { buttonVariants } from "~/components/ui/button";
17import { shouldHideCollapsedToastContent } from "./toast.logic";
17import { buildVisibleToastLayout, shouldHideCollapsedToastContent } from "./toast.logic";
1818
1919type ThreadToastData = {
2020 threadId?: ThreadId | null;
......@@ -158,6 +158,7 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
158158 const visibleToasts = toasts.filter((toast) =>
159159 shouldRenderForActiveThread(toast.data, activeThreadId),
160160 );
161 const visibleToastLayout = buildVisibleToastLayout(visibleToasts);
161162
162163 useEffect(() => {
163164 const activeToastIds = new Set(toasts.map((toast) => toast.id));
......@@ -183,12 +184,17 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
183184 )}
184185 data-position={position}
185186 data-slot="toast-viewport"
187 style={
188 {
189 "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`,
190 } as CSSProperties
191 }
186192 >
187 {visibleToasts.map((toast, visibleIndex) => {
193 {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {
188194 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
189195 const hideCollapsedContent = shouldHideCollapsedToastContent(
190196 visibleIndex,
191 visibleToasts.length,
197 visibleToastLayout.items.length,
192198 );
193199
194200 return (
......@@ -241,6 +247,12 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
241247 )}
242248 data-position={position}
243249 key={toast.id}
250 style={
251 {
252 "--toast-index": visibleIndex,
253 "--toast-offset-y": `${offsetY}px`,
254 } as CSSProperties
255 }
244256 swipeDirection={
245257 position.includes("center")
246258 ? [isTop ? "up" : "down"]