sonner-native
Version:
An opinionated toast component for React Native. A port of @emilkowalski's sonner.
524 lines (449 loc) • 13.7 kB
text/typescript
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();