History

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

Julius Marminge committed 8 months ago

Commit 0beaf091 parent 38639013 files · +122 −88Browse files

Unified · Split

3 files
  1. apps/web/src/components/ui/toast.logic.test.ts +16 −0
  2. apps/web/src/components/ui/toast.logic.ts +9 −0
  3. apps/web/src/components/ui/toast.tsx +97 −88
apps/web/src/components/ui/toast.logic.test.ts+16−0
@@ -0,0 +1,16 @@
1+import { assert, describe, it } from "vitest";
2+import { shouldHideCollapsedToastContent } from "./toast.logic";
3+
4+describe("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+9−0
@@ -0,0 +1,9 @@
1+export 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 { · 12 unmodified lines
1313 import { cn } from "~/lib/utils";
1414 import { buttonVariants } from "~/components/ui/button";
1515 import { useStore } from "~/store";
16+import { shouldHideCollapsedToastContent } from "./toast.logic";
1617
1718 type ThreadToastData = {
1819 threadId?: string | null;
@@ -146,6 +147,9 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { · 127 unmodified lines
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 }) { · 20 unmodified lines
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 );