'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 `
` 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>> = {
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) => string
error: (message: string, options?: Omit) => string
warning: (message: string, options?: Omit) => string
info: (message: string, options?: Omit) => 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(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 ``. 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 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 ')
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(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 (
{leadingIcon}
{text}
{open ? (
{leadingIcon}
{text}
) : null}
)
}
function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: ToastItemProps) {
const contentRef = useRef(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 `