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.
Files changed (80) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +376 -0
  3. package/lib/module/constants.js +40 -0
  4. package/lib/module/constants.js.map +1 -0
  5. package/lib/module/context.js +25 -0
  6. package/lib/module/context.js.map +1 -0
  7. package/lib/module/easings.js +9 -0
  8. package/lib/module/easings.js.map +1 -0
  9. package/lib/module/gestures.js +119 -0
  10. package/lib/module/gestures.js.map +1 -0
  11. package/lib/module/hooks.js +9 -0
  12. package/lib/module/hooks.js.map +1 -0
  13. package/lib/module/icons.js +332 -0
  14. package/lib/module/icons.js.map +1 -0
  15. package/lib/module/index.js +13 -0
  16. package/lib/module/index.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/module/state.js +200 -0
  19. package/lib/module/state.js.map +1 -0
  20. package/lib/module/theme.js +189 -0
  21. package/lib/module/theme.js.map +1 -0
  22. package/lib/module/toast.js +362 -0
  23. package/lib/module/toast.js.map +1 -0
  24. package/lib/module/toaster.js +198 -0
  25. package/lib/module/toaster.js.map +1 -0
  26. package/lib/module/types.js +4 -0
  27. package/lib/module/types.js.map +1 -0
  28. package/lib/module/use-app-state.js +13 -0
  29. package/lib/module/use-app-state.js.map +1 -0
  30. package/lib/module/use-pauseable-timer.js +18 -0
  31. package/lib/module/use-pauseable-timer.js.map +1 -0
  32. package/lib/module/use-toast-state.js +37 -0
  33. package/lib/module/use-toast-state.js.map +1 -0
  34. package/lib/typescript/package.json +1 -0
  35. package/lib/typescript/src/constants.d.ts +32 -0
  36. package/lib/typescript/src/constants.d.ts.map +1 -0
  37. package/lib/typescript/src/context.d.ts +20 -0
  38. package/lib/typescript/src/context.d.ts.map +1 -0
  39. package/lib/typescript/src/easings.d.ts +6 -0
  40. package/lib/typescript/src/easings.d.ts.map +1 -0
  41. package/lib/typescript/src/gestures.d.ts +17 -0
  42. package/lib/typescript/src/gestures.d.ts.map +1 -0
  43. package/lib/typescript/src/hooks.d.ts +5 -0
  44. package/lib/typescript/src/hooks.d.ts.map +1 -0
  45. package/lib/typescript/src/icons.d.ts +15 -0
  46. package/lib/typescript/src/icons.d.ts.map +1 -0
  47. package/lib/typescript/src/index.d.ts +12 -0
  48. package/lib/typescript/src/index.d.ts.map +1 -0
  49. package/lib/typescript/src/state.d.ts +66 -0
  50. package/lib/typescript/src/state.d.ts.map +1 -0
  51. package/lib/typescript/src/theme.d.ts +163 -0
  52. package/lib/typescript/src/theme.d.ts.map +1 -0
  53. package/lib/typescript/src/toast.d.ts +3 -0
  54. package/lib/typescript/src/toast.d.ts.map +1 -0
  55. package/lib/typescript/src/toaster.d.ts +3 -0
  56. package/lib/typescript/src/toaster.d.ts.map +1 -0
  57. package/lib/typescript/src/types.d.ts +264 -0
  58. package/lib/typescript/src/types.d.ts.map +1 -0
  59. package/lib/typescript/src/use-app-state.d.ts +3 -0
  60. package/lib/typescript/src/use-app-state.d.ts.map +1 -0
  61. package/lib/typescript/src/use-pauseable-timer.d.ts +2 -0
  62. package/lib/typescript/src/use-pauseable-timer.d.ts.map +1 -0
  63. package/lib/typescript/src/use-toast-state.d.ts +7 -0
  64. package/lib/typescript/src/use-toast-state.d.ts.map +1 -0
  65. package/package.json +152 -0
  66. package/src/constants.ts +44 -0
  67. package/src/context.tsx +38 -0
  68. package/src/easings.ts +7 -0
  69. package/src/gestures.tsx +135 -0
  70. package/src/hooks.ts +3 -0
  71. package/src/icons.tsx +227 -0
  72. package/src/index.tsx +48 -0
  73. package/src/state.ts +262 -0
  74. package/src/theme.ts +170 -0
  75. package/src/toast.tsx +429 -0
  76. package/src/toaster.tsx +221 -0
  77. package/src/types.ts +311 -0
  78. package/src/use-app-state.ts +15 -0
  79. package/src/use-pauseable-timer.ts +24 -0
  80. 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
+ }
@@ -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
+ });