sonner-native
Version:
An opinionated toast component for React Native. A port of @emilkowalski's sonner.
520 lines (514 loc) • 17.3 kB
JavaScript
"use strict";
import * as React from 'react';
import { ActivityIndicator, Pressable, Text, View, useWindowDimensions } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
import { STACKING_ANIMATION_DURATION, useToastLayoutAnimations } from "./animations.js";
import { toastDefaultValues } from "./constants.js";
import { useDynamicToastContext, useToastContext } from "./context.js";
import { easeOutQuartFn } from "./easings.js";
import { ToastSwipeHandler } from "./gestures.js";
import { CircleCheck, CircleX, Info, TriangleAlert, X } from "./icons.js";
import { isPressNearCloseButton } from "./press-utils.js";
import { toastStore } from "./toast-store.js";
import { isToastAction } from "./types.js";
import { useAppStateListener } from "./use-app-state.js";
import { useDefaultStyles, useIconColor } from "./use-default-styles.js";
import { useToastPosition } from "./use-toast-position.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export const Toast = /*#__PURE__*/React.forwardRef(({
id,
title,
jsx,
description,
icon,
duration: durationProps,
variant,
action,
cancel,
close,
onDismiss,
dismissible = toastDefaultValues.dismissible,
closeButton: closeButtonProps,
actionButtonStyle,
actionButtonTextStyle,
cancelButtonStyle,
cancelButtonTextStyle,
style,
styles,
parentStyle,
parentStyles,
promiseOptions,
position,
animation,
unstyled: unstyledProps,
important,
invert: invertProps,
richColors: richColorsProps,
onPress,
allowFontScaling: allowFontScalingProps,
maxFontSizeMultiplier: maxFontSizeMultiplierProps,
backgroundComponent: backgroundComponentProps,
numberOfToasts,
index,
orderedToastIds
}, ref) => {
const {
duration: durationCtx,
closeButton: closeButtonCtx,
icons,
pauseWhenPageIsHidden,
invert: invertCtx,
richColors: richColorsCtx,
allowFontScaling: allowFontScalingCtx,
maxFontSizeMultiplier: maxFontSizeMultiplierCtx,
enableStacking,
gap,
position: positionCtx,
visibleToasts: visibleToastsCtx,
toastOptions: {
unstyled: unstyledCtx,
toastContainerStyle: toastContainerStyleCtx,
actionButtonStyle: actionButtonStyleCtx,
actionButtonTextStyle: actionButtonTextStyleCtx,
cancelButtonStyle: cancelButtonStyleCtx,
cancelButtonTextStyle: cancelButtonTextStyleCtx,
style: toastStyleCtx,
toastContentStyle: toastContentStyleCtx,
textContainerStyle: textContainerStyleCtx,
titleStyle: titleStyleCtx,
descriptionStyle: descriptionStyleCtx,
buttonsStyle: buttonsStyleCtx,
closeButtonStyle: closeButtonStyleCtx,
closeButtonIconStyle: closeButtonIconStyleCtx,
backgroundComponent: backgroundComponentCtx,
success: successStyleCtx,
error: errorStyleCtx,
warning: warningStyleCtx,
info: infoStyleCtx,
loading: loadingStyleCtx
}
} = useToastContext();
const {
toastHeights,
toastHeightsVersion,
isExpanded,
toggleExpand
} = useDynamicToastContext();
const invert = invertProps ?? invertCtx;
const richColors = richColorsProps ?? richColorsCtx;
const allowFontScaling = allowFontScalingProps ?? allowFontScalingCtx;
const maxFontSizeMultiplier = maxFontSizeMultiplierProps ?? maxFontSizeMultiplierCtx;
const unstyled = unstyledProps ?? unstyledCtx;
const duration = durationProps ?? durationCtx;
const closeButton = closeButtonProps ?? closeButtonCtx;
const backgroundComponent = backgroundComponentProps ?? backgroundComponentCtx;
const mergedStyle = React.useMemo(() => parentStyle || style ? {
...parentStyle,
...style
} : undefined, [parentStyle, style]);
const mergedStyles = React.useMemo(() => {
if (!parentStyles && !styles) return undefined;
return {
toastContainer: {
...parentStyles?.toastContainer,
...styles?.toastContainer
},
toast: {
...parentStyles?.toast,
...styles?.toast
},
toastContent: {
...parentStyles?.toastContent,
...styles?.toastContent
},
textContainer: {
...parentStyles?.textContainer,
...styles?.textContainer
},
title: {
...parentStyles?.title,
...styles?.title
},
description: {
...parentStyles?.description,
...styles?.description
},
buttons: {
...parentStyles?.buttons,
...styles?.buttons
},
closeButton: {
...parentStyles?.closeButton,
...styles?.closeButton
},
closeButtonIcon: {
...parentStyles?.closeButtonIcon,
...styles?.closeButtonIcon
}
};
}, [parentStyles, styles]);
const toastPosition = position ?? positionCtx;
// Determine if this toast should be hidden due to visibility limit
// For top-center (reversed array), front = index 0; for others, front = highest index
const distanceFromFront = toastPosition === 'top-center' ? index : numberOfToasts - 1 - index;
const isHiddenByLimit = enableStacking && distanceFromFront >= (visibleToastsCtx ?? toastDefaultValues.visibleToasts);
const {
entering,
exiting
} = useToastLayoutAnimations(position, animation, isHiddenByLimit, numberOfToasts);
const stackGap = gap ?? toastDefaultValues.stackGap;
const yPosition = useToastPosition({
id,
index,
numberOfToasts,
enableStacking,
position: toastPosition,
allToastHeights: toastHeights,
gap,
orderedToastIds,
isExpanded,
stackGap,
toastHeightsVersion
});
const isDragging = React.useRef(false);
const toastRef = React.useRef(null);
const wiggleSharedValue = useSharedValue(1);
const wiggleAnimationStyle = useAnimatedStyle(() => {
return {
transform: [{
scale: wiggleSharedValue.value
}]
};
}, [wiggleSharedValue]);
// ScaleX: visual narrowing avoids layout-width changes that cause text rewrap
const {
width: screenWidth
} = useWindowDimensions();
const stackScaleX = useDerivedValue(() => {
'worklet';
if (!enableStacking || numberOfToasts <= 1 || isExpanded) {
return withTiming(1, {
duration: STACKING_ANIMATION_DURATION,
easing: easeOutQuartFn
});
}
const multiplier = toastPosition === 'top-center' ? index : numberOfToasts - index - 1;
const narrowAmount = stackGap * multiplier * 2;
const scale = Math.max(0.8, 1 - narrowAmount / screenWidth);
return withTiming(scale, {
duration: STACKING_ANIMATION_DURATION,
easing: easeOutQuartFn
});
}, [enableStacking, numberOfToasts, index, toastPosition, isExpanded, stackGap, screenWidth]);
const absolutePositionStyle = useAnimatedStyle(() => {
const base = {
position: 'absolute',
width: '100%',
transform: [{
translateY: yPosition.value
}, {
scaleX: stackScaleX.value
}]
};
if (toastPosition === 'bottom-center') {
base.bottom = 0;
} else {
base.top = 0;
}
return base;
}, [yPosition, toastPosition, stackScaleX]);
const wiggle = React.useCallback(() => {
'worklet';
wiggleSharedValue.value = withRepeat(withTiming(Math.min(wiggleSharedValue.value * 1.035, 1.035), {
duration: 150
}), 4, true);
}, [wiggleSharedValue]);
const wiggleHandler = React.useCallback(() => {
// we can't send Infinity over to the native layer.
if (duration === Infinity) {
return;
}
if (wiggleSharedValue.value !== 1) {
// we should animate back to 1 and then wiggle
wiggleSharedValue.value = withTiming(1, {
duration: 150
}, wiggle);
} else {
wiggle();
}
}, [wiggle, wiggleSharedValue, duration]);
React.useImperativeHandle(ref, () => ({
wiggle: wiggleHandler
}));
const onBackground = React.useCallback(() => {
if (!pauseWhenPageIsHidden) {
return;
}
toastStore.pauseTimer(id);
}, [pauseWhenPageIsHidden, id]);
const onForeground = React.useCallback(() => {
if (!pauseWhenPageIsHidden) {
return;
}
toastStore.resumeTimer(id);
}, [pauseWhenPageIsHidden, id]);
useAppStateListener({
onBackground,
onForeground
});
// Synchronous layout read via getBoundingClientRect when available
// (refs are ReactNativeElement by default from RN 0.83). Older New Arch
// versions (e.g. RN 0.81/Expo SDK 54) don't expose it on refs, so fall
// back to async measureInWindow.
React.useLayoutEffect(() => {
if (!toastRef.current) {
return;
}
if (typeof toastRef.current.getBoundingClientRect === 'function') {
const {
height
} = toastRef.current.getBoundingClientRect();
toastStore.setToastHeight(id, height);
return;
}
let stale = false;
toastRef.current.measureInWindow((_x, _y, _w, height) => {
if (!stale) {
toastStore.setToastHeight(id, height);
}
});
return () => {
stale = true;
};
}, [id, variant, title, description, jsx]);
const defaultStyles = useDefaultStyles({
invert,
richColors,
unstyled,
description,
variant
});
const variantStyle = variant === 'success' ? successStyleCtx : variant === 'error' ? errorStyleCtx : variant === 'warning' ? warningStyleCtx : variant === 'info' ? infoStyleCtx : loadingStyleCtx;
const onRemove = React.useCallback(() => {
onDismiss?.(id);
}, [onDismiss, id]);
const onSwipeBegin = React.useCallback(() => {
isDragging.current = true;
toastStore.pauseTimer(id);
}, [id]);
const onSwipeFinalize = React.useCallback(() => {
isDragging.current = false;
if (!isExpanded) {
toastStore.resumeTimer(id);
}
}, [id, isExpanded]);
const onSwipePress = React.useCallback(({
x
}) => {
const pressToastPosition = position || positionCtx;
if (enableStacking && numberOfToasts > 1 && pressToastPosition !== 'center') {
if (isPressNearCloseButton({
x,
viewWidth: screenWidth
})) {
// On Android, the RNGH Tap gesture intercepts the touch before
// it reaches the native Pressable (close button). Dismiss
// explicitly when tapping the close button area while expanded.
if (isExpanded && closeButton && dismissible) {
onDismiss?.(id);
}
} else {
toggleExpand();
}
}
onPress?.();
}, [position, positionCtx, enableStacking, numberOfToasts, toggleExpand, onPress, isExpanded, closeButton, dismissible, onDismiss, id, screenWidth]);
const toastSwipeHandlerProps = React.useMemo(() => ({
onRemove,
onBegin: onSwipeBegin,
onFinalize: onSwipeFinalize,
onPress: onSwipePress,
enabled: !promiseOptions && dismissible,
style: [toastContainerStyleCtx, mergedStyles?.toastContainer],
unstyled,
important,
position
}), [onRemove, onSwipeBegin, onSwipeFinalize, onSwipePress, promiseOptions, dismissible, toastContainerStyleCtx, mergedStyles?.toastContainer, unstyled, important, position]);
const stackZIndex = toastPosition === 'top-center' ? -(index + 1) : -(numberOfToasts - index);
if (jsx) {
return /*#__PURE__*/_jsx(Animated.View, {
style: [absolutePositionStyle, {
zIndex: stackZIndex
}],
children: /*#__PURE__*/_jsx(ToastSwipeHandler, {
...toastSwipeHandlerProps,
children: /*#__PURE__*/_jsx(Animated.View, {
ref: toastRef,
entering: entering,
exiting: exiting,
children: jsx
})
})
});
}
const backgroundComponentStyle = backgroundComponent ? {
overflow: 'hidden',
backgroundColor: 'transparent'
} : undefined;
const contentContainerStyle = backgroundComponent ? {
position: 'relative',
zIndex: 1
} : undefined;
return /*#__PURE__*/_jsx(Animated.View, {
style: [absolutePositionStyle, {
zIndex: stackZIndex
}],
children: /*#__PURE__*/_jsx(ToastSwipeHandler, {
...toastSwipeHandlerProps,
children: /*#__PURE__*/_jsx(Animated.View, {
style: wiggleAnimationStyle,
children: /*#__PURE__*/_jsxs(Animated.View, {
ref: toastRef,
style: [unstyled ? undefined : elevationStyle, defaultStyles.toast, toastStyleCtx, variantStyle, mergedStyles?.toast, mergedStyle, backgroundComponentStyle],
entering: entering,
exiting: exiting,
children: [backgroundComponent, /*#__PURE__*/_jsxs(View, {
style: [defaultStyles.toastContent, toastContentStyleCtx, mergedStyles?.toastContent, contentContainerStyle],
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/_jsx(ActivityIndicator, {}) : icon ? /*#__PURE__*/_jsx(View, {
children: icon
}) : variant in icons ? icons[variant] : /*#__PURE__*/_jsx(ToastIcon, {
variant: variant,
invert: invert,
richColors: richColors
}), /*#__PURE__*/_jsxs(View, {
style: [{
flex: 1
}, textContainerStyleCtx, mergedStyles?.textContainer],
children: [/*#__PURE__*/_jsx(Text, {
allowFontScaling: allowFontScaling,
maxFontSizeMultiplier: maxFontSizeMultiplier,
style: [defaultStyles.title, titleStyleCtx, mergedStyles?.title],
children: title
}), description ? /*#__PURE__*/_jsx(Text, {
allowFontScaling: allowFontScaling,
maxFontSizeMultiplier: maxFontSizeMultiplier,
style: [defaultStyles.description, descriptionStyleCtx, mergedStyles?.description],
children: description
}) : null, /*#__PURE__*/_jsxs(View, {
style: [unstyled || !action && !cancel ? undefined : defaultStyles.buttons, buttonsStyleCtx, mergedStyles?.buttons],
children: [isToastAction(action) ? /*#__PURE__*/_jsx(Pressable, {
onPress: action.onClick,
style: [defaultStyles.actionButton, actionButtonStyleCtx, actionButtonStyle],
children: /*#__PURE__*/_jsx(Text, {
numberOfLines: 1,
allowFontScaling: allowFontScaling,
maxFontSizeMultiplier: maxFontSizeMultiplier,
style: [defaultStyles.actionButtonText, actionButtonTextStyleCtx, actionButtonTextStyle],
children: action.label
})
}) : action || undefined, isToastAction(cancel) ? /*#__PURE__*/_jsx(Pressable, {
onPress: () => {
cancel.onClick();
onDismiss?.(id);
},
style: [defaultStyles.cancelButton, cancelButtonStyleCtx, cancelButtonStyle],
children: /*#__PURE__*/_jsx(Text, {
numberOfLines: 1,
allowFontScaling: allowFontScaling,
maxFontSizeMultiplier: maxFontSizeMultiplier,
style: [defaultStyles.cancelButtonText, cancelButtonTextStyleCtx, cancelButtonTextStyle],
children: cancel.label
})
}) : cancel || undefined]
})]
}), /*#__PURE__*/_jsx(CloseButton, {
dismissible: dismissible,
close: close,
closeButton: closeButton,
onDismiss: onDismiss,
id: id,
closeButtonStyle: [closeButtonStyleCtx, mergedStyles?.closeButton],
closeButtonIconStyle: [closeButtonIconStyleCtx, mergedStyles?.closeButtonIcon],
defaultStyles: defaultStyles
})]
})]
})
})
})
});
});
Toast.displayName = 'Toast';
export const ToastIcon = ({
variant,
invert,
richColors
}) => {
const color = useIconColor({
variant,
invert,
richColors
});
switch (variant) {
case 'success':
return /*#__PURE__*/_jsx(CircleCheck, {
size: 20,
color: color
});
case 'error':
return /*#__PURE__*/_jsx(CircleX, {
size: 20,
color: color
});
case 'warning':
return /*#__PURE__*/_jsx(TriangleAlert, {
size: 20,
color: color
});
default:
case 'info':
return /*#__PURE__*/_jsx(Info, {
size: 20,
color: color
});
}
};
const elevationStyle = {
shadowOpacity: 0.0015 * 4 + 0.1,
shadowRadius: 3 * 4,
shadowOffset: {
height: 4,
width: 0
},
elevation: 4
};
const CloseButton = ({
dismissible,
close,
closeButton,
onDismiss,
id,
closeButtonStyle,
defaultStyles,
closeButtonIconStyle
}) => {
if (!dismissible) {
return null;
}
if (close) {
return close;
}
if (closeButton) {
return /*#__PURE__*/_jsx(Pressable, {
onPress: () => onDismiss?.(id),
hitSlop: 10,
style: closeButtonStyle,
children: /*#__PURE__*/_jsx(X, {
size: 20,
color: defaultStyles.closeButtonColor,
style: closeButtonIconStyle
})
});
}
return null;
};
//# sourceMappingURL=toast.js.map