expo-toastee
Version:
A simple and elegant toast notification library for React Native Expo with Material You and Neobrutalist themes
275 lines (273 loc) • 10.3 kB
JavaScript
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Animated, TouchableOpacity, SafeAreaView, Dimensions, } from 'react-native';
import { toastManager } from './ToastManager';
import { getThemeStyles } from './themes';
import { toastConfig } from './ToastConfig';
import { getSizeConfig } from './sizes';
const { width: screenWidth } = Dimensions.get('window');
const ToastItem = ({ toast, onRemove }) => {
const [fadeAnim] = useState(new Animated.Value(0));
const [slideAnim] = useState(new Animated.Value(toast.position === 'top' ? -100 : 100));
const [isExiting, setIsExiting] = useState(false);
const handleRemove = () => {
console.log('ToastItem: handleRemove called for toast:', toast.id, 'isExiting:', isExiting);
if (isExiting)
return; // Prevent multiple exit animations
setIsExiting(true);
// Exit animation before removing
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: toast.position === 'top' ? -100 : 100,
duration: 200,
useNativeDriver: true,
}),
]).start(() => {
console.log('ToastItem: Animation complete, calling onRemove for:', toast.id);
onRemove(toast.id);
});
};
useEffect(() => {
// Entrance animation
switch (toast.animationType) {
case 'bounce':
Animated.parallel([
Animated.spring(slideAnim, {
toValue: 0,
bounciness: 12,
speed: 10,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
]).start();
break;
case 'spring':
Animated.parallel([
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
]).start();
break;
case 'fade':
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
break;
case 'slide':
default:
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
break;
}
// Auto-dismiss timer
if (toast.duration && toast.duration > 0) {
console.log('ToastItem: Setting auto-dismiss timer for toast:', toast.id, 'duration:', toast.duration);
const timer = setTimeout(() => {
console.log('ToastItem: Auto-dismiss timer fired for toast:', toast.id);
handleRemove();
}, toast.duration);
return () => {
console.log('ToastItem: Clearing timer for toast:', toast.id);
clearTimeout(timer);
};
}
}, [toast.duration]);
const getToastStyle = () => {
const themeStyles = getThemeStyles(toast.theme);
const customStyles = toast.customStyles || {};
const sizeConfig = getSizeConfig(toast.size, toast.theme);
let baseStyle = [
themeStyles.container,
{
width: sizeConfig.width,
maxWidth: sizeConfig.maxWidth,
borderRadius: sizeConfig.borderRadius,
borderWidth: sizeConfig.borderWidth,
}
];
let typeStyle = [];
switch (toast.type) {
case 'success':
typeStyle = [themeStyles.successContainer, customStyles.successContainer];
break;
case 'error':
typeStyle = [themeStyles.errorContainer, customStyles.errorContainer];
break;
case 'warning':
typeStyle = [themeStyles.warningContainer, customStyles.warningContainer];
break;
case 'info':
default:
typeStyle = [themeStyles.infoContainer, customStyles.infoContainer];
break;
}
return [baseStyle, typeStyle, customStyles.container];
};
const getTextStyle = () => {
const themeStyles = getThemeStyles(toast.theme);
const customStyles = toast.customStyles || {};
const sizeConfig = getSizeConfig(toast.size, toast.theme);
let baseStyle = [
themeStyles.text,
{
fontSize: sizeConfig.fontSize,
}
];
let typeStyle = [];
switch (toast.type) {
case 'success':
typeStyle = [themeStyles.successText, customStyles.successText];
break;
case 'error':
typeStyle = [themeStyles.errorText, customStyles.errorText];
break;
case 'warning':
typeStyle = [themeStyles.warningText, customStyles.warningText];
break;
case 'info':
default:
typeStyle = [themeStyles.infoText, customStyles.infoText];
break;
}
return [baseStyle, typeStyle, customStyles.text];
};
const handlePress = () => {
handleRemove();
};
const themeStyles = getThemeStyles(toast.theme);
const sizeConfig = getSizeConfig(toast.size, toast.theme);
const isNeoBrutalist = toast.theme === 'neobrutalist';
if (isNeoBrutalist) {
// Special rendering for neobrutalist theme with proper box shadow
const shadowOffset = Math.max(4, (sizeConfig.borderWidth || 0) + 2);
return (<Animated.View style={[
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
marginVertical: 6,
marginHorizontal: 16,
alignSelf: 'center',
},
]}>
{/* Shadow layer */}
<View style={[
{
position: 'absolute',
top: shadowOffset,
left: shadowOffset,
right: -shadowOffset,
bottom: -shadowOffset,
backgroundColor: '#000000',
borderRadius: sizeConfig.borderRadius,
},
]}/>
{/* Main toast content */}
<View style={getToastStyle()}>
<TouchableOpacity onPress={handlePress} style={{
paddingHorizontal: sizeConfig.paddingHorizontal,
paddingVertical: sizeConfig.paddingVertical,
}}>
<Text style={getTextStyle()}>{toast.message}</Text>
</TouchableOpacity>
</View>
</Animated.View>);
}
return (<Animated.View style={[
getToastStyle(),
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
alignSelf: 'center',
},
]}>
<TouchableOpacity onPress={handlePress} style={{
paddingHorizontal: sizeConfig.paddingHorizontal,
paddingVertical: sizeConfig.paddingVertical,
}}>
<Text style={getTextStyle()}>{toast.message}</Text>
</TouchableOpacity>
</Animated.View>);
};
export const ToastContainer = (props) => {
const [toasts, setToasts] = useState([]);
// Apply props configuration on mount and when props change
useEffect(() => {
if (Object.keys(props).length > 0) {
toastConfig.setConfig(props);
}
}, [props]);
useEffect(() => {
const unsubscribe = toastManager.subscribe((newToasts) => {
console.log('ToastContainer: Received toasts update:', newToasts.length);
setToasts(newToasts);
});
return unsubscribe;
}, []);
const handleRemoveToast = (id) => {
console.log('ToastContainer: Requesting removal of toast:', id);
toastManager.removeToast(id);
};
const topToasts = toasts.filter(toast => toast.position === 'top');
const bottomToasts = toasts.filter(toast => toast.position === 'bottom');
return (<>
{/* Top toasts */}
{topToasts.length > 0 && (<SafeAreaView style={styles.topContainer}>
<View style={styles.toastList}>
{topToasts.map(toast => (<ToastItem key={toast.id} toast={{ ...toast, theme: toast.theme || 'material' }} onRemove={handleRemoveToast}/>))}
</View>
</SafeAreaView>)}
{/* Bottom toasts */}
{bottomToasts.length > 0 && (<SafeAreaView style={styles.bottomContainer}>
<View style={styles.toastList}>
{bottomToasts.map(toast => (<ToastItem key={toast.id} toast={{ ...toast, theme: toast.theme || 'material' }} onRemove={handleRemoveToast}/>))}
</View>
</SafeAreaView>)}
</>);
};
const styles = StyleSheet.create({
topContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
pointerEvents: 'box-none',
},
bottomContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
pointerEvents: 'box-none',
},
toastList: {
paddingVertical: 8,
},
});