react-native-sooner 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +376 -0
- package/lib/module/constants.js +40 -0
- package/lib/module/constants.js.map +1 -0
- package/lib/module/context.js +25 -0
- package/lib/module/context.js.map +1 -0
- package/lib/module/easings.js +9 -0
- package/lib/module/easings.js.map +1 -0
- package/lib/module/gestures.js +119 -0
- package/lib/module/gestures.js.map +1 -0
- package/lib/module/hooks.js +9 -0
- package/lib/module/hooks.js.map +1 -0
- package/lib/module/icons.js +332 -0
- package/lib/module/icons.js.map +1 -0
- package/lib/module/index.js +13 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/state.js +200 -0
- package/lib/module/state.js.map +1 -0
- package/lib/module/theme.js +189 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/toast.js +362 -0
- package/lib/module/toast.js.map +1 -0
- package/lib/module/toaster.js +198 -0
- package/lib/module/toaster.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/use-app-state.js +13 -0
- package/lib/module/use-app-state.js.map +1 -0
- package/lib/module/use-pauseable-timer.js +18 -0
- package/lib/module/use-pauseable-timer.js.map +1 -0
- package/lib/module/use-toast-state.js +37 -0
- package/lib/module/use-toast-state.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/constants.d.ts +32 -0
- package/lib/typescript/src/constants.d.ts.map +1 -0
- package/lib/typescript/src/context.d.ts +20 -0
- package/lib/typescript/src/context.d.ts.map +1 -0
- package/lib/typescript/src/easings.d.ts +6 -0
- package/lib/typescript/src/easings.d.ts.map +1 -0
- package/lib/typescript/src/gestures.d.ts +17 -0
- package/lib/typescript/src/gestures.d.ts.map +1 -0
- package/lib/typescript/src/hooks.d.ts +5 -0
- package/lib/typescript/src/hooks.d.ts.map +1 -0
- package/lib/typescript/src/icons.d.ts +15 -0
- package/lib/typescript/src/icons.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +12 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/state.d.ts +66 -0
- package/lib/typescript/src/state.d.ts.map +1 -0
- package/lib/typescript/src/theme.d.ts +163 -0
- package/lib/typescript/src/theme.d.ts.map +1 -0
- package/lib/typescript/src/toast.d.ts +3 -0
- package/lib/typescript/src/toast.d.ts.map +1 -0
- package/lib/typescript/src/toaster.d.ts +3 -0
- package/lib/typescript/src/toaster.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +264 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/use-app-state.d.ts +3 -0
- package/lib/typescript/src/use-app-state.d.ts.map +1 -0
- package/lib/typescript/src/use-pauseable-timer.d.ts +2 -0
- package/lib/typescript/src/use-pauseable-timer.d.ts.map +1 -0
- package/lib/typescript/src/use-toast-state.d.ts +7 -0
- package/lib/typescript/src/use-toast-state.d.ts.map +1 -0
- package/package.json +152 -0
- package/src/constants.ts +44 -0
- package/src/context.tsx +38 -0
- package/src/easings.ts +7 -0
- package/src/gestures.tsx +135 -0
- package/src/hooks.ts +3 -0
- package/src/icons.tsx +227 -0
- package/src/index.tsx +48 -0
- package/src/state.ts +262 -0
- package/src/theme.ts +170 -0
- package/src/toast.tsx +429 -0
- package/src/toaster.tsx +221 -0
- package/src/types.ts +311 -0
- package/src/use-app-state.ts +15 -0
- package/src/use-pauseable-timer.ts +24 -0
- package/src/use-toast-state.ts +43 -0
package/src/toast.tsx
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { Pressable, Text, View, type ViewStyle, type TextStyle } from "react-native";
|
|
3
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
4
|
+
import Animated, {
|
|
5
|
+
Easing,
|
|
6
|
+
runOnJS,
|
|
7
|
+
useAnimatedStyle,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
withSpring,
|
|
10
|
+
withTiming,
|
|
11
|
+
} from "react-native-reanimated";
|
|
12
|
+
import {
|
|
13
|
+
ANIMATION_DEFAULTS,
|
|
14
|
+
DAMPING_FACTOR,
|
|
15
|
+
ENTRY_OFFSET,
|
|
16
|
+
SNAP_BACK_DURATION,
|
|
17
|
+
SWIPE_EXIT_DISTANCE,
|
|
18
|
+
SWIPE_THRESHOLD,
|
|
19
|
+
VELOCITY_THRESHOLD,
|
|
20
|
+
} from "./constants";
|
|
21
|
+
import { CloseIcon, getIcon } from "./icons";
|
|
22
|
+
import { baseStyles, getIconColor, getToastColors } from "./theme";
|
|
23
|
+
import type { AnimationConfig, ToastIcons, ToastProps, ToastT, ToastType } from "./types";
|
|
24
|
+
import { usePauseableTimer } from "./use-pauseable-timer";
|
|
25
|
+
|
|
26
|
+
let Haptics: typeof import("expo-haptics") | null = null;
|
|
27
|
+
try {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
|
+
Haptics = require("expo-haptics");
|
|
30
|
+
} catch {
|
|
31
|
+
// expo-haptics not available
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function triggerHaptic(type: "light" | "medium" | "success" | "warning" | "error" = "light"): void {
|
|
35
|
+
if (!Haptics) return;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
switch (type) {
|
|
39
|
+
case "success":
|
|
40
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
41
|
+
break;
|
|
42
|
+
case "warning":
|
|
43
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
|
44
|
+
break;
|
|
45
|
+
case "error":
|
|
46
|
+
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
47
|
+
break;
|
|
48
|
+
case "medium":
|
|
49
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
50
|
+
break;
|
|
51
|
+
default:
|
|
52
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getSpringConfig(animation: Required<AnimationConfig>) {
|
|
60
|
+
return {
|
|
61
|
+
damping: animation.damping,
|
|
62
|
+
stiffness: animation.stiffness,
|
|
63
|
+
mass: animation.mass,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ToastIcon({
|
|
68
|
+
icon,
|
|
69
|
+
icons,
|
|
70
|
+
type,
|
|
71
|
+
iconColor,
|
|
72
|
+
}: {
|
|
73
|
+
icon?: React.ReactNode;
|
|
74
|
+
icons?: ToastIcons;
|
|
75
|
+
type: ToastType;
|
|
76
|
+
iconColor: string;
|
|
77
|
+
}) {
|
|
78
|
+
if (icon) {
|
|
79
|
+
return <View style={baseStyles.iconContainer}>{icon}</View>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (icons && type in icons) {
|
|
83
|
+
const customIcon = icons[type as keyof ToastIcons];
|
|
84
|
+
if (customIcon) {
|
|
85
|
+
return <View style={baseStyles.iconContainer}>{customIcon}</View>;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const defaultIcon = getIcon(type, iconColor);
|
|
90
|
+
if (defaultIcon) {
|
|
91
|
+
return <View style={baseStyles.iconContainer}>{defaultIcon}</View>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ToastActions({
|
|
98
|
+
toast,
|
|
99
|
+
action,
|
|
100
|
+
cancel,
|
|
101
|
+
toastColors,
|
|
102
|
+
defaultStyles,
|
|
103
|
+
hapticFeedback,
|
|
104
|
+
}: {
|
|
105
|
+
toast: ToastT;
|
|
106
|
+
action?: ToastT["action"];
|
|
107
|
+
cancel?: ToastT["cancel"];
|
|
108
|
+
toastColors: ReturnType<typeof getToastColors>;
|
|
109
|
+
defaultStyles?: ToastProps["defaultStyles"];
|
|
110
|
+
hapticFeedback: boolean;
|
|
111
|
+
}) {
|
|
112
|
+
const handleActionPress = useCallback(() => {
|
|
113
|
+
if (hapticFeedback) triggerHaptic("medium");
|
|
114
|
+
action?.onClick(toast);
|
|
115
|
+
}, [action, toast, hapticFeedback]);
|
|
116
|
+
|
|
117
|
+
const handleCancelPress = useCallback(() => {
|
|
118
|
+
if (hapticFeedback) triggerHaptic("light");
|
|
119
|
+
cancel?.onClick(toast);
|
|
120
|
+
}, [cancel, toast, hapticFeedback]);
|
|
121
|
+
|
|
122
|
+
if (!action && !cancel) return null;
|
|
123
|
+
|
|
124
|
+
const cancelButtonStyle: ViewStyle = {
|
|
125
|
+
...baseStyles.cancelButton,
|
|
126
|
+
borderColor: toastColors.border,
|
|
127
|
+
...(defaultStyles?.cancelButton as ViewStyle),
|
|
128
|
+
...(toast.styles?.cancelButton as ViewStyle),
|
|
129
|
+
...(cancel?.style as ViewStyle),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const cancelTextStyle: TextStyle = {
|
|
133
|
+
...baseStyles.cancelButtonText,
|
|
134
|
+
color: toastColors.description,
|
|
135
|
+
...(defaultStyles?.cancelButtonText as TextStyle),
|
|
136
|
+
...(toast.styles?.cancelButtonText as TextStyle),
|
|
137
|
+
...(cancel?.textStyle as TextStyle),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const actionButtonStyle: ViewStyle = {
|
|
141
|
+
...baseStyles.actionButton,
|
|
142
|
+
backgroundColor: toastColors.foreground,
|
|
143
|
+
...(defaultStyles?.actionButton as ViewStyle),
|
|
144
|
+
...(toast.styles?.actionButton as ViewStyle),
|
|
145
|
+
...(action?.style as ViewStyle),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const actionTextStyle: TextStyle = {
|
|
149
|
+
...baseStyles.actionButtonText,
|
|
150
|
+
color: toastColors.background,
|
|
151
|
+
...(defaultStyles?.actionButtonText as TextStyle),
|
|
152
|
+
...(toast.styles?.actionButtonText as TextStyle),
|
|
153
|
+
...(action?.textStyle as TextStyle),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<View style={baseStyles.actionsContainer}>
|
|
158
|
+
{cancel && (
|
|
159
|
+
<Pressable
|
|
160
|
+
onPress={handleCancelPress}
|
|
161
|
+
style={cancelButtonStyle}
|
|
162
|
+
accessibilityLabel={cancel.label}
|
|
163
|
+
>
|
|
164
|
+
<Text style={cancelTextStyle}>{cancel.label}</Text>
|
|
165
|
+
</Pressable>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{action && (
|
|
169
|
+
<Pressable
|
|
170
|
+
onPress={handleActionPress}
|
|
171
|
+
style={actionButtonStyle}
|
|
172
|
+
accessibilityLabel={action.label}
|
|
173
|
+
>
|
|
174
|
+
<Text style={actionTextStyle}>{action.label}</Text>
|
|
175
|
+
</Pressable>
|
|
176
|
+
)}
|
|
177
|
+
</View>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function Toast({
|
|
182
|
+
toast,
|
|
183
|
+
position,
|
|
184
|
+
gap,
|
|
185
|
+
swipeToDismiss,
|
|
186
|
+
swipeDirection,
|
|
187
|
+
theme,
|
|
188
|
+
richColors,
|
|
189
|
+
closeButton,
|
|
190
|
+
icons,
|
|
191
|
+
defaultStyles,
|
|
192
|
+
defaultAnimation = ANIMATION_DEFAULTS,
|
|
193
|
+
onDismiss,
|
|
194
|
+
duration,
|
|
195
|
+
pauseOnAppBackground,
|
|
196
|
+
hapticFeedback = false,
|
|
197
|
+
}: ToastProps) {
|
|
198
|
+
const animation: Required<AnimationConfig> = useMemo(() => {
|
|
199
|
+
const toastAnim = toast.animation;
|
|
200
|
+
return {
|
|
201
|
+
duration: toastAnim?.duration ?? defaultAnimation.duration ?? ANIMATION_DEFAULTS.duration,
|
|
202
|
+
exitDuration: toastAnim?.exitDuration ?? defaultAnimation.exitDuration ?? ANIMATION_DEFAULTS.exitDuration,
|
|
203
|
+
useSpring: toastAnim?.useSpring ?? defaultAnimation.useSpring ?? ANIMATION_DEFAULTS.useSpring,
|
|
204
|
+
damping: toastAnim?.damping ?? defaultAnimation.damping ?? ANIMATION_DEFAULTS.damping,
|
|
205
|
+
stiffness: toastAnim?.stiffness ?? defaultAnimation.stiffness ?? ANIMATION_DEFAULTS.stiffness,
|
|
206
|
+
mass: toastAnim?.mass ?? defaultAnimation.mass ?? ANIMATION_DEFAULTS.mass,
|
|
207
|
+
};
|
|
208
|
+
}, [defaultAnimation, toast.animation]);
|
|
209
|
+
|
|
210
|
+
const opacity = useSharedValue(0);
|
|
211
|
+
const translateY = useSharedValue(position.startsWith("top") ? -ENTRY_OFFSET : ENTRY_OFFSET);
|
|
212
|
+
const translateX = useSharedValue(0);
|
|
213
|
+
const scale = useSharedValue(0.95);
|
|
214
|
+
const isExiting = useRef(false);
|
|
215
|
+
|
|
216
|
+
const canSwipeLeft = swipeDirection.includes("left");
|
|
217
|
+
const canSwipeRight = swipeDirection.includes("right");
|
|
218
|
+
|
|
219
|
+
const { type, title, description, action, cancel, icon, dismissible = true } = toast;
|
|
220
|
+
const toastColors = getToastColors(type, theme, richColors);
|
|
221
|
+
|
|
222
|
+
const hasCustomIcon = Boolean(icon) || Boolean(icons?.[type as keyof ToastIcons]);
|
|
223
|
+
const iconColor = useMemo(() => {
|
|
224
|
+
if (hasCustomIcon || type === "default") return "";
|
|
225
|
+
return getIconColor(type, theme, richColors);
|
|
226
|
+
}, [hasCustomIcon, type, theme, richColors]);
|
|
227
|
+
|
|
228
|
+
const hasIcon = hasCustomIcon || type !== "default";
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (animation.useSpring) {
|
|
232
|
+
const springConfig = getSpringConfig(animation);
|
|
233
|
+
opacity.value = withTiming(1, { duration: animation.duration * 0.6 });
|
|
234
|
+
translateY.value = withSpring(0, springConfig);
|
|
235
|
+
scale.value = withSpring(1, springConfig);
|
|
236
|
+
} else {
|
|
237
|
+
const timingConfig = {
|
|
238
|
+
duration: animation.duration,
|
|
239
|
+
easing: Easing.bezier(0.19, 1, 0.22, 1),
|
|
240
|
+
};
|
|
241
|
+
opacity.value = withTiming(1, { duration: animation.duration * 0.6 });
|
|
242
|
+
translateY.value = withTiming(0, timingConfig);
|
|
243
|
+
scale.value = withTiming(1, timingConfig);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (hapticFeedback) {
|
|
247
|
+
const hapticType = type === "success" ? "success"
|
|
248
|
+
: type === "error" ? "error"
|
|
249
|
+
: type === "warning" ? "warning"
|
|
250
|
+
: "light";
|
|
251
|
+
triggerHaptic(hapticType);
|
|
252
|
+
}
|
|
253
|
+
}, [opacity, translateY, scale, animation, hapticFeedback, type]);
|
|
254
|
+
|
|
255
|
+
const handleDismiss = useCallback(() => {
|
|
256
|
+
if (isExiting.current) return;
|
|
257
|
+
isExiting.current = true;
|
|
258
|
+
|
|
259
|
+
const exitOffset = position.startsWith("top") ? -ENTRY_OFFSET : ENTRY_OFFSET;
|
|
260
|
+
const exitConfig = {
|
|
261
|
+
duration: animation.exitDuration,
|
|
262
|
+
easing: Easing.bezier(0.4, 0, 1, 1),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
opacity.value = withTiming(0, { duration: animation.exitDuration * 0.7 });
|
|
266
|
+
translateY.value = withTiming(exitOffset, exitConfig);
|
|
267
|
+
scale.value = withTiming(0.95, exitConfig);
|
|
268
|
+
|
|
269
|
+
setTimeout(() => onDismiss(toast.id), animation.exitDuration);
|
|
270
|
+
}, [opacity, translateY, scale, position, onDismiss, toast.id, animation.exitDuration]);
|
|
271
|
+
|
|
272
|
+
const delayedDismiss = useCallback(() => {
|
|
273
|
+
if (isExiting.current) return;
|
|
274
|
+
isExiting.current = true;
|
|
275
|
+
setTimeout(() => onDismiss(toast.id), animation.exitDuration);
|
|
276
|
+
}, [onDismiss, toast.id, animation.exitDuration]);
|
|
277
|
+
|
|
278
|
+
const effectiveDuration = toast.duration ?? duration;
|
|
279
|
+
const shouldAutoDismiss =
|
|
280
|
+
effectiveDuration > 0 &&
|
|
281
|
+
effectiveDuration !== Number.POSITIVE_INFINITY &&
|
|
282
|
+
toast.type !== "loading";
|
|
283
|
+
|
|
284
|
+
const handleAutoClose = useCallback(() => {
|
|
285
|
+
toast.onAutoClose?.(toast);
|
|
286
|
+
handleDismiss();
|
|
287
|
+
}, [toast, handleDismiss]);
|
|
288
|
+
|
|
289
|
+
usePauseableTimer(handleAutoClose, effectiveDuration, shouldAutoDismiss, pauseOnAppBackground);
|
|
290
|
+
|
|
291
|
+
const panGesture = Gesture.Pan()
|
|
292
|
+
.enabled(swipeToDismiss && dismissible)
|
|
293
|
+
.onUpdate((event) => {
|
|
294
|
+
"worklet";
|
|
295
|
+
if (canSwipeLeft && event.translationX < 0) {
|
|
296
|
+
translateX.value = event.translationX;
|
|
297
|
+
} else if (canSwipeRight && event.translationX > 0) {
|
|
298
|
+
translateX.value = event.translationX;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
|
|
302
|
+
const excess = Math.abs(translateX.value) - SWIPE_THRESHOLD;
|
|
303
|
+
const direction = translateX.value > 0 ? 1 : -1;
|
|
304
|
+
translateX.value = direction * (SWIPE_THRESHOLD + excess * DAMPING_FACTOR);
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
.onEnd((event) => {
|
|
308
|
+
"worklet";
|
|
309
|
+
const shouldDismiss =
|
|
310
|
+
Math.abs(event.translationX) > SWIPE_THRESHOLD ||
|
|
311
|
+
Math.abs(event.velocityX) > VELOCITY_THRESHOLD;
|
|
312
|
+
|
|
313
|
+
if (shouldDismiss) {
|
|
314
|
+
const direction = event.translationX > 0 ? 1 : -1;
|
|
315
|
+
translateX.value = withTiming(
|
|
316
|
+
direction * SWIPE_EXIT_DISTANCE,
|
|
317
|
+
{ duration: animation.exitDuration }
|
|
318
|
+
);
|
|
319
|
+
opacity.value = withTiming(0, { duration: animation.exitDuration });
|
|
320
|
+
runOnJS(delayedDismiss)();
|
|
321
|
+
} else {
|
|
322
|
+
translateX.value = withTiming(0, { duration: SNAP_BACK_DURATION });
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
327
|
+
opacity: opacity.value,
|
|
328
|
+
transform: [
|
|
329
|
+
{ translateY: translateY.value },
|
|
330
|
+
{ translateX: translateX.value },
|
|
331
|
+
{ scale: scale.value },
|
|
332
|
+
],
|
|
333
|
+
}));
|
|
334
|
+
|
|
335
|
+
const containerStyle: ViewStyle = {
|
|
336
|
+
...baseStyles.container,
|
|
337
|
+
backgroundColor: toastColors.background,
|
|
338
|
+
borderColor: toastColors.border,
|
|
339
|
+
marginBottom: gap,
|
|
340
|
+
...(defaultStyles?.container as ViewStyle),
|
|
341
|
+
...(toast.styles?.container as ViewStyle),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const contentStyle: ViewStyle = {
|
|
345
|
+
...baseStyles.content,
|
|
346
|
+
...(!hasIcon ? baseStyles.contentNoIcon : {}),
|
|
347
|
+
...(defaultStyles?.content as ViewStyle),
|
|
348
|
+
...(toast.styles?.content as ViewStyle),
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const titleStyle: TextStyle = {
|
|
352
|
+
...baseStyles.title,
|
|
353
|
+
color: toastColors.foreground,
|
|
354
|
+
...(defaultStyles?.title as TextStyle),
|
|
355
|
+
...(toast.styles?.title as TextStyle),
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const descriptionStyle: TextStyle = {
|
|
359
|
+
...baseStyles.description,
|
|
360
|
+
color: toastColors.description,
|
|
361
|
+
...(defaultStyles?.description as TextStyle),
|
|
362
|
+
...(toast.styles?.description as TextStyle),
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const closeButtonStyle: ViewStyle = {
|
|
366
|
+
...baseStyles.closeButton,
|
|
367
|
+
...(defaultStyles?.closeButton as ViewStyle),
|
|
368
|
+
...(toast.styles?.closeButton as ViewStyle),
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const accessibilityLabel = toast.accessibility?.accessibilityLabel
|
|
372
|
+
?? (typeof title === "string" ? title : undefined);
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<GestureDetector gesture={panGesture}>
|
|
376
|
+
<Animated.View
|
|
377
|
+
style={[containerStyle, animatedStyle]}
|
|
378
|
+
accessible={true}
|
|
379
|
+
accessibilityLabel={accessibilityLabel}
|
|
380
|
+
>
|
|
381
|
+
<ToastIcon
|
|
382
|
+
icon={icon}
|
|
383
|
+
icons={icons}
|
|
384
|
+
type={type}
|
|
385
|
+
iconColor={iconColor}
|
|
386
|
+
/>
|
|
387
|
+
|
|
388
|
+
<View style={contentStyle}>
|
|
389
|
+
{typeof title === "string" ? (
|
|
390
|
+
<Text style={titleStyle} numberOfLines={2}>
|
|
391
|
+
{title}
|
|
392
|
+
</Text>
|
|
393
|
+
) : (
|
|
394
|
+
title
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
{description &&
|
|
398
|
+
(typeof description === "string" ? (
|
|
399
|
+
<Text style={descriptionStyle} numberOfLines={3}>
|
|
400
|
+
{description}
|
|
401
|
+
</Text>
|
|
402
|
+
) : (
|
|
403
|
+
description
|
|
404
|
+
))}
|
|
405
|
+
</View>
|
|
406
|
+
|
|
407
|
+
<ToastActions
|
|
408
|
+
toast={toast}
|
|
409
|
+
action={action}
|
|
410
|
+
cancel={cancel}
|
|
411
|
+
toastColors={toastColors}
|
|
412
|
+
defaultStyles={defaultStyles}
|
|
413
|
+
hapticFeedback={hapticFeedback}
|
|
414
|
+
/>
|
|
415
|
+
|
|
416
|
+
{closeButton && dismissible && (
|
|
417
|
+
<Pressable
|
|
418
|
+
onPress={handleDismiss}
|
|
419
|
+
style={closeButtonStyle}
|
|
420
|
+
hitSlop={8}
|
|
421
|
+
accessibilityLabel="Close notification"
|
|
422
|
+
>
|
|
423
|
+
<CloseIcon color={toastColors.description} />
|
|
424
|
+
</Pressable>
|
|
425
|
+
)}
|
|
426
|
+
</Animated.View>
|
|
427
|
+
</GestureDetector>
|
|
428
|
+
);
|
|
429
|
+
}
|
package/src/toaster.tsx
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from "react";
|
|
2
|
+
import { AccessibilityInfo, Platform, StyleSheet, View, type ViewStyle } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { ANIMATION_DEFAULTS, toastDefaults } from "./constants";
|
|
5
|
+
import { ToastState } from "./state";
|
|
6
|
+
import { resolveTheme } from "./theme";
|
|
7
|
+
import { Toast } from "./toast";
|
|
8
|
+
import type { AnimationConfig, Position, SwipeDirection, ToasterProps, ToastT } from "./types";
|
|
9
|
+
import { useToastState } from "./use-toast-state";
|
|
10
|
+
|
|
11
|
+
interface Offset {
|
|
12
|
+
top: number;
|
|
13
|
+
bottom: number;
|
|
14
|
+
left: number;
|
|
15
|
+
right: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getContainerStyle(position: Position, offset: Offset) {
|
|
19
|
+
const isTop = position.startsWith("top");
|
|
20
|
+
const isBottom = position.startsWith("bottom");
|
|
21
|
+
|
|
22
|
+
const style: Record<string, number | string> = {
|
|
23
|
+
position: "absolute",
|
|
24
|
+
left: offset.left,
|
|
25
|
+
right: offset.right,
|
|
26
|
+
flexDirection: "column",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (isTop) {
|
|
30
|
+
style.top = offset.top;
|
|
31
|
+
} else if (isBottom) {
|
|
32
|
+
style.bottom = offset.bottom;
|
|
33
|
+
style.flexDirection = "column-reverse";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (position.includes("left")) {
|
|
37
|
+
style.alignItems = "flex-start";
|
|
38
|
+
} else if (position.includes("right")) {
|
|
39
|
+
style.alignItems = "flex-end";
|
|
40
|
+
} else {
|
|
41
|
+
style.alignItems = "center";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return style;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function announceForAccessibility(toast: ToastT): void {
|
|
48
|
+
if (toast.accessibility?.announceToScreenReader === false) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const message = toast.accessibility?.accessibilityLabel
|
|
53
|
+
?? (typeof toast.title === "string" ? toast.title : "Notification");
|
|
54
|
+
|
|
55
|
+
if (Platform.OS === "ios" || Platform.OS === "android") {
|
|
56
|
+
AccessibilityInfo.announceForAccessibility(message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Toaster({
|
|
61
|
+
position = toastDefaults.position,
|
|
62
|
+
theme = toastDefaults.theme,
|
|
63
|
+
duration = toastDefaults.duration,
|
|
64
|
+
gap = toastDefaults.gap,
|
|
65
|
+
offset: customOffset,
|
|
66
|
+
swipeToDismiss = toastDefaults.swipeToDismiss,
|
|
67
|
+
swipeDirection = toastDefaults.swipeDirection,
|
|
68
|
+
pauseOnAppBackground = toastDefaults.pauseOnAppBackground,
|
|
69
|
+
visibleToasts = toastDefaults.visibleToasts,
|
|
70
|
+
icons,
|
|
71
|
+
toastStyles,
|
|
72
|
+
containerStyle,
|
|
73
|
+
richColors = toastDefaults.richColors,
|
|
74
|
+
closeButton = toastDefaults.closeButton,
|
|
75
|
+
toasterId,
|
|
76
|
+
animation,
|
|
77
|
+
hapticFeedback = toastDefaults.hapticFeedback,
|
|
78
|
+
}: ToasterProps) {
|
|
79
|
+
const insets = useSafeAreaInsets();
|
|
80
|
+
const { toasts } = useToastState();
|
|
81
|
+
|
|
82
|
+
const resolvedTheme = resolveTheme(theme);
|
|
83
|
+
|
|
84
|
+
// Merge animation config with defaults
|
|
85
|
+
const mergedAnimation: Required<AnimationConfig> = useMemo(() => ({
|
|
86
|
+
...ANIMATION_DEFAULTS,
|
|
87
|
+
...animation,
|
|
88
|
+
}), [animation]);
|
|
89
|
+
|
|
90
|
+
const offset = useMemo(() => {
|
|
91
|
+
const base = { ...toastDefaults.offset, ...customOffset };
|
|
92
|
+
return {
|
|
93
|
+
top: base.top + insets.top,
|
|
94
|
+
bottom: base.bottom + insets.bottom,
|
|
95
|
+
left: base.left + insets.left,
|
|
96
|
+
right: base.right + insets.right,
|
|
97
|
+
};
|
|
98
|
+
}, [customOffset, insets]);
|
|
99
|
+
|
|
100
|
+
const normalizedSwipeDirection: SwipeDirection[] = useMemo(() => {
|
|
101
|
+
return Array.isArray(swipeDirection) ? swipeDirection : [swipeDirection];
|
|
102
|
+
}, [swipeDirection]);
|
|
103
|
+
|
|
104
|
+
// Filter toasts by toasterId if provided
|
|
105
|
+
const filteredToasts = useMemo(() => {
|
|
106
|
+
if (!toasterId) {
|
|
107
|
+
return toasts;
|
|
108
|
+
}
|
|
109
|
+
// Only show toasts that match this toasterId or have no toasterId set
|
|
110
|
+
return toasts.filter((t) => t.toasterId === toasterId || t.toasterId === undefined);
|
|
111
|
+
}, [toasts, toasterId]);
|
|
112
|
+
|
|
113
|
+
// Sort toasts: newest first for top position, oldest first for bottom
|
|
114
|
+
// This ensures newest toast appears at the edge (top or bottom)
|
|
115
|
+
const sortedToasts = useMemo(() => {
|
|
116
|
+
const sorted = [...filteredToasts].sort((a, b) => b.createdAt - a.createdAt);
|
|
117
|
+
// For top: newest first (appears at top, older ones below)
|
|
118
|
+
// For bottom: reverse so newest appears at bottom edge
|
|
119
|
+
return position.startsWith("bottom") ? sorted.reverse() : sorted;
|
|
120
|
+
}, [filteredToasts, position]);
|
|
121
|
+
|
|
122
|
+
// Enforce visibleToasts limit - show only the most recent N toasts
|
|
123
|
+
// Important toasts are always shown
|
|
124
|
+
const visibleToastList = useMemo(() => {
|
|
125
|
+
if (visibleToasts <= 0) {
|
|
126
|
+
return sortedToasts;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Separate important and regular toasts
|
|
130
|
+
const important = sortedToasts.filter((t) => t.important);
|
|
131
|
+
const regular = sortedToasts.filter((t) => !t.important);
|
|
132
|
+
|
|
133
|
+
// Show all important toasts plus fill remaining slots with regular toasts
|
|
134
|
+
const remainingSlots = Math.max(0, visibleToasts - important.length);
|
|
135
|
+
const visibleRegular = regular.slice(0, remainingSlots);
|
|
136
|
+
|
|
137
|
+
// Combine and maintain sort order
|
|
138
|
+
const combined = [...important, ...visibleRegular];
|
|
139
|
+
return combined.sort((a, b) => {
|
|
140
|
+
const aIndex = sortedToasts.indexOf(a);
|
|
141
|
+
const bIndex = sortedToasts.indexOf(b);
|
|
142
|
+
return aIndex - bIndex;
|
|
143
|
+
});
|
|
144
|
+
}, [sortedToasts, visibleToasts]);
|
|
145
|
+
|
|
146
|
+
const handleDismiss = useCallback((toastId: string | number) => {
|
|
147
|
+
const toast = toasts.find((t) => t.id === toastId);
|
|
148
|
+
if (toast) {
|
|
149
|
+
toast.onDismiss?.(toast);
|
|
150
|
+
}
|
|
151
|
+
ToastState.dismiss(toastId);
|
|
152
|
+
}, [toasts]);
|
|
153
|
+
|
|
154
|
+
// Announce new toasts for accessibility
|
|
155
|
+
const announcedRef = React.useRef<Set<string | number>>(new Set());
|
|
156
|
+
React.useEffect(() => {
|
|
157
|
+
for (const toast of visibleToastList) {
|
|
158
|
+
if (!announcedRef.current.has(toast.id)) {
|
|
159
|
+
announcedRef.current.add(toast.id);
|
|
160
|
+
announceForAccessibility(toast);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Clean up old IDs
|
|
164
|
+
const currentIds = new Set(visibleToastList.map((t) => t.id));
|
|
165
|
+
announcedRef.current.forEach((id) => {
|
|
166
|
+
if (!currentIds.has(id)) {
|
|
167
|
+
announcedRef.current.delete(id);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}, [visibleToastList]);
|
|
171
|
+
|
|
172
|
+
if (visibleToastList.length === 0) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const positionStyle = getContainerStyle(position, offset);
|
|
177
|
+
|
|
178
|
+
// Build container style using spread
|
|
179
|
+
const finalContainerStyle: ViewStyle = {
|
|
180
|
+
...styles.container,
|
|
181
|
+
...positionStyle,
|
|
182
|
+
...(containerStyle as ViewStyle),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<View
|
|
187
|
+
style={finalContainerStyle}
|
|
188
|
+
pointerEvents="box-none"
|
|
189
|
+
accessible={true}
|
|
190
|
+
accessibilityLabel="Notifications"
|
|
191
|
+
>
|
|
192
|
+
{visibleToastList.map((toast) => (
|
|
193
|
+
<Toast
|
|
194
|
+
key={toast.id}
|
|
195
|
+
toast={toast}
|
|
196
|
+
position={position}
|
|
197
|
+
gap={gap}
|
|
198
|
+
swipeToDismiss={swipeToDismiss}
|
|
199
|
+
swipeDirection={normalizedSwipeDirection}
|
|
200
|
+
theme={resolvedTheme}
|
|
201
|
+
richColors={richColors}
|
|
202
|
+
closeButton={closeButton}
|
|
203
|
+
icons={icons}
|
|
204
|
+
defaultStyles={toastStyles}
|
|
205
|
+
defaultAnimation={mergedAnimation}
|
|
206
|
+
onDismiss={handleDismiss}
|
|
207
|
+
duration={duration}
|
|
208
|
+
pauseOnAppBackground={pauseOnAppBackground}
|
|
209
|
+
hapticFeedback={hapticFeedback}
|
|
210
|
+
/>
|
|
211
|
+
))}
|
|
212
|
+
</View>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const styles = StyleSheet.create({
|
|
217
|
+
container: {
|
|
218
|
+
zIndex: 9999,
|
|
219
|
+
pointerEvents: "box-none",
|
|
220
|
+
},
|
|
221
|
+
});
|