UNPKG

sonner-native

Version:

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

518 lines (512 loc) 17.3 kB
"use strict"; import * as React from 'react'; import { ActivityIndicator, Dimensions, Pressable, Text, View } 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 screenWidth = Dimensions.get('window').width; 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: Dimensions.get('window').width })) { // 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]); 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