"use client"; import { CircleAlert, CircleCheck, X } from "lucide-react"; import { type CSSProperties, type PointerEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, } from "react"; import { cn } from "@/lib/utils"; export type ToastTone = "neutral" | "success" | "error"; export interface ToastAction { label: string; onClick: () => void; } export interface ToastOptions { /** Second line with more detail. */ description?: string; /** Adds a status icon. Default "neutral". */ tone?: ToastTone; /** Time on screen in ms. By default it is worked out from the word count. */ duration?: number; /** One button, such as Undo. Pressing it also closes the toast. */ action?: ToastAction; } type ToastItem = { id: number; title: string; description?: string; tone: ToastTone; duration: number; action?: ToastAction; closing: boolean; swiped: boolean; }; /** Cards visible in the stack. Older ones wait out of sight until there is room. */ const VISIBLE = 3; /** Older toasts are closed once this many are waiting. */ const MAX_QUEUED = 8; /** Space between cards when the stack is spread out. */ const GAP = 10; /** How far each card behind the front one peeks out above it. */ const PEEK = 12; /** How much smaller each card behind the front one is. */ const SCALE_STEP = 0.05; const MOVE_MS = 400; /** Exits are quicker than entrances. */ const EXIT_MS = 200; /** Drag distance, in px, that dismisses a toast. */ const SWIPE_DISTANCE = 45; /** Drag speed, in px per ms, that dismisses a toast even when the drag is short. */ const SWIPE_VELOCITY = 0.11; const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; /** An average reading speed, in words per minute. */ const READING_WPM = 220; let items: ToastItem[] = []; let nextId = 0; const listeners = new Set<() => void>(); const EMPTY: ToastItem[] = []; function emit() { for (const listener of listeners) listener(); } function subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; } /** Long enough to read the message once at a normal pace, plus a moment to notice it. */ function readingTime(text: string) { const words = text.trim().split(/\s+/).filter(Boolean).length; return Math.min(10_000, Math.max(3000, 1500 + (words / READING_WPM) * 60_000)); } function dismiss(id: number, swiped = false) { if (!items.some((item) => item.id === id && !item.closing)) return; items = items.map((item) => (item.id === id ? { ...item, closing: true, swiped } : item)); emit(); setTimeout(() => { items = items.filter((item) => item.id !== id); emit(); }, EXIT_MS); } function show(title: string, options: ToastOptions = {}) { nextId += 1; const { description, tone = "neutral", action } = options; const duration = options.duration ?? readingTime(`${title} ${description ?? ""}`); items = [ ...items, { id: nextId, title, description, tone, duration, action, closing: false, swiped: false }, ]; emit(); const open = items.filter((item) => !item.closing); for (const item of open.slice(0, Math.max(0, open.length - MAX_QUEUED))) dismiss(item.id); return nextId; } /** Shows a toast and returns its id. Render one Toaster somewhere in the app. */ export const toast = Object.assign(show, { success: (title: string, options?: Omit) => show(title, { ...options, tone: "success" }), error: (title: string, options?: Omit) => show(title, { ...options, tone: "error" }), dismiss: (id: number) => dismiss(id), }); function useMediaQuery(query: string) { const [matches, setMatches] = useState(false); useEffect(() => { const list = window.matchMedia(query); const update = () => setMatches(list.matches); update(); list.addEventListener("change", update); return () => list.removeEventListener("change", update); }, [query]); return matches; } /** True while the tab is in the background. */ function useDocumentHidden() { const [hidden, setHidden] = useState(false); useEffect(() => { const update = () => setHidden(document.hidden); update(); document.addEventListener("visibilitychange", update); return () => document.removeEventListener("visibilitychange", update); }, []); return hidden; } const TONE_ICON: Record = { neutral: null, success: