authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 19:54:27-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 20:12:24-08:00
log05b47e748f6a57001b9cb6b4f99d883e05b6d171
treebb70c1156fcfc900d2173c94c8e030fbe59e45e9
parent38639010859b760c1770c717f737e5e725127f46

Fix collapsed toast content visibility in filtered stacks

- keep the front-most visible toast readable when thread filtering hides others - extract visibility rule into `toast.logic.ts` - add unit tests for collapsed-content visibility behavior

3 files changed, 122 insertions(+), 88 deletions(-)

apps/web/src/components/ui/toast.logic.test.ts created+16
......@@ -0,0 +1,16 @@
1import { assert, describe, it } from "vitest";
2import { shouldHideCollapsedToastContent } from "./toast.logic";
3
4describe("shouldHideCollapsedToastContent", () => {
5 it("keeps a single visible toast readable", () => {
6 assert.equal(shouldHideCollapsedToastContent(0, 1), false);
7 });
8
9 it("keeps the front-most toast readable in a visible stack", () => {
10 assert.equal(shouldHideCollapsedToastContent(0, 3), false);
11 });
12
13 it("hides non-front toasts until the stack is expanded", () => {
14 assert.equal(shouldHideCollapsedToastContent(1, 3), true);
15 });
16});
apps/web/src/components/ui/toast.logic.ts created+9
......@@ -0,0 +1,9 @@
1export function shouldHideCollapsedToastContent(
2 visibleToastIndex: number,
3 visibleToastCount: number,
4): boolean {
5 // Keep the front-most toast readable even if Base UI marks it as "behind"
6 // due to toasts hidden by thread filtering.
7 if (visibleToastCount <= 1) return false;
8 return visibleToastIndex > 0;
9}
apps/web/src/components/ui/toast.tsx+97-88
......@@ -13,6 +13,7 @@ import {
1313import { cn } from "~/lib/utils";
1414import { buttonVariants } from "~/components/ui/button";
1515import { useStore } from "~/store";
16import { shouldHideCollapsedToastContent } from "./toast.logic";
1617
1718type ThreadToastData = {
1819 threadId?: string | null;
......@@ -146,6 +147,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
146147 const { toasts } = Toast.useToastManager<ThreadToastData>();
147148 const activeThreadId = state.activeThreadId;
148149 const isTop = position.startsWith("top");
150 const visibleToasts = toasts.filter((toast) =>
151 shouldRenderForActiveThread(toast.data, activeThreadId),
152 );
149153
150154 useEffect(() => {
151155 const activeToastIds = new Set(toasts.map((toast) => toast.id));
......@@ -172,100 +176,105 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
172176 data-position={position}
173177 data-slot="toast-viewport"
174178 >
175 {toasts
176 .filter((toast) => shouldRenderForActiveThread(toast.data, activeThreadId))
177 .map((toast) => {
178 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
179 {visibleToasts.map((toast, visibleIndex) => {
180 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
181 const hideCollapsedContent = shouldHideCollapsedToastContent(
182 visibleIndex,
183 visibleToasts.length,
184 );
179185
180 return (
181 <Toast.Root
186 return (
187 <Toast.Root
188 className={cn(
189 "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
190 // Base positioning using data-position
191 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
192 "data-[position*=left]:right-auto data-[position*=left]:left-0",
193 "data-[position*=center]:right-0 data-[position*=center]:left-0",
194 "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
195 "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
196 // Gap fill for hover
197 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
198 "data-[position*=top]:after:top-full",
199 "data-[position*=bottom]:after:bottom-full",
200 // Define some variables
201 "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
202 // Define offset-y variable
203 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
204 "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
205 // Default state transform
206 "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
207 "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
208 // Limited state
209 "data-limited:opacity-0",
210 // Expanded state
211 "data-expanded:h-(--toast-height)",
212 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
213 // Starting and ending animations
214 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
215 "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
216 "data-ending-style:opacity-0",
217 // Ending animations (direction-aware)
218 "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
219 "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
220 "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
221 "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
222 "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
223 // Ending animations (expanded)
224 "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
225 "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
226 "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
227 "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
228 )}
229 data-position={position}
230 key={toast.id}
231 swipeDirection={
232 position.includes("center")
233 ? [isTop ? "up" : "down"]
234 : position.includes("left")
235 ? ["left", isTop ? "up" : "down"]
236 : ["right", isTop ? "up" : "down"]
237 }
238 toast={toast}
239 >
240 <ThreadToastVisibleAutoDismiss
241 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
242 toastId={toast.id}
243 />
244 <Toast.Content
182245 className={cn(
183 "absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-popover not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
184 // Base positioning using data-position
185 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
186 "data-[position*=left]:right-auto data-[position*=left]:left-0",
187 "data-[position*=center]:right-0 data-[position*=center]:left-0",
188 "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
189 "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
190 // Gap fill for hover
191 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
192 "data-[position*=top]:after:top-full",
193 "data-[position*=bottom]:after:bottom-full",
194 // Define some variables
195 "[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
196 // Define offset-y variable
197 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
198 "data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
199 // Default state transform
200 "data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
201 "data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
202 // Limited state
203 "data-limited:opacity-0",
204 // Expanded state
205 "data-expanded:h-(--toast-height)",
206 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
207 // Starting and ending animations
208 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
209 "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
210 "data-ending-style:opacity-0",
211 // Ending animations (direction-aware)
212 "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
213 "data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
214 "data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
215 "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
216 "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
217 // Ending animations (expanded)
218 "data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
219 "data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
220 "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
221 "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
246 "pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-expanded:opacity-100",
247 hideCollapsedContent &&
248 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
222249 )}
223 data-position={position}
224 key={toast.id}
225 swipeDirection={
226 position.includes("center")
227 ? [isTop ? "up" : "down"]
228 : position.includes("left")
229 ? ["left", isTop ? "up" : "down"]
230 : ["right", isTop ? "up" : "down"]
231 }
232 toast={toast}
233250 >
234 <ThreadToastVisibleAutoDismiss
235 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
236 toastId={toast.id}
237 />
238 <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100">
239 <div className="flex gap-2">
240 {Icon && (
241 <div
242 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
243 data-slot="toast-icon"
244 >
245 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
246 </div>
247 )}
248
249 <div className="flex flex-col gap-0.5">
250 <Toast.Title className="font-medium" data-slot="toast-title" />
251 <Toast.Description
252 className="text-muted-foreground"
253 data-slot="toast-description"
254 />
255 </div>
256 </div>
257 {toast.actionProps && (
258 <Toast.Action
259 className={buttonVariants({ size: "xs" })}
260 data-slot="toast-action"
251 <div className="flex gap-2">
252 {Icon && (
253 <div
254 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
255 data-slot="toast-icon"
261256 >
262 {toast.actionProps.children}
263 </Toast.Action>
257 <Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
258 </div>
264259 )}
265 </Toast.Content>
266 </Toast.Root>
267 );
268 })}
260
261 <div className="flex flex-col gap-0.5">
262 <Toast.Title className="font-medium" data-slot="toast-title" />
263 <Toast.Description
264 className="text-muted-foreground"
265 data-slot="toast-description"
266 />
267 </div>
268 </div>
269 {toast.actionProps && (
270 <Toast.Action className={buttonVariants({ size: "xs" })} data-slot="toast-action">
271 {toast.actionProps.children}
272 </Toast.Action>
273 )}
274 </Toast.Content>
275 </Toast.Root>
276 );
277 })}
269278 </Toast.Viewport>
270279 </Toast.Portal>
271280 );