feat: Sprint 5 - Toast, ConfirmDialog, EmptyStates, Settings, Export/Import, Accessibility (TERLOG-52/53/54/55/56/57/58/59)
This commit is contained in:
127
components/ui/Toast.tsx
Normal file
127
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user