UNPKG

sonner-native

Version:

An opinionated toast component for React Native. A port of @emilkowalski's sonner.

524 lines (449 loc) 13.7 kB
import * as React from 'react'; import { ENTERING_ANIMATION_DURATION } from './animations'; import { toastDefaultValues } from './constants'; import { areToastsEqual } from './toast-comparator'; import type { ToastProps, ToastRef } from './types'; type ToastTimer = { timeout: ReturnType<typeof setTimeout>; startTime: number; remainingTime: number; isPaused: boolean; }; type ToastStoreState = { toasts: ToastProps[]; toastsById: Map<string | number, ToastProps>; toastsCounter: number; toastRefs: Record<string | number, React.RefObject<ToastRef | null>>; shouldShowOverlay: boolean; toastTimers: Record<string | number, ToastTimer>; toastHeights: Record<string | number, number>; toastHeightsVersion: number; isExpanded: boolean; }; type Subscriber = () => void; type ToastStoreConfig = { autoWiggleOnUpdate?: 'never' | 'toast-change' | 'always'; visibleToasts?: number; duration?: number; pauseWhenPageIsHidden?: boolean; }; class ToastStore { private state: ToastStoreState = { toasts: [], toastsById: new Map(), toastsCounter: 1, toastRefs: {}, shouldShowOverlay: false, toastTimers: {}, toastHeights: {}, toastHeightsVersion: 0, isExpanded: false, }; private subscribers = new Set<Subscriber>(); private config: ToastStoreConfig = {}; private hideOverlayTimeout: ReturnType<typeof setTimeout> | null = null; private promiseResolvers = new Map<string | number, boolean>(); private collapseCooldown = false; private collapseCooldownTimeout: ReturnType<typeof setTimeout> | null = null; subscribe = (callback: Subscriber) => { this.subscribers.add(callback); return () => { this.subscribers.delete(callback); }; }; getSnapshot = (): ToastStoreState => { return this.state; }; setConfig = (config: ToastStoreConfig) => { this.config = config; }; private notify = () => { this.subscribers.forEach((callback) => callback()); }; private cloneIndex = (): Map<string | number, ToastProps> => { return new Map(this.state.toastsById); }; private startTimer = ({ id, duration, onComplete, }: { id: string | number; duration: number; onComplete: () => void; }) => { // Don't start timer for infinite duration if (duration === Infinity) { return; } this.clearTimer(id); const timeout = setTimeout(() => { onComplete(); delete this.state.toastTimers[id]; }, ENTERING_ANIMATION_DURATION + duration); this.state.toastTimers[id] = { timeout, startTime: Date.now(), remainingTime: duration, isPaused: false, }; }; private clearTimer = (id: string | number) => { const timer = this.state.toastTimers[id]; if (timer) { clearTimeout(timer.timeout); delete this.state.toastTimers[id]; } }; pauseTimer = (id: string | number) => { const timer = this.state.toastTimers[id]; if (timer && !timer.isPaused) { clearTimeout(timer.timeout); timer.remainingTime = timer.remainingTime - (Date.now() - timer.startTime); timer.isPaused = true; } }; resumeTimer = (id: string | number) => { const timer = this.state.toastTimers[id]; if (!timer || !timer.isPaused) return; const toast = this.state.toastsById.get(id); if (!toast) return; timer.isPaused = false; timer.startTime = Date.now(); timer.timeout = setTimeout( () => { this.dismissToast(id, 'onAutoClose'); delete this.state.toastTimers[id]; }, Math.max(timer.remainingTime, 1000) ); }; pauseAllTimers = () => { for (const toast of this.state.toastsById.values()) { this.pauseTimer(toast.id); } }; resumeAllTimers = () => { for (const toast of this.state.toastsById.values()) { this.resumeTimer(toast.id); } }; private handlePromise = async (toast: ToastProps) => { if (!toast.promiseOptions?.promise) { return; } const { id, promiseOptions } = toast; // Check if already resolving if (this.promiseResolvers.has(id)) { return; } this.promiseResolvers.set(id, true); try { const data = await promiseOptions.promise; if (!this.state.toastsById.has(id)) return; this.addToast({ title: promiseOptions.success(data) ?? 'Success', id, variant: 'success', promiseOptions: undefined, duration: toast.duration, styles: promiseOptions.styles?.success, }); } catch (error) { if (!this.state.toastsById.has(id)) return; this.addToast({ title: typeof promiseOptions.error === 'function' ? promiseOptions.error(error) : (promiseOptions.error ?? 'Error'), id, variant: 'error', promiseOptions: undefined, duration: toast.duration, styles: promiseOptions.styles?.error, }); } finally { this.promiseResolvers.delete(id); } }; addToast = ( options: Omit< ToastProps, 'id' | 'numberOfToasts' | 'index' | 'orderedToastIds' > & { id?: string | number; } ): string | number => { const hasValidId = typeof options?.id === 'number' || (typeof options?.id === 'string' && options.id.length > 0); const id: string | number = hasValidId && options.id !== undefined ? options.id : this.state.toastsCounter; const nextCounter = hasValidId ? this.state.toastsCounter : this.state.toastsCounter + 1; const duration = options.duration ?? this.config.duration ?? toastDefaultValues.duration; const newToast: ToastProps = { ...options, id, variant: options.variant ?? toastDefaultValues.variant, duration, // These are set by toaster.tsx at render time; defaults here for type satisfaction numberOfToasts: 0, index: 0, orderedToastIds: [], }; const existingToast = this.state.toastsById.get(newToast.id); const shouldUpdate = existingToast && options?.id !== undefined; if (shouldUpdate) { const shouldWiggle = this.config.autoWiggleOnUpdate === 'always' || (this.config.autoWiggleOnUpdate === 'toast-change' && !areToastsEqual(newToast, existingToast)); if (shouldWiggle && options.id !== undefined) { this.wiggleToast(options.id); } const updatedToasts = this.state.toasts.map((currentToast) => { if (currentToast.id === options.id) { return { ...currentToast, ...newToast, duration, id: options.id, }; } return currentToast; }); // Restart timer if duration changed if (!newToast.promiseOptions) { this.startTimer({ id, duration, onComplete: () => { this.dismissToast(id, 'onAutoClose'); }, }); } const updatedIndex = this.cloneIndex(); const updatedEntry = updatedToasts.find((t) => t.id === options.id); if (updatedEntry) updatedIndex.set(options.id!, updatedEntry); this.state = { ...this.state, toasts: updatedToasts, toastsById: updatedIndex, shouldShowOverlay: true, }; } else { const newToasts: ToastProps[] = [...this.state.toasts, newToast]; const newToastRefs = { ...this.state.toastRefs }; if (!(newToast.id in newToastRefs)) { newToastRefs[newToast.id] = React.createRef<ToastRef>(); } const visibleToasts = this.config.visibleToasts ?? toastDefaultValues.visibleToasts; const newIndex = this.cloneIndex(); newIndex.set(newToast.id, newToast); const updatedHeights = { ...this.state.toastHeights }; let heightsChanged = false; if (newToasts.length > visibleToasts) { const removedToast = newToasts.shift(); if (removedToast) { this.clearTimer(removedToast.id); newIndex.delete(removedToast.id); if (removedToast.id in updatedHeights) { delete updatedHeights[removedToast.id]; heightsChanged = true; } } } this.state = { ...this.state, toasts: newToasts, toastsById: newIndex, toastRefs: newToastRefs, toastHeights: heightsChanged ? updatedHeights : this.state.toastHeights, toastHeightsVersion: heightsChanged ? this.state.toastHeightsVersion + 1 : this.state.toastHeightsVersion, toastsCounter: nextCounter, shouldShowOverlay: true, }; // Handle promise if present if (newToast.promiseOptions) { this.handlePromise(newToast); } else { // Start timer for regular toasts this.startTimer({ id, duration, onComplete: () => { this.dismissToast(id, 'onAutoClose'); }, }); } } if (this.hideOverlayTimeout) { clearTimeout(this.hideOverlayTimeout); this.hideOverlayTimeout = null; } this.notify(); return id; }; dismissToast = ( id: string | number | undefined, origin?: 'onDismiss' | 'onAutoClose' ): string | number | undefined => { if (id == null) { this.state.toasts.forEach((currentToast) => { this.clearTimer(currentToast.id); if (origin === 'onDismiss') { currentToast.onDismiss?.(currentToast.id); } else { currentToast.onAutoClose?.(currentToast.id); } }); this.state = { ...this.state, toasts: [], toastsById: new Map(), toastsCounter: 1, toastTimers: {}, toastHeights: {}, toastHeightsVersion: this.state.toastHeightsVersion + 1, isExpanded: false, }; this.scheduleHideOverlay(); this.notify(); return; } // Clear timer for this specific toast this.clearTimer(id); const toastForCallback = this.state.toastsById.get(id); const filteredToasts = this.state.toasts.filter( (currentToast) => currentToast.id !== id ); const updatedHeights = { ...this.state.toastHeights }; delete updatedHeights[id]; const shouldAutoCollapse = filteredToasts.length <= 1 && this.state.isExpanded; const updatedIndex = this.cloneIndex(); updatedIndex.delete(id); this.state = { ...this.state, toasts: filteredToasts, toastsById: updatedIndex, toastHeights: updatedHeights, toastHeightsVersion: this.state.toastHeightsVersion + 1, isExpanded: shouldAutoCollapse ? false : this.state.isExpanded, }; if (shouldAutoCollapse) { this.resumeAllTimers(); } if (origin === 'onDismiss') { toastForCallback?.onDismiss?.(id); } else { toastForCallback?.onAutoClose?.(id); } // Schedule hiding overlay if no toasts remain if (filteredToasts.length === 0) { this.scheduleHideOverlay(); } this.notify(); return id; }; private scheduleHideOverlay = () => { if (this.hideOverlayTimeout) { clearTimeout(this.hideOverlayTimeout); } // Wait for animation to finish before hiding overlay this.hideOverlayTimeout = setTimeout(() => { this.state = { ...this.state, shouldShowOverlay: false, }; this.hideOverlayTimeout = null; this.notify(); }, ENTERING_ANIMATION_DURATION); }; wiggleToast = (id: string | number) => { const toast = this.state.toastsById.get(id); if (!toast) { return; } // Trigger the wiggle animation via the ref const toastRef = this.state.toastRefs[id]; if (toastRef && toastRef.current) { toastRef.current.wiggle(); } // Reset timer on wiggle (but not for Infinity duration or promise toasts) if (toast.duration !== Infinity && !toast.promiseOptions) { this.startTimer({ id, duration: toast.duration ?? this.config.duration ?? toastDefaultValues.duration, onComplete: () => { this.dismissToast(id, 'onAutoClose'); }, }); } }; getToastRef = ( id: string | number ): React.RefObject<ToastRef | null> | undefined => { return this.state.toastRefs[id]; }; setToastHeight = (id: string | number, height: number) => { if (this.state.toastHeights[id] === height) return; this.state = { ...this.state, toastHeights: { ...this.state.toastHeights, [id]: height, }, toastHeightsVersion: this.state.toastHeightsVersion + 1, }; this.notify(); }; expand = () => { this.state = { ...this.state, isExpanded: true, }; // Pause all timers when expanded this.pauseAllTimers(); this.notify(); }; collapse = () => { this.state = { ...this.state, isExpanded: false, }; // Prevent immediate re-expansion — flag clears after timeout this.collapseCooldown = true; if (this.collapseCooldownTimeout) { clearTimeout(this.collapseCooldownTimeout); } this.collapseCooldownTimeout = setTimeout(() => { this.collapseCooldown = false; this.collapseCooldownTimeout = null; }, 100); // Resume all timers when collapsed this.resumeAllTimers(); this.notify(); }; toggleExpand = () => { if (!this.state.isExpanded && this.collapseCooldown) { return; } if (this.state.isExpanded) { this.collapse(); } else { this.expand(); } }; } export const toastStore = new ToastStore();