'use client'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { store, DEFAULT_VISIBLE_TOASTS } from '../core/store'; import type { Direction, RichColorsMode, SwipeDirection, ToastOffset, ToastOptions, ToastPosition, ToastRecord, ToastType, ToasterProps, } from '../core/types'; import { Toast, ToastRuntime } from './Toast'; import '../styles/toast.css'; /** Time the exit animation is given before the node is dropped. */ const EXIT_DURATION = 220; /** Visible sliver of each toast behind the front one, when collapsed. */ const COLLAPSED_PEEK = 14; const DEFAULT_GAP = 14; const DEFAULT_HOTKEY = ['altKey', 'KeyT']; const toCssLength = (value: string | number | undefined, fallback: string): string => { if (value === undefined) return fallback; return typeof value === 'number' ? `${value}px` : value; }; const isOffsetObject = (value: unknown): value is ToastOffset => typeof value === 'object' && value !== null; /** * Turn `offset` into custom properties. A scalar sets every side at once; an * object sets only the sides it names, each falling back to the scalar in CSS. */ const offsetVars = ( value: string | number | ToastOffset | undefined, prefix: string, fallback: string ): Record => { if (isOffsetObject(value)) { const vars: Record = { [prefix]: fallback }; (['top', 'right', 'bottom', 'left'] as const).forEach((side) => { const side_value = value[side]; if (side_value !== undefined) { vars[`${prefix}-${side}`] = toCssLength(side_value, fallback); } }); return vars; } return { [prefix]: toCssLength(value, fallback) }; }; /** Directions that make sense for a given corner, when not set explicitly. */ const defaultSwipeDirections = (position: ToastPosition): SwipeDirection[] => { const vertical: SwipeDirection = position.startsWith('top') ? 'up' : 'down'; if (position.endsWith('left')) return ['left', vertical]; if (position.endsWith('right')) return ['right', vertical]; return ['left', 'right', vertical]; }; /** * What theme has the host application declared? * * Checked before `prefers-color-scheme`, because an app that states its theme * outranks the OS: a Tailwind/next-themes site toggled to light on a machine * set to dark must get light toasts, not black ones. * * Covers the conventions in the wild: a `.dark` / `.light` class (Tailwind's * `darkMode: 'class'`, next-themes `attribute="class"`), a `data-theme` * attribute (next-themes `attribute="data-theme"`, DaisyUI), and an inline * `color-scheme` (next-themes sets this whenever `enableColorScheme` is on). */ const readHostTheme = (): 'light' | 'dark' | null => { if (typeof document === 'undefined') return null; for (const root of [document.documentElement, document.body]) { if (!root) continue; if (root.classList.contains('dark')) return 'dark'; if (root.classList.contains('light')) return 'light'; const attribute = root.getAttribute('data-theme'); if (attribute === 'dark' || attribute === 'light') return attribute; const scheme = root.style.colorScheme; if (scheme === 'dark' || scheme === 'light') return scheme; } return null; }; /** Best-effort plain text for the live region. JSX content is skipped. */ const textOf = (node: React.ReactNode): string => { if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(textOf).filter(Boolean).join(' '); return ''; }; export const Toaster: React.FC = (props) => { const { children, position: positionProp, defaultPosition, layout: layoutProp, defaultLayout, visibleToasts, maxToasts, duration, defaultDuration, theme = 'system', dir = 'auto', richColors = false, expand = false, closeButton, showCloseButton, showProgressBar = true, color = true, invert = false, size = 'md', gap = DEFAULT_GAP, offset, mobileOffset, toastOptions, className, containerClassName, style, swipeDirections, swipeDirection, swipeThreshold = 0.35, hotkey = DEFAULT_HOTKEY, pauseWhenPageIsHidden = false, pauseOnFocusLoss = false, newestFirst = true, closeOnClick = false, containerAriaLabel = 'Notifications', closeButtonAriaLabel = 'Close', icons = {}, loadingIcon, zIndex = 9999, } = props; const overrides = store.getOverrides(); const position: ToastPosition = overrides.position ?? positionProp ?? defaultPosition ?? 'bottom-right'; const layout = overrides.layout ?? layoutProp ?? defaultLayout ?? 'normal'; const limit = visibleToasts ?? maxToasts ?? DEFAULT_VISIBLE_TOASTS; const resolvedDuration = duration ?? defaultDuration; // Intentionally NOT seeded from the store. The server has no queue, so // seeding here would make the first client render disagree with the server // HTML whenever a toast was dispatched before hydration. The mount effect // fills it in immediately afterwards, before paint. const [toasts, setToasts] = useState([]); const [ghosts, setGhosts] = useState([]); const [heights, setHeights] = useState>({}); const [hovered, setHovered] = useState(false); const [focusWithin, setFocusWithin] = useState(false); const [systemDark, setSystemDark] = useState(false); const [hostTheme, setHostTheme] = useState<'light' | 'dark' | null>(null); const [documentDir, setDocumentDir] = useState('ltr'); const [announce, setAnnounce] = useState({ polite: '', assertive: '' }); const regionRef = useRef(null); const seenIds = useRef>(new Set()); const previous = useRef([]); const focusReturn = useRef(null); const [refocus, setRefocus] = useState(0); // Stable identity for this instance's claim on rendering. const [token] = useState(() => Symbol('vyrn-toaster')); const [isActive, setIsActive] = useState(false); useEffect(() => { const release = store.claimRenderer(token); const sync = () => setIsActive(store.isActiveRenderer(token)); sync(); const unsubscribe = store.subscribe(sync); return () => { unsubscribe(); release(); }; }, [token]); useEffect(() => { setToasts(store.getToasts()); return store.subscribe(setToasts); }, []); useEffect(() => { if (!isActive) return; store.setLimit(limit); }, [limit, isActive]); // Read from the document after hydration, never during render. useEffect(() => { if (dir !== 'auto' || typeof document === 'undefined') return; setDocumentDir(document.dir === 'rtl' ? 'rtl' : 'ltr'); }, [dir]); /* * Only creation-time defaults go to the store — timing and de-duplication. * Presentational defaults stay in the render path so changing a prop restyles * toasts that are already on screen, and so nothing is merged in twice. */ useEffect(() => { if (!isActive) return; const duration = resolvedDuration !== undefined ? resolvedDuration : toastOptions?.duration; const types: Partial> = {}; const perType = toastOptions?.types; if (perType) { (Object.keys(perType) as ToastType[]).forEach((type) => { const entry = perType[type]; if (!entry) return; const slice: ToastOptions = {}; if (entry.duration !== undefined) slice.duration = entry.duration; if (entry.preventDuplicate !== undefined) { slice.preventDuplicate = entry.preventDuplicate; } if (Object.keys(slice).length) types[type] = slice; }); } store.setDefaults({ ...(duration !== undefined ? { duration } : {}), ...(toastOptions?.preventDuplicate !== undefined ? { preventDuplicate: toastOptions.preventDuplicate } : {}), ...(Object.keys(types).length ? { types } : {}), }); }, [resolvedDuration, toastOptions, isActive]); // Track what the host app declares, and re-read it whenever it changes. useEffect(() => { if (theme !== 'system' || typeof document === 'undefined') return; const sync = () => setHostTheme(readHostTheme()); sync(); if (typeof MutationObserver === 'undefined') return; const observer = new MutationObserver(sync); const options = { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); return () => observer.disconnect(); }, [theme]); // Resolve `theme="system"`. useEffect(() => { if (theme !== 'system' || typeof window === 'undefined' || !window.matchMedia) return; const query = window.matchMedia('(prefers-color-scheme: dark)'); const sync = () => setSystemDark(query.matches); sync(); if (query.addEventListener) { query.addEventListener('change', sync); return () => query.removeEventListener('change', sync); } query.addListener(sync); return () => query.removeListener(sync); }, [theme]); const visible = useMemo(() => toasts.slice(0, limit), [toasts, limit]); // Announce newly arrived toasts, and play their sound if they asked for one. useEffect(() => { const fresh = visible.filter((toast) => !seenIds.current.has(toast.id)); visible.forEach((toast) => seenIds.current.add(toast.id)); if (!fresh.length) return; const latest = fresh[fresh.length - 1]; const message = [textOf(latest.content), textOf(latest.description)] .filter(Boolean) .join('. '); if (message) { const assertive = latest.important === true || latest.type === 'error'; setAnnounce(assertive ? { polite: '', assertive: message } : { polite: message, assertive: '' }); } fresh.forEach((toast) => { if (!toast.soundEffect || typeof Audio === 'undefined') return; try { const audio = new Audio(toast.soundEffect); void audio.play().catch(() => undefined); } catch { /* autoplay blocked — a toast is not worth throwing over */ } }); }, [visible]); // Retain just-removed toasts briefly so they can animate out. useEffect(() => { const gone = previous.current.filter( (candidate) => !toasts.some((toast) => toast.id === candidate.id) ); previous.current = visible; if (!gone.length) return; setGhosts((current) => [...current, ...gone]); const timers = gone.map((toast) => setTimeout(() => { setGhosts((current) => current.filter((ghost) => ghost.id !== toast.id)); setHeights((current) => { const next = { ...current }; delete next[toast.id]; return next; }); seenIds.current.delete(toast.id); }, EXIT_DURATION) ); return () => timers.forEach(clearTimeout); }, [toasts, visible]); // Pause on hover, on keyboard focus, and optionally while the tab is hidden. useEffect(() => { if (hovered || focusWithin) store.pause(); else store.resume(); }, [hovered, focusWithin]); useEffect(() => { if (!pauseWhenPageIsHidden || typeof document === 'undefined') return; const sync = () => { if (document.visibilityState === 'hidden') store.pause(); else if (!hovered && !focusWithin) store.resume(); }; document.addEventListener('visibilitychange', sync); return () => document.removeEventListener('visibilitychange', sync); }, [pauseWhenPageIsHidden, hovered, focusWithin]); useEffect(() => { if (!pauseOnFocusLoss || typeof window === 'undefined') return; const onBlur = () => store.pause(); const onFocus = () => { if (!hovered && !focusWithin) store.resume(); }; window.addEventListener('blur', onBlur); window.addEventListener('focus', onFocus); return () => { window.removeEventListener('blur', onBlur); window.removeEventListener('focus', onFocus); }; }, [pauseOnFocusLoss, hovered, focusWithin]); // Hotkey moves focus into the list, the way Sonner's ⌥T does. useEffect(() => { if (typeof document === 'undefined') return; const onKeyDown = (event: KeyboardEvent) => { const matched = hotkey.every((part) => part.endsWith('Key') ? Boolean((event as unknown as Record)[part]) : event.code === part ); if (!matched) return; const target = regionRef.current?.querySelector('[data-vyrn-toast]'); target?.focus(); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [hotkey]); // Losing focus to when a toast is dismissed strands keyboard users. const requestRefocus = useCallback(() => setRefocus((n) => n + 1), []); useEffect(() => { if (refocus === 0) return; const region = regionRef.current; if (!region) return; const next = region.querySelector( '[data-vyrn-toast][data-removed="false"]' ); if (next) next.focus(); else if (focusReturn.current && document.contains(focusReturn.current)) { focusReturn.current.focus(); } }, [refocus]); const onHeight = useCallback((id: string, height: number) => { setHeights((current) => (current[id] === height ? current : { ...current, [id]: height })); }, []); // Explicit prop > what the host declares > the OS preference. const resolvedTheme = theme === 'system' ? hostTheme ?? (systemDark ? 'dark' : 'light') : theme; const resolvedDir: Direction = dir === 'auto' ? documentDir : dir; const resolvedRich: RichColorsMode | false = richColors === true ? 'minimal' : richColors === false ? false : richColors; const isExpanded = expand || layout !== 'stack' || hovered || focusWithin; const rendered = useMemo(() => [...visible, ...ghosts], [visible, ghosts]); /** One list per position, so per-toast `position` works. */ const groups = useMemo(() => { const map = new Map(); rendered.forEach((toast) => { const key = toast.position || position; const bucket = map.get(key); if (bucket) bucket.push(toast); else map.set(key, [toast]); }); return Array.from(map.entries()); }, [rendered, position]); return ( <> {children}
{/* A single pair of live regions does all announcing. The toasts themselves carry no live semantics, so nothing is announced twice and an exiting toast is never re-read. */}
{announce.polite}
{announce.assertive}
{(isActive ? groups : []).map(([groupPosition, groupToasts]) => { const isTop = groupPosition.startsWith('top'); // The front of the list is the edge of the viewport. const ordered = newestFirst ? [...groupToasts].reverse() : [...groupToasts]; const swipeSet = swipeDirections || (swipeDirection ? [swipeDirection] : defaultSwipeDirections(groupPosition)); const runtime: ToastRuntime = { closeButton: closeButton ?? showCloseButton ?? true, showProgressBar, size, richColors: resolvedRich, color, invert, expanded: isExpanded, swipeDirections: swipeSet, swipeThreshold, icons, loadingIcon, defaults: toastOptions, closeButtonAriaLabel, closeOnClick, }; // Real measured heights, so multi-line toasts never overlap. let running = 0; const offsets = ordered.map((toast, index) => { if (index === 0) return 0; const previousToast = ordered[index - 1]; running += (heights[previousToast.id] || 0) + gap; return isExpanded ? running : index * COLLAPSED_PEEK; }); return (
    setHovered(true)} onPointerLeave={() => setHovered(false)} onFocus={(event) => { if (!focusWithin) { const from = event.relatedTarget as HTMLElement | null; if (from && !regionRef.current?.contains(from)) { focusReturn.current = from; } } setFocusWithin(true); }} onBlur={() => setFocusWithin(false)} > {ordered.map((toast, index) => ( ghost.id === toast.id)} runtime={runtime} onHeight={onHeight} onRequestRefocus={requestRefocus} /> ))}
); })}
); }; /** Legacy v4 name. `` wrapped children; `` need not. */ export const ToastProvider = Toaster;