({ children }: { children?: ReactNode })
| 385 | * ``` |
| 386 | */ |
| 387 | export function ToastProvider({ children }: { children?: ReactNode }) { |
| 388 | const pathname = usePathname() |
| 389 | const reduceMotion = useReducedMotion() ?? false |
| 390 | /** On the workflow editor (`/w/[id]` and the `/w` index) the stack insets by `--panel-width` / `--terminal-height` to clear the panel and terminal. */ |
| 391 | const isWorkflowPage = pathname ? /\/w(\/|$)/.test(pathname) : false |
| 392 | |
| 393 | const [toasts, setToasts] = useState<ToastData[]>([]) |
| 394 | const [heights, setHeights] = useState<Record<string, number>>({}) |
| 395 | const [expanded, setExpanded] = useState(false) |
| 396 | const [mounted, setMounted] = useState(false) |
| 397 | const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>()) |
| 398 | |
| 399 | useEffect(() => { |
| 400 | setMounted(true) |
| 401 | }, []) |
| 402 | |
| 403 | /** |
| 404 | * Reset the hover-expanded flag whenever the stack empties. The hover wrapper |
| 405 | * unmounts without firing mouse-leave when the last toast goes (dismiss / clear |
| 406 | * / navigation), so without this `expanded` could stay `true` and stop the next |
| 407 | * toasts from auto-dismissing. |
| 408 | */ |
| 409 | useEffect(() => { |
| 410 | if (toasts.length === 0) setExpanded(false) |
| 411 | }, [toasts.length]) |
| 412 | |
| 413 | /** |
| 414 | * Adds a toast. Actionable toasts persist (`duration: 0`) unless an explicit |
| 415 | * `duration` is given. When the stack exceeds `STACK_LIMIT` the oldest |
| 416 | * auto-dismissable toast is evicted first, so a persistent (actionable) toast |
| 417 | * isn't silently dropped — only an all-persistent overflow evicts the oldest. |
| 418 | */ |
| 419 | const addToast = useCallback((input: ToastInput): string => { |
| 420 | const id = generateId() |
| 421 | const data: ToastData = { |
| 422 | id, |
| 423 | message: input.message, |
| 424 | description: input.description, |
| 425 | variant: input.variant ?? 'default', |
| 426 | action: input.action, |
| 427 | duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS), |
| 428 | persistAcrossRoutes: input.persistAcrossRoutes ?? false, |
| 429 | } |
| 430 | setToasts((prev) => { |
| 431 | const next = [...prev, data] |
| 432 | if (next.length <= STACK_LIMIT) return next |
| 433 | const evictIndex = next.findIndex((t) => t.duration > 0) |
| 434 | next.splice(evictIndex === -1 ? 0 : evictIndex, 1) |
| 435 | return next |
| 436 | }) |
| 437 | return id |
| 438 | }, []) |
| 439 | |
| 440 | const dismissToast = useCallback((id: string) => { |
| 441 | const timer = timersRef.current.get(id) |
| 442 | if (timer) { |
| 443 | clearTimeout(timer) |
| 444 | timersRef.current.delete(id) |
nothing calls this directly
no test coverage detected