sonner-native
Version:
An opinionated toast component for React Native. A port of @emilkowalski's sonner.
403 lines (394 loc) • 11.8 kB
JavaScript
"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);
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,
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;
};
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