Files
simstudioai--sim/packages/emcn/src/components/toast/toast.tsx
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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>
)
}