d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
641 lines
22 KiB
TypeScript
641 lines
22 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type ComponentType,
|
|
createContext,
|
|
type ReactNode,
|
|
type SVGProps,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
|
import { usePathname } from 'next/navigation'
|
|
import { createPortal } from 'react-dom'
|
|
import { Bell } from '../../icons/bell'
|
|
import { CircleAlert } from '../../icons/circle-alert'
|
|
import { CircleCheck } from '../../icons/circle-check'
|
|
import { CircleInfo } from '../../icons/circle-info'
|
|
import { TriangleAlert } from '../../icons/triangle-alert'
|
|
import { X } from '../../icons/x'
|
|
import { cn } from '../../lib/cn'
|
|
import { Button } from '../button/button'
|
|
import { Chip } from '../chip/chip'
|
|
import { chipContentIconClass, chipFilledFillTokens } from '../chip/chip-chrome'
|
|
|
|
const AUTO_DISMISS_MS = 5000
|
|
|
|
/** Card width; tracks the workflow-panel inset on narrow viewports. */
|
|
const TOAST_WIDTH = 'min(100vw - 2rem, 280px)'
|
|
|
|
/** Most toasts kept alive at once; older arrivals are evicted. */
|
|
const STACK_LIMIT = 3
|
|
/** Per-depth lift and shrink that make collapsed cards peek above the front one. */
|
|
const COLLAPSED_OFFSET_PX = 13
|
|
const COLLAPSED_SCALE_STEP = 0.05
|
|
/** Vertical gap between cards once the stack is expanded. */
|
|
const EXPAND_GAP_PX = 8
|
|
|
|
/** Fallback card height used for the expanded fan-out before a toast is measured. */
|
|
const ESTIMATED_TOAST_HEIGHT = 56
|
|
|
|
/** Card border box, added to the measured content so the clamped `<li>` height matches. */
|
|
const CARD_BORDER_PX = 2
|
|
|
|
/** Shared expo-out easing so every card in the stack reshuffles with identical timing. */
|
|
const TOAST_EASE = [0.22, 1, 0.36, 1] as const
|
|
const STACK_DURATION = 0.4
|
|
const RESIZE_DURATION = 0.3
|
|
|
|
/** Single-line cards use a tighter corner radius; taller cards keep the concentric 16px. */
|
|
const COMPACT_CARD_HEIGHT_PX = 46
|
|
const COMPACT_RADIUS_PX = 12
|
|
const CONCENTRIC_RADIUS_PX = 16
|
|
|
|
type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
|
|
|
|
/** Leading icon per variant; the shape alone signals intent, tinted with the canonical chip icon color. */
|
|
const VARIANT_ICON: Record<ToastVariant, ComponentType<SVGProps<SVGSVGElement>>> = {
|
|
default: Bell,
|
|
info: CircleInfo,
|
|
success: CircleCheck,
|
|
warning: TriangleAlert,
|
|
error: CircleAlert,
|
|
}
|
|
|
|
interface ToastAction {
|
|
label: string
|
|
onClick: () => void
|
|
}
|
|
|
|
interface ToastData {
|
|
id: string
|
|
message: string
|
|
description?: string
|
|
variant: ToastVariant
|
|
action?: ToastAction
|
|
duration: number
|
|
persistAcrossRoutes: boolean
|
|
}
|
|
|
|
type ToastInput = {
|
|
message: string
|
|
description?: string
|
|
variant?: ToastVariant
|
|
action?: ToastAction
|
|
duration?: number
|
|
/**
|
|
* Keep the toast across navigation. The stack is otherwise cleared on every
|
|
* route change (route-scoped notifications shouldn't trail the user); set
|
|
* this for global, ongoing-state toasts like a connection/reconnect status.
|
|
* @default false
|
|
*/
|
|
persistAcrossRoutes?: boolean
|
|
}
|
|
|
|
type ToastFn = {
|
|
(input: ToastInput): string
|
|
success: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
|
|
error: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
|
|
warning: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
|
|
info: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
|
|
/** Dismisses a single toast by id. */
|
|
dismiss: (id: string) => void
|
|
/** Dismisses every visible toast. */
|
|
dismissAll: () => void
|
|
}
|
|
|
|
interface ToastContextValue {
|
|
toast: ToastFn
|
|
dismiss: (id: string) => void
|
|
dismissAll: () => void
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(null)
|
|
|
|
let globalToast: ToastFn | null = null
|
|
let globalDismiss: ((id: string) => void) | null = null
|
|
let globalDismissAll: (() => void) | null = null
|
|
|
|
function createToastFn(add: (input: ToastInput) => string): ToastFn {
|
|
const fn = ((input: ToastInput) => add(input)) as ToastFn
|
|
fn.success = (message, options) => add({ ...options, message, variant: 'success' })
|
|
fn.error = (message, options) => add({ ...options, message, variant: 'error' })
|
|
fn.warning = (message, options) => add({ ...options, message, variant: 'warning' })
|
|
fn.info = (message, options) => add({ ...options, message, variant: 'info' })
|
|
fn.dismiss = (id) => globalDismiss?.(id)
|
|
fn.dismissAll = () => globalDismissAll?.()
|
|
return fn
|
|
}
|
|
|
|
/**
|
|
* Imperative toast. Requires a mounted `<ToastProvider>`. A toast carrying an
|
|
* `action` persists until dismissed unless an explicit `duration` is passed.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* toast.error('Upload failed', { description: 'Network timed out' })
|
|
* toast.success('Saved', { action: { label: 'View', onClick: () => router.push('/x') } })
|
|
* ```
|
|
*/
|
|
export const toast: ToastFn = createToastFn((input) => {
|
|
if (!globalToast) {
|
|
throw new Error('toast() called before <ToastProvider> mounted')
|
|
}
|
|
return globalToast(input)
|
|
})
|
|
|
|
/** Hook to access the toast function and dismiss helpers from context. */
|
|
export function useToast() {
|
|
const ctx = useContext(ToastContext)
|
|
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
|
|
return ctx
|
|
}
|
|
|
|
interface ToastGeometry {
|
|
/** Vertical lift from the bottom anchor (negative = upward). */
|
|
y: number
|
|
/** Depth shrink; `1` when expanded or for the front card. */
|
|
scale: number
|
|
/** Rendered height; collapsed back cards are clamped to the front card's height. */
|
|
height: number
|
|
/** Paint order — the front card sits on top. */
|
|
zIndex: number
|
|
}
|
|
|
|
interface ToastItemProps {
|
|
toast: ToastData
|
|
geometry: ToastGeometry
|
|
reduceMotion: boolean
|
|
onDismiss: (id: string) => void
|
|
onMeasure: (id: string, height: number) => void
|
|
}
|
|
|
|
interface RevealTextProps {
|
|
text: string
|
|
/** Whether the parent card is hovered — the trigger for revealing hidden lines. */
|
|
expanded: boolean
|
|
/** Lines kept visible before truncation. */
|
|
clampLines: number
|
|
/** Line height in px; must match the text leading so the head/tail split lands on a line boundary. */
|
|
lineHeightPx: number
|
|
/** Optional inline icon; included in head and tail so wrapping stays identical. */
|
|
leadingIcon?: ReactNode
|
|
className?: string
|
|
reduceMotion: boolean
|
|
}
|
|
|
|
/**
|
|
* Truncated text that reveals its hidden lines on hover: the head shows the
|
|
* first `clampLines` lines, and when hovered (and actually overflowing) the
|
|
* remaining lines mount below and blur in as one continuous block. Text that
|
|
* fits within the clamp is never truncated and the card never grows for it.
|
|
*/
|
|
function RevealText({
|
|
text,
|
|
expanded,
|
|
clampLines,
|
|
lineHeightPx,
|
|
leadingIcon,
|
|
className,
|
|
reduceMotion,
|
|
}: RevealTextProps) {
|
|
const headRef = useRef<HTMLDivElement>(null)
|
|
const [truncated, setTruncated] = useState(false)
|
|
const clampHeight = clampLines * lineHeightPx
|
|
|
|
useLayoutEffect(() => {
|
|
const el = headRef.current
|
|
if (!el) return
|
|
const check = () => setTruncated(el.scrollHeight - el.clientHeight > 1)
|
|
check()
|
|
const observer = new ResizeObserver(check)
|
|
observer.observe(el)
|
|
return () => observer.disconnect()
|
|
}, [text])
|
|
|
|
const open = expanded && truncated
|
|
const fadeStart = Math.max(0, clampHeight - lineHeightPx * 1.5)
|
|
const collapsedMask = `linear-gradient(to bottom, #000 ${fadeStart}px, transparent ${clampHeight}px)`
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
ref={headRef}
|
|
className={cn('overflow-hidden', className)}
|
|
style={{
|
|
maxHeight: clampHeight,
|
|
maskImage: truncated && !open ? collapsedMask : undefined,
|
|
WebkitMaskImage: truncated && !open ? collapsedMask : undefined,
|
|
}}
|
|
>
|
|
{leadingIcon}
|
|
{text}
|
|
</div>
|
|
<AnimatePresence initial={false}>
|
|
{open ? (
|
|
<motion.div
|
|
className='overflow-hidden'
|
|
aria-hidden
|
|
initial={reduceMotion ? false : { height: 0 }}
|
|
animate={{ height: 'auto' }}
|
|
exit={{ height: 0 }}
|
|
transition={
|
|
reduceMotion ? { duration: 0 } : { duration: RESIZE_DURATION, ease: TOAST_EASE }
|
|
}
|
|
>
|
|
<motion.div
|
|
className={className}
|
|
style={{ marginTop: -clampHeight }}
|
|
initial={reduceMotion ? false : { opacity: 0, filter: 'blur(5px)' }}
|
|
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
|
transition={
|
|
reduceMotion ? { duration: 0 } : { duration: RESIZE_DURATION, ease: [0.2, 0, 0, 1] }
|
|
}
|
|
>
|
|
{leadingIcon}
|
|
{text}
|
|
</motion.div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: ToastItemProps) {
|
|
const contentRef = useRef<HTMLDivElement>(null)
|
|
const [hovered, setHovered] = useState(false)
|
|
const Icon = VARIANT_ICON[t.variant]
|
|
|
|
/**
|
|
* Report the natural height — measured on the unconstrained inner content so
|
|
* the outer `<li>` clamp can't feed back — so the provider lays the fan and
|
|
* collapsed clamp against real heights. `useLayoutEffect` avoids a paint jump.
|
|
*/
|
|
useLayoutEffect(() => {
|
|
const el = contentRef.current
|
|
if (!el) return
|
|
const report = () => onMeasure(t.id, el.offsetHeight + CARD_BORDER_PX)
|
|
report()
|
|
const observer = new ResizeObserver(report)
|
|
observer.observe(el)
|
|
return () => observer.disconnect()
|
|
}, [t.id, onMeasure])
|
|
|
|
const dismiss = useCallback(() => onDismiss(t.id), [onDismiss, t.id])
|
|
|
|
const { y, scale, height, zIndex } = geometry
|
|
const cornerRadius = height <= COMPACT_CARD_HEIGHT_PX ? COMPACT_RADIUS_PX : CONCENTRIC_RADIUS_PX
|
|
/** One fixed-duration tween so all cards reshuffle in unison; height tracks content instantly. */
|
|
const transition = reduceMotion
|
|
? { duration: 0 }
|
|
: {
|
|
duration: STACK_DURATION,
|
|
ease: TOAST_EASE,
|
|
height: { duration: 0 },
|
|
}
|
|
|
|
return (
|
|
<motion.li
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
initial={reduceMotion ? false : { opacity: 0, y: height }}
|
|
animate={{ opacity: 1, y, scale, height }}
|
|
exit={{
|
|
opacity: 0,
|
|
scale: 0.95,
|
|
transition: reduceMotion ? { duration: 0 } : { duration: 0.15, ease: 'easeIn' },
|
|
}}
|
|
transition={transition}
|
|
style={{ zIndex, transformOrigin: 'bottom', width: TOAST_WIDTH, borderRadius: cornerRadius }}
|
|
className='pointer-events-auto absolute right-0 bottom-0 m-0 overflow-hidden border border-[var(--border-1)] bg-[var(--bg)] shadow-[var(--shadow-overlay)]'
|
|
>
|
|
<div ref={contentRef} className='flex flex-col gap-2 p-2'>
|
|
<div className='flex items-start gap-2'>
|
|
<div className='min-w-0 flex-1'>
|
|
<RevealText
|
|
text={t.message}
|
|
expanded={hovered}
|
|
clampLines={2}
|
|
lineHeightPx={20}
|
|
leadingIcon={
|
|
<span className='mr-1.5 inline-flex h-5 items-center align-top'>
|
|
<Icon className={chipContentIconClass} />
|
|
</span>
|
|
}
|
|
className='text-[var(--text-body)] text-sm leading-5'
|
|
reduceMotion={reduceMotion}
|
|
/>
|
|
</div>
|
|
<div className='flex h-5 flex-shrink-0 items-center'>
|
|
<Button
|
|
variant='quiet'
|
|
onClick={dismiss}
|
|
aria-label='Dismiss notification'
|
|
title='Dismiss'
|
|
className='size-[18px] rounded-sm p-0'
|
|
>
|
|
<X className='size-[16px]' />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{t.description ? (
|
|
<RevealText
|
|
text={t.description}
|
|
expanded={hovered}
|
|
clampLines={3}
|
|
lineHeightPx={18}
|
|
className='text-[var(--text-muted)] text-small leading-[18px]'
|
|
reduceMotion={reduceMotion}
|
|
/>
|
|
) : null}
|
|
{t.action ? (
|
|
<Chip
|
|
fullWidth
|
|
flush
|
|
onClick={() => {
|
|
t.action!.onClick()
|
|
dismiss()
|
|
}}
|
|
className={cn('justify-center', chipFilledFillTokens)}
|
|
>
|
|
{t.action.label}
|
|
</Chip>
|
|
) : null}
|
|
</div>
|
|
</motion.li>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Toast container, mounted once in the root layout. Toasts pile bottom-right as
|
|
* a collapsed stack that fans open on hover or keyboard focus, mirroring the
|
|
* Sonner / Base-UI interaction; hovering pauses auto-dismiss. On workflow pages
|
|
* the stack is inset by the panel and terminal, and it clears on navigation.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <ToastProvider />
|
|
* ```
|
|
*/
|
|
export function ToastProvider({ children }: { children?: ReactNode }) {
|
|
const pathname = usePathname()
|
|
const reduceMotion = useReducedMotion() ?? false
|
|
/** On the workflow editor (`/w/[id]` and the `/w` index) the stack insets by `--panel-width` / `--terminal-height` to clear the panel and terminal. */
|
|
const isWorkflowPage = pathname ? /\/w(\/|$)/.test(pathname) : false
|
|
|
|
const [toasts, setToasts] = useState<ToastData[]>([])
|
|
const [heights, setHeights] = useState<Record<string, number>>({})
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [mounted, setMounted] = useState(false)
|
|
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
/**
|
|
* Reset the hover-expanded flag whenever the stack empties. The hover wrapper
|
|
* unmounts without firing mouse-leave when the last toast goes (dismiss / clear
|
|
* / navigation), so without this `expanded` could stay `true` and stop the next
|
|
* toasts from auto-dismissing.
|
|
*/
|
|
useEffect(() => {
|
|
if (toasts.length === 0) setExpanded(false)
|
|
}, [toasts.length])
|
|
|
|
/**
|
|
* Adds a toast. Actionable toasts persist (`duration: 0`) unless an explicit
|
|
* `duration` is given. When the stack exceeds `STACK_LIMIT` the oldest
|
|
* auto-dismissable toast is evicted first, so a persistent (actionable) toast
|
|
* isn't silently dropped — only an all-persistent overflow evicts the oldest.
|
|
*/
|
|
const addToast = useCallback((input: ToastInput): string => {
|
|
const id = generateId()
|
|
const data: ToastData = {
|
|
id,
|
|
message: input.message,
|
|
description: input.description,
|
|
variant: input.variant ?? 'default',
|
|
action: input.action,
|
|
duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS),
|
|
persistAcrossRoutes: input.persistAcrossRoutes ?? false,
|
|
}
|
|
setToasts((prev) => {
|
|
const next = [...prev, data]
|
|
if (next.length <= STACK_LIMIT) return next
|
|
const evictIndex = next.findIndex((t) => t.duration > 0)
|
|
next.splice(evictIndex === -1 ? 0 : evictIndex, 1)
|
|
return next
|
|
})
|
|
return id
|
|
}, [])
|
|
|
|
const dismissToast = useCallback((id: string) => {
|
|
const timer = timersRef.current.get(id)
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timersRef.current.delete(id)
|
|
}
|
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
setHeights((prev) => {
|
|
if (!(id in prev)) return prev
|
|
const next = { ...prev }
|
|
delete next[id]
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const dismissAllToasts = useCallback(() => {
|
|
for (const timer of timersRef.current.values()) clearTimeout(timer)
|
|
timersRef.current.clear()
|
|
setToasts([])
|
|
setHeights({})
|
|
}, [])
|
|
|
|
/**
|
|
* Clear only route-scoped toasts. Toasts flagged `persistAcrossRoutes` —
|
|
* global, ongoing-state notifications like the connection status — survive,
|
|
* everything else (page-scoped notifications) is cleared on navigation.
|
|
*/
|
|
const dismissRouteScopedToasts = useCallback(() => {
|
|
setToasts((prev) => {
|
|
const kept = prev.filter((t) => t.persistAcrossRoutes)
|
|
if (kept.length === prev.length) return prev
|
|
for (const t of prev) {
|
|
if (t.persistAcrossRoutes) continue
|
|
const timer = timersRef.current.get(t.id)
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timersRef.current.delete(t.id)
|
|
}
|
|
}
|
|
return kept
|
|
})
|
|
}, [])
|
|
|
|
const measureToast = useCallback((id: string, height: number) => {
|
|
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }))
|
|
}, [])
|
|
|
|
/** Drop measured heights for toasts evicted by `slice(-STACK_LIMIT)` (single dismissal prunes its own entry). */
|
|
useEffect(() => {
|
|
setHeights((prev) => {
|
|
const live = new Set(toasts.map((t) => t.id))
|
|
const stale = Object.keys(prev).filter((id) => !live.has(id))
|
|
if (stale.length === 0) return prev
|
|
const next = { ...prev }
|
|
for (const id of stale) delete next[id]
|
|
return next
|
|
})
|
|
}, [toasts])
|
|
|
|
/**
|
|
* Per-toast auto-dismiss timers. Each timed toast runs its own timer so it
|
|
* expires independently regardless of how many toasts are stacked; persistent
|
|
* toasts (`duration <= 0`) never get a timer, and hovering (`expanded`) holds
|
|
* every timer so a toast can't be cleared mid-read.
|
|
*/
|
|
useEffect(() => {
|
|
const timers = timersRef.current
|
|
if (toasts.length === 0 || expanded) {
|
|
for (const timer of timers.values()) clearTimeout(timer)
|
|
timers.clear()
|
|
return
|
|
}
|
|
|
|
for (const t of toasts) {
|
|
if (t.duration <= 0 || timers.has(t.id)) continue
|
|
timers.set(
|
|
t.id,
|
|
setTimeout(() => {
|
|
timers.delete(t.id)
|
|
dismissToast(t.id)
|
|
}, t.duration)
|
|
)
|
|
}
|
|
|
|
for (const [id, timer] of timers) {
|
|
if (!toasts.some((t) => t.id === id)) {
|
|
clearTimeout(timer)
|
|
timers.delete(id)
|
|
}
|
|
}
|
|
}, [toasts, expanded, dismissToast])
|
|
|
|
useEffect(() => {
|
|
const timers = timersRef.current
|
|
return () => {
|
|
for (const timer of timers.values()) clearTimeout(timer)
|
|
}
|
|
}, [])
|
|
|
|
/** On navigation, clear route-scoped toasts so they don't trail the user; `persistAcrossRoutes` toasts survive. */
|
|
useEffect(() => {
|
|
dismissRouteScopedToasts()
|
|
}, [pathname, dismissRouteScopedToasts])
|
|
|
|
/** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */
|
|
const toastFn = useRef<ToastFn>(createToastFn(addToast))
|
|
|
|
useEffect(() => {
|
|
const fn = toastFn.current
|
|
globalToast = fn
|
|
globalDismiss = dismissToast
|
|
globalDismissAll = dismissAllToasts
|
|
return () => {
|
|
if (globalToast === fn) globalToast = null
|
|
if (globalDismiss === dismissToast) globalDismiss = null
|
|
if (globalDismissAll === dismissAllToasts) globalDismissAll = null
|
|
}
|
|
}, [dismissToast, dismissAllToasts])
|
|
|
|
const ctx = useMemo<ToastContextValue>(
|
|
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll: dismissAllToasts }),
|
|
[dismissToast, dismissAllToasts]
|
|
)
|
|
|
|
const ordered = [...toasts].reverse()
|
|
const frontHeight = heights[ordered[0]?.id ?? ''] ?? ESTIMATED_TOAST_HEIGHT
|
|
let cumulative = 0
|
|
const layout = ordered.map((toast, index) => {
|
|
const naturalHeight = heights[toast.id] ?? ESTIMATED_TOAST_HEIGHT
|
|
const offsetBefore = cumulative
|
|
cumulative += naturalHeight + EXPAND_GAP_PX
|
|
const geometry: ToastGeometry = {
|
|
y: expanded ? -offsetBefore : -index * COLLAPSED_OFFSET_PX,
|
|
scale: expanded ? 1 : 1 - index * COLLAPSED_SCALE_STEP,
|
|
height: expanded || index === 0 ? naturalHeight : frontHeight,
|
|
zIndex: ordered.length - index,
|
|
}
|
|
return { toast, geometry }
|
|
})
|
|
|
|
const collapsedHeight =
|
|
frontHeight + Math.max(0, Math.min(ordered.length, STACK_LIMIT) - 1) * COLLAPSED_OFFSET_PX
|
|
const expandedHeight = cumulative > 0 ? cumulative - EXPAND_GAP_PX : frontHeight
|
|
const containerHeight = expanded ? expandedHeight : collapsedHeight
|
|
|
|
return (
|
|
<ToastContext.Provider value={ctx}>
|
|
{children}
|
|
{mounted
|
|
? createPortal(
|
|
<AnimatePresence>
|
|
{toasts.length > 0 ? (
|
|
<motion.ol
|
|
key='toast-stack'
|
|
aria-live='polite'
|
|
aria-label='Notifications'
|
|
className='fixed z-[var(--z-toast)] m-0 list-none p-0'
|
|
exit={{
|
|
opacity: 0,
|
|
transition: reduceMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeIn' },
|
|
}}
|
|
style={{
|
|
right: isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px',
|
|
bottom: isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px',
|
|
width: TOAST_WIDTH,
|
|
height: containerHeight,
|
|
}}
|
|
>
|
|
<div
|
|
onMouseEnter={() => setExpanded(true)}
|
|
onMouseLeave={() => setExpanded(false)}
|
|
onFocusCapture={() => setExpanded(true)}
|
|
onBlurCapture={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
setExpanded(false)
|
|
}
|
|
}}
|
|
className='absolute inset-0'
|
|
>
|
|
<AnimatePresence>
|
|
{layout.map(({ toast, geometry }) => (
|
|
<ToastItem
|
|
key={toast.id}
|
|
toast={toast}
|
|
geometry={geometry}
|
|
reduceMotion={reduceMotion}
|
|
onDismiss={dismissToast}
|
|
onMeasure={measureToast}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</motion.ol>
|
|
) : null}
|
|
</AnimatePresence>,
|
|
document.body
|
|
)
|
|
: null}
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|