import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { createPortal } from 'react-dom'; import { Tone, Surface, cn } from '../common'; // PixelToast.tsx imports `ToastItem` (and friends) back from this module as // type-only imports (erased at runtime), so this value import does NOT form a // runtime cycle. import { PixelToast } from './PixelToast'; /* ────────────────────────────────────────────────────────────────────────── Types ────────────────────────────────────────────────────────────────────────── */ export type ToastTone = Tone; export type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'; /** Shape of an individual toast notification. */ export interface ToastItem { id: string; title: string; message?: string; tone?: ToastTone; /** Auto-dismiss in ms. Set to `0` to disable. Defaults to 4500. */ duration?: number; /** Optional icon node rendered to the left of the title. */ icon?: React.ReactNode; /** * Optional animated pxlkit icon node. When provided, takes precedence over `icon` * and is rendered with the same slot semantics. Typically ``. */ animatedIcon?: React.ReactNode; /** Optional inline action (typically a {@link PixelButton} or link). */ action?: React.ReactNode; /** When `true`, render with `role="alert"` (assertive). Defaults based on tone. */ assertive?: boolean; /** * Show a leading spinner (used by `toast.loading()` and `toast.promise()`'s * loading state). Has no auto-dismiss unless `duration` is also set. */ loading?: boolean; } /** Input shape accepted by {@link useToast}. `id` is generated if omitted. */ export type ToastInput = Omit & { id?: string }; /** Patch shape for {@link UseToastReturn.update}. */ export type ToastPatch = Partial>; /** Options for {@link UseToastReturn.promise}. */ export interface ToastPromiseOptions { loading: Omit; success: Omit | ((value: T) => Omit); error: Omit | ((err: unknown) => Omit); } interface ToastContextValue { toasts: ToastItem[]; push: (toast: ToastInput) => string; update: (id: string, patch: ToastPatch) => void; dismiss: (id: string) => void; clear: () => void; } const ToastContext = createContext(null); /* ────────────────────────────────────────────────────────────────────────── useToast — primary public API. ────────────────────────────────────────────────────────────────────────── */ /** Shorthand fn for tone-locked convenience helpers (success/error/info/warning/loading). */ export type ToastShortcut = ( titleOrInput: string | Omit, message?: string, ) => string; export interface UseToastReturn { /** Push a toast. Returns the toast id (useful for `dismiss()`). */ toast: ((input: ToastInput) => string) & { success: ToastShortcut; error: ToastShortcut; info: ToastShortcut; warning: ToastShortcut; loading: ToastShortcut; update: (id: string, patch: ToastPatch) => void; dismiss: (id: string) => void; promise: (p: Promise | (() => Promise), opts: ToastPromiseOptions) => Promise; }; /** Dismiss a specific toast by id. */ dismiss: (id: string) => void; /** Update an existing toast in place (merge patch). */ update: (id: string, patch: ToastPatch) => void; /** Clear every active toast immediately. */ clear: () => void; /** Current active toasts (read-only). */ toasts: ToastItem[]; } function normalizeShortcutArgs( titleOrInput: string | Omit, message?: string, ): Omit { if (typeof titleOrInput === 'string') { return message != null ? { title: titleOrInput, message } : { title: titleOrInput }; } return titleOrInput; } export function useToast(): UseToastReturn { const ctx = useContext(ToastContext); if (!ctx) { throw new Error('useToast must be used inside .'); } const { push, update, dismiss, clear, toasts } = ctx; // Build the callable+attached `toast` once per ctx identity. const toast = useMemo(() => { const fn = ((input: ToastInput) => push(input)) as UseToastReturn['toast']; fn.success = (t, m) => push({ ...normalizeShortcutArgs(t, m), tone: 'green' }); fn.error = (t, m) => push({ ...normalizeShortcutArgs(t, m), tone: 'red' }); fn.info = (t, m) => push({ ...normalizeShortcutArgs(t, m), tone: 'cyan' }); fn.warning = (t, m) => push({ ...normalizeShortcutArgs(t, m), tone: 'gold' }); fn.loading = (t, m) => push({ ...normalizeShortcutArgs(t, m), tone: 'cyan', loading: true, duration: 0, }); fn.update = update; fn.dismiss = dismiss; fn.promise = ( p: Promise | (() => Promise), opts: ToastPromiseOptions, ): Promise => { const id = push({ ...opts.loading, tone: 'cyan', loading: true, duration: 0, }); const promise = typeof p === 'function' ? (p as () => Promise)() : p; return promise.then( (value) => { const patch = typeof opts.success === 'function' ? opts.success(value) : opts.success; update(id, { tone: 'green', loading: false, duration: patch.duration ?? 4500, ...patch, }); return value; }, (err) => { const patch = typeof opts.error === 'function' ? opts.error(err) : opts.error; update(id, { tone: 'red', loading: false, duration: patch.duration ?? 6000, ...patch, }); throw err; }, ); }; return fn; }, [push, update, dismiss]); return { toast, dismiss, update, clear, toasts }; } /* ────────────────────────────────────────────────────────────────────────── PxlKitToastProvider — mount once near the app root. ────────────────────────────────────────────────────────────────────────── */ export interface PxlKitToastProviderProps { children: React.ReactNode; position?: ToastPosition; /** Maximum simultaneous toasts. Oldest is dropped if exceeded. Defaults to 5. */ max?: number; surface?: Surface; /** * Sonner-style stacked-offset visual: toasts collapse into a small stack * showing only the front card; hover expands them into a vertical list. * Defaults to `true`. */ stacked?: boolean; /** How many additional cards peek behind the front when stacked. Defaults to 2. */ stackVisible?: number; } let toastSeq = 0; const nextId = () => `pxl-toast-${++toastSeq}`; export function PxlKitToastProvider({ children, position = 'top-right', max = 5, surface, stacked = true, stackVisible = 2, }: PxlKitToastProviderProps) { const [toasts, setToasts] = useState([]); const dismiss = useCallback((id: string) => { setToasts((cur) => cur.filter((t) => t.id !== id)); }, []); const push = useCallback( (input: ToastInput) => { const id = input.id ?? nextId(); setToasts((cur) => { const next = [...cur.filter((t) => t.id !== id), { id, duration: 4500, ...input }]; return next.length > max ? next.slice(next.length - max) : next; }); return id; }, [max], ); const update = useCallback((id: string, patch: ToastPatch) => { setToasts((cur) => cur.map((t) => (t.id === id ? { ...t, ...patch } : t))); }, []); const clear = useCallback(() => setToasts([]), []); const value = useMemo( () => ({ toasts, push, update, dismiss, clear }), [toasts, push, update, dismiss, clear], ); return ( {children} ); } /* ────────────────────────────────────────────────────────────────────────── Toast viewport (portal target). ────────────────────────────────────────────────────────────────────────── */ const POSITION_CLASS: Record = { 'top-right': 'top-4 right-4 items-end', 'top-left': 'top-4 left-4 items-start', 'bottom-right': 'bottom-4 right-4 items-end', 'bottom-left': 'bottom-4 left-4 items-start', 'top-center': 'top-4 left-1/2 -translate-x-1/2 items-center', 'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 items-center', }; function isBottomPosition(p: ToastPosition) { return p.startsWith('bottom'); } function ToastViewport({ toasts, position, onDismiss, surface, stacked, stackVisible, }: { toasts: ToastItem[]; position: ToastPosition; onDismiss: (id: string) => void; surface?: Surface; stacked: boolean; stackVisible: number; }) { const [mounted, setMounted] = useState(false); const [expanded, setExpanded] = useState(false); useEffect(() => { setMounted(true); }, []); if (!mounted || typeof document === 'undefined') return null; const bottom = isBottomPosition(position); // Visually we want the newest toast in front. For bottom positions, newest is // at the top of the rendered list; for top positions, newest at the bottom. // Stacking puts the newest at offset 0 and older ones peeking behind. const ordered = bottom ? [...toasts].reverse() : toasts; const frontIndex = ordered.length - 1; return createPortal(
stacked && setExpanded(true)} onMouseLeave={() => stacked && setExpanded(false)} onFocus={() => stacked && setExpanded(true)} onBlur={(e) => { if (!stacked) return; // Only collapse when focus leaves the viewport entirely. if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setExpanded(false); } }} className={cn( 'pointer-events-none fixed z-[90] flex w-full max-w-[min(24rem,calc(100vw-2rem))] flex-col gap-2 p-0', POSITION_CLASS[position], )} > {ordered.map((t, i) => { // Depth = how far behind the front this card is (0 = front). const depth = frontIndex - i; const visible = !stacked || expanded || depth <= stackVisible; const offsetPx = stacked && !expanded ? depth * 8 : 0; const scale = stacked && !expanded ? Math.max(0.92, 1 - depth * 0.04) : 1; const translateY = bottom ? -offsetPx : offsetPx; return (
onDismiss(t.id)} surface={surface} />
); })}
, document.body, ); }