1"use client";
2
3import { Spinner } from "~/components/ui/spinner";
4
5import { Toast } from "@base-ui/react/toast";
6import {
7 useEffect,
8 useMemo,
9 useState,
10 type CSSProperties,
11 type ComponentPropsWithoutRef,
12 type KeyboardEvent,
13 type ReactNode,
14} from "react";
15import { useParams } from "@tanstack/react-router";
16import { type ScopedThreadRef, type ThreadId } from "@t3tools/contracts";
17import { CircleAlertIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, XIcon } from "lucide-react";
18import { Check, ChevronDown, ChevronUp, Copy } from "lucide";
19
20import { cn } from "~/lib/utils";
21import { Button, buttonVariants } from "~/components/ui/button";
22import { MorphIcon } from "~/components/MorphIcon";
23import { useComposerDraftStore } from "~/composerDraftStore";
24import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
25import { resolveThreadRouteTarget } from "~/threadRoutes";
26import {
27 buildVisibleToastLayout,
28 hasVisibleToastAction,
29 shouldHideCollapsedToastContent,
30 shouldRenderThreadScopedToast,
31} from "./toast.logic";
32import { Tooltip, TooltipPopup, TooltipTrigger } from "./tooltip";
33
34export type ThreadToastData = {
35 threadRef?: ScopedThreadRef | null;
36 threadId?: ThreadId | null;
37 leadingIcon?: ReactNode;
38 tooltipStyle?: boolean;
39 onClose?: (() => void) | undefined;
40 dismissAfterVisibleMs?: number;
41 hideCopyButton?: boolean;
42 additionalActions?: ReadonlyArray<{
43 id: string;
44 props: ComponentPropsWithoutRef<"button">;
45 }>;
46 secondaryActionProps?: ComponentPropsWithoutRef<"button">;
47 secondaryActionVariant?:
48 | "default"
49 | "destructive"
50 | "destructive-outline"
51 | "ghost"
52 | "link"
53 | "outline"
54 | "secondary";
55 /** Optional extra body shown after toggling “Show details” (e.g. a list of pending RPCs). */
56 expandableContent?: ReactNode;
57 expandableLabels?: { expand?: string; collapse?: string };
58 /** When set with `expandableContent`, the summary + label act as one text disclosure (no separate chevron row). */
59 expandableDescriptionTrigger?: boolean;
60 actionLayout?: "inline" | "stacked-end";
61 actionVariant?:
62 | "default"
63 | "destructive"
64 | "destructive-outline"
65 | "ghost"
66 | "link"
67 | "outline"
68 | "secondary";
69};
70
71const toastManager = Toast.createToastManager<ThreadToastData>();
72const anchoredToastManager = Toast.createToastManager<ThreadToastData>();
73type ToastId = ReturnType<typeof toastManager.add>;
74const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>();
75
76const TOAST_ICONS = {
77 error: CircleAlertIcon,
78 info: InfoIcon,
79 loading: Spinner,
80 success: CircleCheckIcon,
81 warning: TriangleAlertIcon,
82} as const;
83
84/** Visually shorten long error bodies; clipboard copy still uses the full `description` string. */
85const ERROR_DESCRIPTION_CLAMP_MIN_CHARS = 180;
86function errorDescriptionClampClass(type: unknown, description: unknown): string | undefined {
87 if (type !== "error" || typeof description !== "string") {
88 return undefined;
89 }
90 if (description.length < ERROR_DESCRIPTION_CLAMP_MIN_CHARS) {
91 return undefined;
92 }
93 return "line-clamp-4";
94}
95
96/** Dismiss-only: circular control overlapping the card corner (iOS notification–style). */
97const toastCornerDismissClass = "absolute z-20 -top-1.5 -right-1.5";
98const toastCornerOrbClass = cn(
99 "inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/60 bg-popover/92 text-muted-foreground shadow-sm outline-none backdrop-blur-sm",
100 "transition-[color,background-color,box-shadow] hover:bg-popover hover:text-foreground",
101 "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
102);
103
104function handleToastDismissClick(
105 manager: typeof toastManager | typeof anchoredToastManager,
106 toastId: ToastId,
107 onClose: (() => void) | undefined,
108) {
109 onClose?.();
110 manager.close(toastId);
111}
112
113function CopyErrorButton({ text }: { text: string }) {
114 const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" });
115 const label = isCopied ? "Copied error" : "Copy error";
116
117 return (
118 <Tooltip>
119 <TooltipTrigger
120 render={
121 <Button
122 size="icon-micro"
123 variant="ghost-muted"
124 aria-label={label}
125 className="[--control-icon-color:currentColor] rounded-md text-muted-foreground/80 hover:bg-transparent hover:text-muted-foreground"
126 onClick={() => copyToClipboard(text)}
127 />
128 }
129 >
130 <MorphIcon
131 className={cn("size-3", isCopied && "text-success")}
132 icon={isCopied ? Check : Copy}
133 />
134 </TooltipTrigger>
135 <TooltipPopup side="top">{label}</TooltipPopup>
136 </Tooltip>
137 );
138}
139
140/** Scrollable cap for long expandable lists (~10rem); keeps the toast from growing without bound. */
141const toastExpandablePanelClassName =
142 "mt-2 max-h-40 min-h-0 overflow-y-auto overscroll-contain pr-0.5 select-text";
143
144function ToastExpandableSection({
145 children,
146 labels,
147}: {
148 children: ReactNode;
149 labels: { expand?: string; collapse?: string };
150}) {
151 const [open, setOpen] = useState(false);
152 const expandLabel = labels.expand ?? "Show details";
153 const collapseLabel = labels.collapse ?? "Hide details";
154
155 return (
156 <div className="min-w-0">
157 <button
158 aria-expanded={open}
159 className="inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 text-left text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
160 onClick={() => setOpen((prev) => !prev)}
161 type="button"
162 >
163 <MorphIcon
164 className="size-3.5 shrink-0 opacity-80"
165 icon={open ? ChevronUp : ChevronDown}
166 strokeWidth={2.25}
167 />
168 {open ? collapseLabel : expandLabel}
169 </button>
170 {open ? <div className={toastExpandablePanelClassName}>{children}</div> : null}
171 </div>
172 );
173}
174
175function ToastDescriptionAndExpandable({
176 toastData,
177 toastDescription,
178 toastType,
179}: {
180 toastData: ThreadToastData | undefined;
181 toastDescription: unknown;
182 toastType: unknown;
183}) {
184 const expandableContent = toastData?.expandableContent;
185 const labels = toastData?.expandableLabels ?? {};
186 const descriptionTrigger = toastData?.expandableDescriptionTrigger ?? false;
187 const descriptionClassName = cn(
188 "min-w-0 select-text wrap-break-word text-muted-foreground",
189 errorDescriptionClampClass(toastType, toastDescription),
190 );
191 const [open, setOpen] = useState(false);
192
193 if (!expandableContent) {
194 return <Toast.Description className={descriptionClassName} data-slot="toast-description" />;
195 }
196
197 if (!descriptionTrigger) {
198 return (
199 <>
200 <Toast.Description className={descriptionClassName} data-slot="toast-description" />
201 <ToastExpandableSection labels={labels}>{expandableContent}</ToastExpandableSection>
202 </>
203 );
204 }
205
206 const expandLabel = labels.expand ?? "Show details";
207 const collapseLabel = labels.collapse ?? "Hide details";
208
209 const toggle = () => setOpen((v) => !v);
210 const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
211 if (event.key === "Enter" || event.key === " ") {
212 event.preventDefault();
213 toggle();
214 }
215 };
216
217 return (
218 <>
219 <Tooltip>
220 <TooltipTrigger
221 render={
222 <div
223 aria-label={open ? collapseLabel : expandLabel}
224 aria-expanded={open}
225 className={cn(
226 "group flex min-w-0 w-full cursor-pointer select-none items-start gap-1.5 rounded-sm text-left outline-none ring-offset-background",
227 "transition-colors hover:bg-muted/40",
228 "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
229 )}
230 onClick={toggle}
231 onKeyDown={onKeyDown}
232 role="button"
233 tabIndex={0}
234 />
235 }
236 >
237 <div className="min-w-0 flex-1">
238 <Toast.Description
239 className={cn(
240 "min-w-0 select-none wrap-break-word text-muted-foreground",
241 errorDescriptionClampClass(toastType, toastDescription),
242 "underline-offset-2 decoration-muted-foreground/60 group-hover:underline",
243 )}
244 data-slot="toast-description"
245 />
246 </div>
247 <MorphIcon
248 aria-hidden
249 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-80"
250 icon={open ? ChevronUp : ChevronDown}
251 strokeWidth={2.25}
252 />
253 </TooltipTrigger>
254 <TooltipPopup side="top">{open ? collapseLabel : expandLabel}</TooltipPopup>
255 </Tooltip>
256 {open ? <div className={toastExpandablePanelClassName}>{expandableContent}</div> : null}
257 </>
258 );
259}
260
261type ToastIconComponent = (typeof TOAST_ICONS)[keyof typeof TOAST_ICONS];
262
263interface ToastBodyDescriptor {
264 readonly Icon: ToastIconComponent | null | undefined;
265 readonly stackedActionLayout: boolean;
266 readonly actionVariant: NonNullable<ThreadToastData["actionVariant"]>;
267 readonly secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]>;
268 readonly copyErrorText: string | null;
269 readonly hasTrailingControls: boolean;
270 readonly inlineContentEndPad: string;
271}
272
273function deriveToastBodyDescriptor(toast: {
274 readonly type?: string | undefined;
275 readonly description?: unknown;
276 readonly actionProps?: unknown;
277 readonly data?: ThreadToastData | undefined;
278}): ToastBodyDescriptor {
279 const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null;
280 const stackedActionLayout =
281 hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end";
282 const actionVariant: NonNullable<ThreadToastData["actionVariant"]> =
283 toast.data?.actionVariant ?? "default";
284 const secondaryActionVariant: NonNullable<ThreadToastData["secondaryActionVariant"]> =
285 toast.data?.secondaryActionVariant ?? "outline";
286 const copyErrorText =
287 toast.type === "error" && typeof toast.description === "string" && !toast.data?.hideCopyButton
288 ? toast.description
289 : null;
290 const hasAdditionalActions = (toast.data?.additionalActions?.length ?? 0) > 0;
291 const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined;
292 const hasTrailingControls =
293 copyErrorText !== null ||
294 hasVisibleToastAction(toast.actionProps) ||
295 hasAdditionalActions ||
296 hasSecondaryAction;
297 const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10";
298 return {
299 Icon,
300 stackedActionLayout,
301 actionVariant,
302 secondaryActionVariant,
303 copyErrorText,
304 hasTrailingControls,
305 inlineContentEndPad,
306 };
307}
308
309interface ToastBodyContentProps extends ToastBodyDescriptor {
310 readonly actionProps: { readonly children?: ReactNode } | undefined;
311 readonly toastData: ThreadToastData | undefined;
312 readonly toastDescription: unknown;
313 readonly toastType: unknown;
314}
315
316function ToastBodyContent({
317 stackedActionLayout,
318 Icon,
319 copyErrorText,
320 actionProps,
321 actionVariant,
322 secondaryActionVariant,
323 hasTrailingControls,
324 toastData,
325 toastDescription,
326 toastType,
327}: ToastBodyContentProps) {
328 const additionalActions = toastData?.additionalActions ?? [];
329 const secondaryActionProps = toastData?.secondaryActionProps;
330 const leadingIcon = toastData?.leadingIcon;
331 const { className: secondaryActionClassName, ...secondaryActionRest } =
332 secondaryActionProps ?? {};
333
334 return (
335 <>
336 <div
337 className={cn(
338 "flex min-h-0 min-w-0 flex-col gap-0.5",
339 stackedActionLayout ? "pr-5" : "flex-1",
340 )}
341 >
342 <div className="flex min-w-0 gap-2">
343 {leadingIcon ? (
344 <div
345 className="flex h-lh w-4 shrink-0 items-center justify-center"
346 data-slot="toast-icon"
347 >
348 {leadingIcon}
349 </div>
350 ) : Icon ? (
351 <div
352 className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
353 data-slot="toast-icon"
354 >
355 <Icon className="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" />
356 </div>
357 ) : null}
358 <Toast.Title className="min-w-0 wrap-break-word font-medium" data-slot="toast-title" />
359 </div>
360 <ToastDescriptionAndExpandable
361 toastData={toastData}
362 toastDescription={toastDescription}
363 toastType={toastType}
364 />
365 </div>
366 {hasTrailingControls ? (
367 <div
368 className={cn(
369 "flex items-center gap-1.5",
370 stackedActionLayout ? "w-full justify-end" : "shrink-0",
371 )}
372 >
373 {copyErrorText !== null ? <CopyErrorButton text={copyErrorText} /> : null}
374 {additionalActions.map(({ id, props: { className, ...props } }) => (
375 <Button
376 {...props}
377 className={className}
378 key={id}
379 size="xs"
380 type="button"
381 variant={secondaryActionVariant}
382 />
383 ))}
384 {secondaryActionProps ? (
385 <Button
386 {...secondaryActionRest}
387 className={secondaryActionClassName}
388 size="xs"
389 type="button"
390 variant={secondaryActionVariant}
391 />
392 ) : null}
393 {hasVisibleToastAction(actionProps) ? (
394 <Toast.Action
395 className={cn(buttonVariants({ size: "xs", variant: actionVariant }), "shrink-0")}
396 data-slot="toast-action"
397 >
398 {actionProps?.children}
399 </Toast.Action>
400 ) : null}
401 </div>
402 ) : null}
403 </>
404 );
405}
406
407type ToastPosition =
408 | "top-left"
409 | "top-center"
410 | "top-right"
411 | "bottom-left"
412 | "bottom-center"
413 | "bottom-right";
414
415interface ToastProviderProps extends Toast.Provider.Props {
416 position?: ToastPosition;
417}
418
419function useActiveThreadRefFromRoute(): ScopedThreadRef | null {
420 const routeTarget = useParams({
421 strict: false,
422 select: (params) => resolveThreadRouteTarget(params),
423 });
424 const activeDraftSession = useComposerDraftStore((store) =>
425 routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null,
426 );
427
428 return useMemo(() => {
429 if (routeTarget?.kind === "server") {
430 return routeTarget.threadRef;
431 }
432 if (routeTarget?.kind === "draft" && activeDraftSession) {
433 return {
434 environmentId: activeDraftSession.environmentId,
435 threadId: activeDraftSession.threadId,
436 };
437 }
438 return null;
439 }, [activeDraftSession, routeTarget]);
440}
441
442function ThreadToastVisibleAutoDismiss({
443 toastId,
444 dismissAfterVisibleMs,
445}: {
446 toastId: ToastId;
447 dismissAfterVisibleMs: number | undefined;
448}) {
449 useEffect(() => {
450 if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return;
451 if (typeof window === "undefined" || typeof document === "undefined") return;
452
453 let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs;
454 let startedAtMs: number | null = null;
455 let timeoutId: number | null = null;
456 let closed = false;
457
458 const clearTimer = () => {
459 if (timeoutId === null) return;
460 window.clearTimeout(timeoutId);
461 timeoutId = null;
462 };
463
464 const closeToast = () => {
465 if (closed) return;
466 closed = true;
467 threadToastVisibleTimeoutRemainingMs.delete(toastId);
468 toastManager.close(toastId);
469 };
470
471 const pause = () => {
472 if (startedAtMs === null) return;
473 remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs));
474 startedAtMs = null;
475 clearTimer();
476 threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs);
477 };
478
479 const start = () => {
480 if (closed || startedAtMs !== null) return;
481 if (remainingMs <= 0) {
482 closeToast();
483 return;
484 }
485 startedAtMs = Date.now();
486 clearTimer();
487 timeoutId = window.setTimeout(() => {
488 remainingMs = 0;
489 startedAtMs = null;
490 closeToast();
491 }, remainingMs);
492 };
493
494 const syncTimer = () => {
495 const shouldRun = document.visibilityState === "visible" && document.hasFocus();
496 if (shouldRun) {
497 start();
498 return;
499 }
500 pause();
501 };
502
503 syncTimer();
504 document.addEventListener("visibilitychange", syncTimer);
505 window.addEventListener("focus", syncTimer);
506 window.addEventListener("blur", syncTimer);
507
508 return () => {
509 document.removeEventListener("visibilitychange", syncTimer);
510 window.removeEventListener("focus", syncTimer);
511 window.removeEventListener("blur", syncTimer);
512 pause();
513 clearTimer();
514 };
515 }, [dismissAfterVisibleMs, toastId]);
516
517 return null;
518}
519
520function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {
521 return (
522 <Toast.Provider toastManager={toastManager} {...props}>
523 {children}
524 <Toasts position={position} />
525 </Toast.Provider>
526 );
527}
528
529function Toasts({ position }: { position: ToastPosition }) {
530 const { toasts } = Toast.useToastManager<ThreadToastData>();
531 const activeThreadRef = useActiveThreadRefFromRoute();
532 const isTop = position.startsWith("top");
533 const visibleToasts = toasts.filter((toast) =>
534 shouldRenderThreadScopedToast(toast.data, activeThreadRef),
535 );
536 const visibleToastLayout = buildVisibleToastLayout(visibleToasts);
537
538 useEffect(() => {
539 const activeToastIds = new Set(toasts.map((toast) => toast.id));
540 for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) {
541 if (!activeToastIds.has(toastId)) {
542 threadToastVisibleTimeoutRemainingMs.delete(toastId);
543 }
544 }
545 }, [toasts]);
546
547 return (
548 <Toast.Portal data-slot="toast-portal">
549 <Toast.Viewport
550 className={cn(
551 "fixed z-100 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-header-offset:var(--workspace-topbar-height)] [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
552 // Vertical positioning
553 "data-[position*=top]:top-[calc(var(--toast-inset)+var(--toast-header-offset))]",
554 "data-[position*=bottom]:bottom-(--toast-inset)",
555 // Horizontal positioning
556 "data-[position*=left]:left-(--toast-inset)",
557 "data-[position*=right]:right-(--toast-inset)",
558 "data-[position*=center]:-translate-x-1/2 data-[position*=center]:left-1/2",
559 )}
560 data-position={position}
561 data-slot="toast-viewport"
562 style={
563 {
564 "--toast-frontmost-height": `${visibleToastLayout.frontmostHeight}px`,
565 } as CSSProperties
566 }
567 >
568 {visibleToastLayout.items.map(({ toast, visibleIndex, offsetY }) => {
569 const hideCollapsedContent = shouldHideCollapsedToastContent(
570 visibleIndex,
571 visibleToastLayout.items.length,
572 );
573 const bodyDescriptor = deriveToastBodyDescriptor(toast);
574 const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor;
575
576 return (
577 <Toast.Root
578 className={cn(
579 "dropdown-glass absolute z-[calc(9999-var(--toast-index))] w-full overflow-visible select-none rounded-lg text-popover-foreground shadow-xl shadow-black/25 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s]",
580 // Base positioning using data-position
581 "data-[position*=right]:right-0 data-[position*=right]:left-auto",
582 "data-[position*=left]:right-auto data-[position*=left]:left-0",
583 "data-[position*=center]:right-0 data-[position*=center]:left-0",
584 "data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-top",
585 "data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-bottom",
586 // Gap fill for hover
587 "after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
588 "data-[position*=top]:after:top-full",
589 "data-[position*=bottom]:after:bottom-full",
590 // `--toast-calc-height`: behind + collapsed = peek height only (content `opacity-0`);
591 // max(front, own) there produced a tall empty shell for long bodies.
592 visibleIndex > 0
593 ? "not-data-expanded:[--toast-calc-height:var(--toast-frontmost-height)] data-expanded:[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]"
594 : "[--toast-calc-height:max(var(--toast-frontmost-height,var(--toast-height)),var(--toast-height))]",
595 "[--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))]",
596 // Root height: never `min-h-(--toast-height)` — Base UI measures height by briefly forcing
597 // `height: auto` on this node; an old `min-height` from `--toast-height` blocks shrinking,
598 // so `recalculateHeight` keeps the inflated value after an expandable closes.
599 // Behind + collapsed: fixed peek. Otherwise natural height (expand/collapse, hover stack).
600 visibleIndex > 0
601 ? "not-data-expanded:h-(--toast-calc-height) data-expanded:h-auto"
602 : "h-auto",
603 // Define offset-y variable
604 "data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
605 "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))]",
606 // Default state transform
607 "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))]",
608 "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))]",
609 // Limited state
610 "data-limited:opacity-0",
611 // Expanded stack
612 "data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
613 // Starting and ending animations
614 "data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
615 "data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
616 "data-[position*=top]:data-[position*=right]:data-starting-style:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
617 "data-ending-style:opacity-0",
618 // Ending animations (direction-aware)
619 "data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
620 "data-[position*=top]:data-[position*=right]:data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateX(calc(100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
621 "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))]",
622 "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))]",
623 "data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
624 "data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
625 // Ending animations (expanded)
626 "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))]",
627 "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))]",
628 "data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
629 "data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
630 )}
631 data-position={position}
632 key={toast.id}
633 style={
634 {
635 "--toast-index": visibleIndex,
636 "--toast-offset-y": `${offsetY}px`,
637 } as CSSProperties
638 }
639 swipeDirection={
640 position.includes("center")
641 ? [isTop ? "up" : "down"]
642 : position.includes("left")
643 ? ["left", isTop ? "up" : "down"]
644 : ["right", isTop ? "up" : "down"]
645 }
646 toast={toast}
647 >
648 <ThreadToastVisibleAutoDismiss
649 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
650 toastId={toast.id}
651 />
652 <div className={toastCornerDismissClass}>
653 <button
654 aria-label="Dismiss notification"
655 className={toastCornerOrbClass}
656 data-slot="toast-close"
657 onClick={() =>
658 handleToastDismissClick(toastManager, toast.id, toast.data?.onClose)
659 }
660 type="button"
661 >
662 <XIcon className="size-3" strokeWidth={2.25} />
663 </button>
664 </div>
665 <Toast.Content
666 className={cn(
667 // `overflow-x: clip` avoids the CSS quirk where pairing `hidden` + `y: visible`
668 // forces `y` to `auto`. Expandable detail panels can extend below without being cut off.
669 "pointer-events-auto min-h-0 overflow-y-visible pl-3.5 text-sm transition-opacity duration-250 [overflow-x:clip] data-expanded:opacity-100",
670 stackedActionLayout
671 ? "flex flex-col gap-2 py-2.5 pr-3.5"
672 : cn("py-3", "flex items-center justify-between gap-1.5", inlineContentEndPad),
673 hideCollapsedContent &&
674 "not-data-expanded:pointer-events-none not-data-expanded:opacity-0",
675 )}
676 >
677 <ToastBodyContent
678 {...bodyDescriptor}
679 actionProps={toast.actionProps}
680 toastData={toast.data}
681 toastDescription={toast.description}
682 toastType={toast.type}
683 />
684 </Toast.Content>
685 </Toast.Root>
686 );
687 })}
688 </Toast.Viewport>
689 </Toast.Portal>
690 );
691}
692
693function AnchoredToastProvider({ children, ...props }: Toast.Provider.Props) {
694 return (
695 <Toast.Provider toastManager={anchoredToastManager} {...props}>
696 {children}
697 <AnchoredToasts />
698 </Toast.Provider>
699 );
700}
701
702function AnchoredToasts() {
703 const { toasts } = Toast.useToastManager<ThreadToastData>();
704 const activeThreadRef = useActiveThreadRefFromRoute();
705
706 return (
707 <Toast.Portal data-slot="toast-portal-anchored">
708 <Toast.Viewport className="outline-none" data-slot="toast-viewport-anchored">
709 {toasts
710 .filter((toast) => shouldRenderThreadScopedToast(toast.data, activeThreadRef))
711 .map((toast) => {
712 const tooltipStyle = toast.data?.tooltipStyle ?? false;
713 const positionerProps = toast.positionerProps;
714 const bodyDescriptor = deriveToastBodyDescriptor(toast);
715 const { stackedActionLayout, inlineContentEndPad } = bodyDescriptor;
716
717 if (!positionerProps?.anchor) {
718 return null;
719 }
720
721 return (
722 <Toast.Positioner
723 className="z-100 max-w-[min(--spacing(64),var(--available-width))]"
724 data-slot="toast-positioner"
725 key={toast.id}
726 sideOffset={positionerProps.sideOffset ?? 4}
727 toast={toast}
728 >
729 <Toast.Root
730 className={cn(
731 "dropdown-glass relative overflow-visible text-balance text-popover-foreground text-xs shadow-xl shadow-black/25 transition-[scale,opacity] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0",
732 tooltipStyle ? "rounded-md" : "rounded-lg",
733 )}
734 data-slot="toast-popup"
735 toast={toast}
736 >
737 {tooltipStyle ? (
738 <Toast.Content className="pointer-events-auto px-2 py-1">
739 <Toast.Title data-slot="toast-title" />
740 </Toast.Content>
741 ) : (
742 <>
743 <div className={toastCornerDismissClass}>
744 <button
745 aria-label="Dismiss notification"
746 className={toastCornerOrbClass}
747 data-slot="toast-close"
748 onClick={() =>
749 handleToastDismissClick(
750 anchoredToastManager,
751 toast.id,
752 toast.data?.onClose,
753 )
754 }
755 type="button"
756 >
757 <XIcon className="size-3" strokeWidth={2.25} />
758 </button>
759 </div>
760 <Toast.Content
761 className={cn(
762 "pointer-events-auto min-h-0 overflow-y-visible pl-3.5 text-sm [overflow-x:clip]",
763 stackedActionLayout
764 ? "flex flex-col gap-2 py-2.5 pr-3.5"
765 : cn(
766 "py-3",
767 "flex items-center justify-between gap-1.5",
768 inlineContentEndPad,
769 ),
770 )}
771 >
772 <ToastBodyContent
773 {...bodyDescriptor}
774 actionProps={toast.actionProps}
775 toastData={toast.data}
776 toastDescription={toast.description}
777 toastType={toast.type}
778 />
779 </Toast.Content>
780 </>
781 )}
782 </Toast.Root>
783 </Toast.Positioner>
784 );
785 })}
786 </Toast.Viewport>
787 </Toast.Portal>
788 );
789}
790
791export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers";
792export type { StackedThreadToastOptions } from "./toastHelpers";
793
794export {
795 ToastProvider,
796 type ToastPosition,
797 toastManager,
798 AnchoredToastProvider,
799 anchoredToastManager,
800};