UNPKG

sonner-native

Version:

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

410 lines (401 loc) 12 kB
"use strict"; import * as React from 'react'; import { ENTERING_ANIMATION_DURATION } from "./animations.js"; import { toastDefaultValues } from "./constants.js"; import { areToastsEqual } from "./toast-comparator.js"; class ToastStore { state = { toasts: [], toastsById: new Map(), toastsCounter: 1, toastRefs: {}, shouldShowOverlay: false, toastTimers: {}, toastHeights: {}, toastHeightsVersion: 0, isExpanded: false }; subscribers = new Set(); config = {}; hideOverlayTimeout = null; promiseResolvers = new Map(); collapseCooldown = false; collapseCooldownTimeout = null; subscribe = callback => { this.subscribers.add(callback); return () => { this.subscribers.delete(callback); }; }; getSnapshot = () => { return this.state; }; setConfig = config => { this.config = config; }; notify = () => { this.subscribers.forEach(callback => callback()); }; cloneIndex = () => { return new Map(this.state.toastsById); }; startTimer = ({ id, duration, onComplete }) => { // 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 }; }; clearTimer = id => { const timer = this.state.toastTimers[id]; if (timer) { clearTimeout(timer.timeout); delete this.state.toastTimers[id]; } }; pauseTimer = id => { 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 => { 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); } }; handlePromise = async toast => { 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 => { const hasValidId = typeof options?.id === 'number' || typeof options?.id === 'string' && options.id.length > 0; const id = 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 = { ...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 = [...this.state.toasts, newToast]; const newToastRefs = { ...this.state.toastRefs }; if (!(newToast.id in newToastRefs)) { newToastRefs[newToast.id] = /*#__PURE__*/React.createRef(); } 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); delete newToastRefs[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, origin) => { 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, toastRefs: {}, 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 updatedRefs = { ...this.state.toastRefs }; delete updatedRefs[id]; const shouldAutoCollapse = filteredToasts.length <= 1 && this.state.isExpanded; const updatedIndex = this.cloneIndex(); updatedIndex.delete(id); this.state = { ...this.state, toasts: filteredToasts, toastsById: updatedIndex, toastRefs: updatedRefs, 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; }; 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 => { 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 => { return this.state.toastRefs[id]; }; setToastHeight = (id, height) => { 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(); //# sourceMappingURL=toast-store.js.map