feat: Sprint 5 - Toast, ConfirmDialog, EmptyStates, Settings, Export/Import, Accessibility (TERLOG-52/53/54/55/56/57/58/59)
This commit is contained in:
@@ -26,6 +26,10 @@ export function Button({ label, onPress, variant = 'primary', loading, disabled
|
||||
className={`${base} ${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={{ disabled: !!(disabled || loading) }}
|
||||
style={{ minHeight: 52 }}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'secondary' ? '#1A6B72' : 'white'} />
|
||||
|
||||
166
components/ui/ConfirmDialog.tsx
Normal file
166
components/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Modal, View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { AlertTriangle } from 'lucide-react-native';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
confirmStyle?: 'danger' | 'default';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
impactCount?: number;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
confirmStyle = 'default',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
impactCount,
|
||||
}: Props) {
|
||||
const isDanger = confirmStyle === 'danger';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onCancel}
|
||||
accessibilityViewIsModal
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View
|
||||
style={styles.dialog}
|
||||
accessibilityRole="alert"
|
||||
accessibilityLabel={title}
|
||||
>
|
||||
{isDanger && (
|
||||
<View style={styles.iconWrap}>
|
||||
<AlertTriangle size={28} color="#C0392B" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.message}>{message}</Text>
|
||||
|
||||
{impactCount !== undefined && impactCount > 0 && (
|
||||
<View style={styles.impactBadge}>
|
||||
<Text style={styles.impactText}>
|
||||
This will affect {impactCount} record{impactCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, styles.cancelBtn]}
|
||||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel"
|
||||
>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, isDanger ? styles.dangerBtn : styles.confirmBtn]}
|
||||
onPress={onConfirm}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={confirmText}
|
||||
>
|
||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
dialog: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 16,
|
||||
elevation: 10,
|
||||
},
|
||||
iconWrap: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#2C3E50',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
impactBadge: {
|
||||
backgroundColor: '#FEF3C7',
|
||||
borderRadius: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
marginBottom: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
impactText: {
|
||||
fontSize: 12,
|
||||
color: '#92400E',
|
||||
fontWeight: '500',
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
btn: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
minHeight: 48,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cancelBtn: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
confirmBtn: {
|
||||
backgroundColor: '#1A6B72',
|
||||
},
|
||||
dangerBtn: {
|
||||
backgroundColor: '#C0392B',
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
@@ -40,6 +40,10 @@ export function DatePicker({ label, value, onChange }: Props) {
|
||||
<TouchableOpacity
|
||||
onPress={() => setShow(true)}
|
||||
className="flex-row items-center border border-gray-200 rounded-xl px-4 py-3 bg-white"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label ? `${label}: ${displayDate}` : displayDate}
|
||||
accessibilityHint="Opens date picker"
|
||||
style={{ minHeight: 48 }}
|
||||
>
|
||||
<Calendar size={16} color="#1A6B72" />
|
||||
<Text className={`ml-2 flex-1 ${value ? 'text-charcoal' : 'text-gray-400'}`}>{displayDate}</Text>
|
||||
@@ -88,7 +92,13 @@ export function DatePicker({ label, value, onChange }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleConfirm} className="bg-primary rounded-xl py-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirm}
|
||||
className="bg-primary rounded-xl py-4"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Confirm date selection"
|
||||
style={{ minHeight: 52 }}
|
||||
>
|
||||
<Text className="text-white text-center font-semibold text-base">Confirm</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
107
components/ui/EmptyState.tsx
Normal file
107
components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Users, MapPin, Map, Calendar, BookOpen, Settings,
|
||||
Inbox, FileText, Bell, Star, Heart, Search,
|
||||
Package, Archive, List, Clipboard, Tag, Shield,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
|
||||
// Map of icon name strings to actual components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Users,
|
||||
MapPin,
|
||||
Map,
|
||||
Calendar,
|
||||
BookOpen,
|
||||
Settings,
|
||||
Inbox,
|
||||
FileText,
|
||||
Bell,
|
||||
Star,
|
||||
Heart,
|
||||
Search,
|
||||
Package,
|
||||
Archive,
|
||||
List,
|
||||
Clipboard,
|
||||
Tag,
|
||||
Shield,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
title: string;
|
||||
message?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, message, actionLabel, onAction }: Props) {
|
||||
const IconComponent = icon ? ICON_MAP[icon] ?? Inbox : Inbox;
|
||||
|
||||
return (
|
||||
<View style={styles.container} accessibilityRole="text" accessibilityLabel={title}>
|
||||
<View style={styles.iconWrap}>
|
||||
<IconComponent size={40} color="#9CA3AF" strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
{actionLabel && onAction ? (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onAction}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={actionLabel}
|
||||
>
|
||||
<Text style={styles.buttonText}>{actionLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
textAlign: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#1A6B72',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 28,
|
||||
borderRadius: 12,
|
||||
minHeight: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,8 @@ export function Input({ label, error, ...props }: InputProps) {
|
||||
error ? 'border-danger' : 'border-gray-200'
|
||||
}`}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
accessibilityLabel={label}
|
||||
style={{ minHeight: 48 }}
|
||||
{...props}
|
||||
/>
|
||||
{error && <Text className="text-danger text-sm mt-1">{error}</Text>}
|
||||
|
||||
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