|
| 1 | +import { useEffect } from 'react' |
| 2 | +import { Pressable, View } from 'react-native' |
| 3 | +import Animated, { FadeInUp, FadeOutDown } from 'react-native-reanimated' |
| 4 | +import { X, ChevronRight } from 'lucide-react-native' |
| 5 | +import type { LucideIcon } from 'lucide-react-native' |
| 6 | +import { useTheme } from '../../theme/useTheme' |
| 7 | +import { AppText } from './Typography' |
| 8 | + |
| 9 | +export type ToastVariant = 'default' | 'success' | 'brand' | 'warning' | 'error' |
| 10 | + |
| 11 | +interface Props { |
| 12 | + title: string |
| 13 | + description?: string |
| 14 | + icon?: LucideIcon |
| 15 | + variant?: ToastVariant |
| 16 | + /** When set, the body is tappable (a chevron affordance is shown). */ |
| 17 | + onPress?: () => void |
| 18 | + onDismiss: () => void |
| 19 | + /** Auto-dismiss delay; pass 0 to keep it until dismissed. Default 4s. */ |
| 20 | + autoDismissMs?: number |
| 21 | + /** Extra classes on the absolute wrapper (e.g. to raise the dock point). */ |
| 22 | + className?: string |
| 23 | +} |
| 24 | + |
| 25 | +// Semantic colour applied only to the icon chip + border, keeping the text in the |
| 26 | +// normal tx-* hierarchy — a fully tinted toast reads as obnoxious over content. |
| 27 | +const VARIANTS: Record<ToastVariant, { border: string; chip: string }> = { |
| 28 | + default: { border: 'border-surface-border', chip: 'bg-surface-muted border-surface-border' }, |
| 29 | + success: { border: 'border-success-500/20', chip: 'bg-success-500/10 border-success-500/20' }, |
| 30 | + brand: { border: 'border-brand-500/20', chip: 'bg-brand-500/10 border-brand-500/20' }, |
| 31 | + warning: { border: 'border-warning-500/20', chip: 'bg-warning-500/10 border-warning-500/20' }, |
| 32 | + error: { border: 'border-error-500/20', chip: 'bg-error-500/10 border-error-500/20' }, |
| 33 | +} |
| 34 | + |
| 35 | +// A dismissible floating toast — the app's shared transient notification, mirror of |
| 36 | +// web ui/Toast. No portal needed on RN: render it as the LAST child of the screen |
| 37 | +// root and the absolute wrapper docks it just above the tab bar (screen content is |
| 38 | +// laid out above the bar, so bottom-3 clears it without a height constant). |
| 39 | +export function Toast({ |
| 40 | + title, |
| 41 | + description, |
| 42 | + icon: Icon, |
| 43 | + variant = 'default', |
| 44 | + onPress, |
| 45 | + onDismiss, |
| 46 | + autoDismissMs = 4000, |
| 47 | + className = '', |
| 48 | +}: Props) { |
| 49 | + const { colors, brand, accent, isDark } = useTheme() |
| 50 | + |
| 51 | + // Armed once on mount — parent re-renders must not extend the window (0 disables). |
| 52 | + useEffect(() => { |
| 53 | + if (!autoDismissMs) return |
| 54 | + const id = setTimeout(onDismiss, autoDismissMs) |
| 55 | + return () => clearTimeout(id) |
| 56 | + // eslint-disable-next-line react-hooks/exhaustive-deps |
| 57 | + }, []) |
| 58 | + |
| 59 | + // Icon `color` is a component prop, so it must come from the theme object (not a |
| 60 | + // className). Soft shades read on dark but wash out on light — pick via isDark |
| 61 | + // (same legibility rule as IconButton's danger variant). |
| 62 | + const ICON_COLOR: Record<ToastVariant, string> = { |
| 63 | + default: colors.txSecondary, |
| 64 | + success: isDark ? brand.successSoft : brand.success, |
| 65 | + brand: accent, |
| 66 | + warning: isDark ? brand.warningSoft : brand.warning, |
| 67 | + error: isDark ? brand.errorSoft : brand.error, |
| 68 | + } |
| 69 | + const v = VARIANTS[variant] |
| 70 | + |
| 71 | + const body = ( |
| 72 | + <> |
| 73 | + {Icon && ( |
| 74 | + <View className={`w-8 h-8 rounded-full border items-center justify-center ${v.chip}`}> |
| 75 | + <Icon size={16} color={ICON_COLOR[variant]} /> |
| 76 | + </View> |
| 77 | + )} |
| 78 | + <View className="flex-1"> |
| 79 | + <AppText variant="subheading">{title}</AppText> |
| 80 | + {description ? ( |
| 81 | + <AppText variant="caption" color="muted" numberOfLines={1}> |
| 82 | + {description} |
| 83 | + </AppText> |
| 84 | + ) : null} |
| 85 | + </View> |
| 86 | + {onPress && <ChevronRight size={16} color={colors.txMuted} />} |
| 87 | + </> |
| 88 | + ) |
| 89 | + |
| 90 | + return ( |
| 91 | + <Animated.View |
| 92 | + entering={FadeInUp.duration(200)} |
| 93 | + exiting={FadeOutDown.duration(150)} |
| 94 | + accessibilityRole="alert" |
| 95 | + className={`absolute bottom-3 left-3 right-3 z-50 ${className}`} |
| 96 | + > |
| 97 | + <View className={`flex-row items-center gap-3 rounded-2xl px-4 py-3 bg-surface-raised border shadow-lg ${v.border}`}> |
| 98 | + {onPress ? ( |
| 99 | + <Pressable onPress={onPress} className="flex-row items-center gap-3 flex-1 active:scale-95"> |
| 100 | + {body} |
| 101 | + </Pressable> |
| 102 | + ) : ( |
| 103 | + <View className="flex-row items-center gap-3 flex-1">{body}</View> |
| 104 | + )} |
| 105 | + <Pressable |
| 106 | + onPress={onDismiss} |
| 107 | + accessibilityLabel="Dismiss" |
| 108 | + hitSlop={8} |
| 109 | + className="p-1.5 -m-1.5 active:opacity-60" |
| 110 | + > |
| 111 | + <X size={16} color={colors.txMuted} /> |
| 112 | + </Pressable> |
| 113 | + </View> |
| 114 | + </Animated.View> |
| 115 | + ) |
| 116 | +} |
0 commit comments