Files
territory-log/components/ui/Toast.tsx

128 lines
3.7 KiB
TypeScript

import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import { View, Text, Animated, TouchableOpacity, StyleSheet } from 'react-native';
import { CheckCircle, AlertTriangle, XCircle, X } from 'lucide-react-native';
export type ToastType = 'success' | 'warning' | 'error';
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} });
const COLORS: Record<ToastType, string> = {
success: '#4CAF7D',
warning: '#D4A843',
error: '#C0392B',
};
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-20)).current;
React.useEffect(() => {
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 250, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration: 250, useNativeDriver: true }),
]).start();
const timer = setTimeout(() => dismiss(), 3000);
return () => clearTimeout(timer);
}, []);
function dismiss() {
Animated.parallel([
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
Animated.timing(translateY, { toValue: -20, duration: 200, useNativeDriver: true }),
]).start(() => onDismiss(toast.id));
}
const color = COLORS[toast.type];
const Icon = toast.type === 'success' ? CheckCircle : toast.type === 'warning' ? AlertTriangle : XCircle;
return (
<Animated.View
style={[styles.toast, { backgroundColor: color, opacity, transform: [{ translateY }] }]}
accessibilityRole="alert"
accessibilityLabel={`${toast.type}: ${toast.message}`}
>
<Icon size={18} color="white" />
<Text style={styles.message} numberOfLines={3}>{toast.message}</Text>
<TouchableOpacity
onPress={dismiss}
accessibilityLabel="Dismiss notification"
accessibilityRole="button"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={16} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
</Animated.View>
);
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const counterRef = useRef(0);
const showToast = useCallback((message: string, type: ToastType = 'success') => {
const id = `toast-${++counterRef.current}-${Date.now()}`;
setToasts((prev) => [...prev, { id, message, type }]);
}, []);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<View style={styles.container} pointerEvents="box-none">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
</View>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
return useContext(ToastContext);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 90,
left: 16,
right: 16,
gap: 8,
zIndex: 9999,
},
toast: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 14,
gap: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 6,
},
message: {
flex: 1,
color: 'white',
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
},
});