react-native-bread 0.1.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/README.md +148 -0
- package/lib/commonjs/icons/CloseIcon.js +22 -0
- package/lib/commonjs/icons/CloseIcon.js.map +1 -0
- package/lib/commonjs/icons/GreenCheck.js +27 -0
- package/lib/commonjs/icons/GreenCheck.js.map +1 -0
- package/lib/commonjs/icons/InfoIcon.js +24 -0
- package/lib/commonjs/icons/InfoIcon.js.map +1 -0
- package/lib/commonjs/icons/RedX.js +27 -0
- package/lib/commonjs/icons/RedX.js.map +1 -0
- package/lib/commonjs/icons/index.js +34 -0
- package/lib/commonjs/icons/index.js.map +1 -0
- package/lib/commonjs/index.js +59 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/toast-api.js +127 -0
- package/lib/commonjs/toast-api.js.map +1 -0
- package/lib/commonjs/toast-provider.js +71 -0
- package/lib/commonjs/toast-provider.js.map +1 -0
- package/lib/commonjs/toast-store.js +278 -0
- package/lib/commonjs/toast-store.js.map +1 -0
- package/lib/commonjs/toast.js +445 -0
- package/lib/commonjs/toast.js.map +1 -0
- package/lib/commonjs/types.js +6 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/icons/CloseIcon.js +16 -0
- package/lib/module/icons/CloseIcon.js.map +1 -0
- package/lib/module/icons/GreenCheck.js +21 -0
- package/lib/module/icons/GreenCheck.js.map +1 -0
- package/lib/module/icons/InfoIcon.js +18 -0
- package/lib/module/icons/InfoIcon.js.map +1 -0
- package/lib/module/icons/RedX.js +21 -0
- package/lib/module/icons/RedX.js.map +1 -0
- package/lib/module/icons/index.js +7 -0
- package/lib/module/icons/index.js.map +1 -0
- package/lib/module/index.js +14 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/toast-api.js +124 -0
- package/lib/module/toast-api.js.map +1 -0
- package/lib/module/toast-provider.js +67 -0
- package/lib/module/toast-provider.js.map +1 -0
- package/lib/module/toast-store.js +274 -0
- package/lib/module/toast-store.js.map +1 -0
- package/lib/module/toast.js +439 -0
- package/lib/module/toast.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/icons/CloseIcon.d.ts +3 -0
- package/lib/typescript/icons/CloseIcon.d.ts.map +1 -0
- package/lib/typescript/icons/GreenCheck.d.ts +3 -0
- package/lib/typescript/icons/GreenCheck.d.ts.map +1 -0
- package/lib/typescript/icons/InfoIcon.d.ts +3 -0
- package/lib/typescript/icons/InfoIcon.d.ts.map +1 -0
- package/lib/typescript/icons/RedX.d.ts +3 -0
- package/lib/typescript/icons/RedX.d.ts.map +1 -0
- package/lib/typescript/icons/index.d.ts +5 -0
- package/lib/typescript/icons/index.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +7 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/toast-api.d.ts +109 -0
- package/lib/typescript/toast-api.d.ts.map +1 -0
- package/lib/typescript/toast-provider.d.ts +52 -0
- package/lib/typescript/toast-provider.d.ts.map +1 -0
- package/lib/typescript/toast-store.d.ts +26 -0
- package/lib/typescript/toast-store.d.ts.map +1 -0
- package/lib/typescript/toast.d.ts +2 -0
- package/lib/typescript/toast.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +101 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +87 -0
- package/src/icons/CloseIcon.tsx +10 -0
- package/src/icons/GreenCheck.tsx +16 -0
- package/src/icons/InfoIcon.tsx +12 -0
- package/src/icons/RedX.tsx +16 -0
- package/src/icons/index.ts +4 -0
- package/src/index.ts +26 -0
- package/src/toast-api.ts +213 -0
- package/src/toast-provider.tsx +81 -0
- package/src/toast-store.ts +270 -0
- package/src/toast.tsx +417 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { Toast, ToastConfig, ToastOptions, ToastState, ToastTheme, ToastType, ToastTypeColors } from "./types";
|
|
2
|
+
|
|
3
|
+
export type Listener = (state: ToastState) => void;
|
|
4
|
+
|
|
5
|
+
const EXIT_DURATION = 350;
|
|
6
|
+
|
|
7
|
+
/** Default theme values */
|
|
8
|
+
const DEFAULT_THEME: ToastTheme = {
|
|
9
|
+
position: "top",
|
|
10
|
+
offset: 0,
|
|
11
|
+
stacking: true,
|
|
12
|
+
maxStack: 3,
|
|
13
|
+
dismissible: true,
|
|
14
|
+
showCloseButton: true,
|
|
15
|
+
colors: {
|
|
16
|
+
success: { accent: "#28B770", background: "#FFFFFF" },
|
|
17
|
+
error: { accent: "#F05964", background: "#FFFFFF" },
|
|
18
|
+
info: { accent: "#EDBE43", background: "#FFFFFF" },
|
|
19
|
+
loading: { accent: "#232323", background: "#FFFFFF" },
|
|
20
|
+
},
|
|
21
|
+
icons: {},
|
|
22
|
+
toastStyle: {},
|
|
23
|
+
titleStyle: {},
|
|
24
|
+
descriptionStyle: {},
|
|
25
|
+
defaultDuration: 4000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Deep merge user config with defaults */
|
|
29
|
+
function mergeConfig(config: ToastConfig | undefined): ToastTheme {
|
|
30
|
+
if (!config) return DEFAULT_THEME;
|
|
31
|
+
|
|
32
|
+
const mergedColors = { ...DEFAULT_THEME.colors };
|
|
33
|
+
if (config.colors) {
|
|
34
|
+
for (const type of Object.keys(config.colors) as ToastType[]) {
|
|
35
|
+
const userColors = config.colors[type];
|
|
36
|
+
if (userColors) {
|
|
37
|
+
mergedColors[type] = {
|
|
38
|
+
...DEFAULT_THEME.colors[type],
|
|
39
|
+
...userColors,
|
|
40
|
+
} as ToastTypeColors;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
position: config.position ?? DEFAULT_THEME.position,
|
|
47
|
+
offset: config.offset ?? DEFAULT_THEME.offset,
|
|
48
|
+
stacking: config.stacking ?? DEFAULT_THEME.stacking,
|
|
49
|
+
maxStack: config.maxStack ?? DEFAULT_THEME.maxStack,
|
|
50
|
+
dismissible: config.dismissible ?? DEFAULT_THEME.dismissible,
|
|
51
|
+
showCloseButton: config.showCloseButton ?? DEFAULT_THEME.showCloseButton,
|
|
52
|
+
colors: mergedColors,
|
|
53
|
+
icons: { ...DEFAULT_THEME.icons, ...config.icons },
|
|
54
|
+
toastStyle: { ...DEFAULT_THEME.toastStyle, ...config.toastStyle },
|
|
55
|
+
titleStyle: { ...DEFAULT_THEME.titleStyle, ...config.titleStyle },
|
|
56
|
+
descriptionStyle: { ...DEFAULT_THEME.descriptionStyle, ...config.descriptionStyle },
|
|
57
|
+
defaultDuration: config.defaultDuration ?? DEFAULT_THEME.defaultDuration,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class ToastStore {
|
|
62
|
+
private state: ToastState = {
|
|
63
|
+
visibleToasts: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
private theme: ToastTheme = DEFAULT_THEME;
|
|
67
|
+
|
|
68
|
+
private listeners = new Set<Listener>();
|
|
69
|
+
private toastIdCounter = 0;
|
|
70
|
+
private timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
71
|
+
|
|
72
|
+
subscribe = (listener: Listener) => {
|
|
73
|
+
this.listeners.add(listener);
|
|
74
|
+
return () => {
|
|
75
|
+
this.listeners.delete(listener);
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
private emit() {
|
|
80
|
+
for (const listener of this.listeners) {
|
|
81
|
+
listener(this.state);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private setState(partial: Partial<ToastState>) {
|
|
86
|
+
this.state = { ...this.state, ...partial };
|
|
87
|
+
this.emit();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getState = () => this.state;
|
|
91
|
+
|
|
92
|
+
getTheme = () => this.theme;
|
|
93
|
+
|
|
94
|
+
setConfig = (config: ToastConfig | undefined) => {
|
|
95
|
+
this.theme = mergeConfig(config);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
show = (
|
|
99
|
+
title: string,
|
|
100
|
+
description?: string,
|
|
101
|
+
type: ToastType = "success",
|
|
102
|
+
duration?: number,
|
|
103
|
+
options?: ToastOptions
|
|
104
|
+
): string => {
|
|
105
|
+
const actualDuration = duration ?? options?.duration ?? this.theme.defaultDuration;
|
|
106
|
+
const maxToasts = this.theme.stacking ? this.theme.maxStack : 1;
|
|
107
|
+
|
|
108
|
+
const id = `toast-${++this.toastIdCounter}`;
|
|
109
|
+
const newToast: Toast = {
|
|
110
|
+
id,
|
|
111
|
+
title,
|
|
112
|
+
description: description ?? options?.description,
|
|
113
|
+
type,
|
|
114
|
+
duration: actualDuration,
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
isExiting: false,
|
|
117
|
+
options,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const { visibleToasts } = this.state;
|
|
121
|
+
|
|
122
|
+
// Get only non-exiting toasts for count
|
|
123
|
+
const activeToasts = visibleToasts.filter(t => !t.isExiting);
|
|
124
|
+
|
|
125
|
+
if (activeToasts.length >= maxToasts) {
|
|
126
|
+
const toastsToRemove = activeToasts.slice(maxToasts - 1);
|
|
127
|
+
|
|
128
|
+
for (const toast of toastsToRemove) {
|
|
129
|
+
// Clear auto-dismiss timeout
|
|
130
|
+
const timeout = this.timeouts.get(toast.id);
|
|
131
|
+
if (timeout) {
|
|
132
|
+
clearTimeout(timeout);
|
|
133
|
+
this.timeouts.delete(toast.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const removeIds = new Set(toastsToRemove.map(t => t.id));
|
|
138
|
+
|
|
139
|
+
if (this.theme.stacking) {
|
|
140
|
+
// When stacking is ON: remove old toasts from state immediately (no animation for stack overflow)
|
|
141
|
+
this.setState({
|
|
142
|
+
visibleToasts: visibleToasts.filter(t => !removeIds.has(t.id)),
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
// When stacking is OFF: animate out the old toast, wait, then show new one
|
|
146
|
+
this.setState({
|
|
147
|
+
visibleToasts: visibleToasts.map(t => (removeIds.has(t.id) ? { ...t, isExiting: true } : t)),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Delay showing the new toast until the old one has animated out
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
for (const toast of toastsToRemove) {
|
|
153
|
+
this.removeToast(toast.id);
|
|
154
|
+
}
|
|
155
|
+
this.addToast(newToast, actualDuration);
|
|
156
|
+
}, EXIT_DURATION - 220);
|
|
157
|
+
|
|
158
|
+
return id;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Add new toast immediately (stacking ON or no existing toasts)
|
|
163
|
+
this.addToast(newToast, actualDuration);
|
|
164
|
+
|
|
165
|
+
return id;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
private addToast(toast: Toast, duration: number) {
|
|
169
|
+
this.setState({
|
|
170
|
+
visibleToasts: [toast, ...this.state.visibleToasts.filter(t => !t.isExiting)],
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Schedule auto-dismiss with duration multiplier based on position
|
|
174
|
+
this.scheduleTimeout(toast.id, duration, 0);
|
|
175
|
+
|
|
176
|
+
// Reschedule timeouts for other toasts based on their new positions
|
|
177
|
+
this.rescheduleAllTimeouts();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private scheduleTimeout(id: string, baseDuration: number, index: number) {
|
|
181
|
+
const existingTimeout = this.timeouts.get(id);
|
|
182
|
+
if (existingTimeout) {
|
|
183
|
+
clearTimeout(existingTimeout);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Duration multiplier: index 0 = 1x, index 1 = 2x, index 2 = 3x
|
|
187
|
+
const duration = baseDuration * (index + 1);
|
|
188
|
+
|
|
189
|
+
const timeout = setTimeout(() => {
|
|
190
|
+
this.hide(id);
|
|
191
|
+
}, duration);
|
|
192
|
+
|
|
193
|
+
this.timeouts.set(id, timeout);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private rescheduleAllTimeouts() {
|
|
197
|
+
const { visibleToasts } = this.state;
|
|
198
|
+
|
|
199
|
+
visibleToasts.forEach((toast, index) => {
|
|
200
|
+
// Skip if already exiting or index 0 (just scheduled)
|
|
201
|
+
if (toast.isExiting || index === 0) return;
|
|
202
|
+
|
|
203
|
+
this.scheduleTimeout(toast.id, toast.duration, index);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
hide = (id: string) => {
|
|
208
|
+
const { visibleToasts } = this.state;
|
|
209
|
+
const toast = visibleToasts.find(t => t.id === id);
|
|
210
|
+
if (!toast || toast.isExiting) return;
|
|
211
|
+
|
|
212
|
+
// Clear the auto-dismiss timeout
|
|
213
|
+
const timeout = this.timeouts.get(id);
|
|
214
|
+
if (timeout) {
|
|
215
|
+
clearTimeout(timeout);
|
|
216
|
+
this.timeouts.delete(id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Mark as exiting (triggers exit animation in component)
|
|
220
|
+
this.setState({
|
|
221
|
+
visibleToasts: visibleToasts.map(t => (t.id === id ? { ...t, isExiting: true } : t)),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// After exit animation, actually remove the toast
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
this.removeToast(id);
|
|
227
|
+
}, EXIT_DURATION);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
private removeToast(id: string) {
|
|
231
|
+
const timeout = this.timeouts.get(id);
|
|
232
|
+
if (timeout) {
|
|
233
|
+
clearTimeout(timeout);
|
|
234
|
+
this.timeouts.delete(id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.setState({
|
|
238
|
+
visibleToasts: this.state.visibleToasts.filter(t => t.id !== id),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Reschedule remaining toasts with updated positions
|
|
242
|
+
this.rescheduleAllTimeouts();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
updateToast = (id: string, data: Partial<Omit<Toast, "id" | "createdAt">>) => {
|
|
246
|
+
const { visibleToasts } = this.state;
|
|
247
|
+
const index = visibleToasts.findIndex(t => t.id === id);
|
|
248
|
+
if (index === -1) return;
|
|
249
|
+
|
|
250
|
+
this.setState({
|
|
251
|
+
visibleToasts: visibleToasts.map(t => (t.id === id ? { ...t, ...data } : t)),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (data.duration !== undefined) {
|
|
255
|
+
this.scheduleTimeout(id, data.duration, index);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
hideAll = () => {
|
|
260
|
+
for (const timeout of this.timeouts.values()) {
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
}
|
|
263
|
+
this.timeouts.clear();
|
|
264
|
+
this.setState({ visibleToasts: [] });
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const toastStore = new ToastStore();
|
|
269
|
+
|
|
270
|
+
export type { Toast, ToastState, ToastType };
|
package/src/toast.tsx
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
4
|
+
import Animated, {
|
|
5
|
+
Easing,
|
|
6
|
+
interpolate,
|
|
7
|
+
interpolateColor,
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useSharedValue,
|
|
10
|
+
withTiming,
|
|
11
|
+
} from "react-native-reanimated";
|
|
12
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
13
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
14
|
+
import { CloseIcon, GreenCheck, InfoIcon, RedX } from "./icons";
|
|
15
|
+
import { type Toast as ToastData, type ToastState, type ToastType, toastStore } from "./toast-store";
|
|
16
|
+
import type { IconRenderFn, ToastPosition, ToastTheme } from "./types";
|
|
17
|
+
|
|
18
|
+
const ICON_SIZE = 28;
|
|
19
|
+
|
|
20
|
+
/** Default icon for each toast type */
|
|
21
|
+
const DefaultIcon = ({ type, accentColor }: { type: ToastType; accentColor: string }) => {
|
|
22
|
+
switch (type) {
|
|
23
|
+
case "success":
|
|
24
|
+
return <GreenCheck width={36} height={36} fill={accentColor} />;
|
|
25
|
+
case "error":
|
|
26
|
+
return <RedX width={ICON_SIZE} height={ICON_SIZE} fill={accentColor} />;
|
|
27
|
+
case "loading":
|
|
28
|
+
return <ActivityIndicator size={ICON_SIZE} color={accentColor} />;
|
|
29
|
+
case "info":
|
|
30
|
+
return <InfoIcon width={ICON_SIZE} height={ICON_SIZE} fill={accentColor} />;
|
|
31
|
+
default:
|
|
32
|
+
return <GreenCheck width={36} height={36} fill={accentColor} />;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Resolves the icon to render - checks per-toast, then config, then default */
|
|
37
|
+
const resolveIcon = (
|
|
38
|
+
type: ToastType,
|
|
39
|
+
accentColor: string,
|
|
40
|
+
customIcon?: ReactNode | IconRenderFn,
|
|
41
|
+
configIcon?: IconRenderFn
|
|
42
|
+
): ReactNode => {
|
|
43
|
+
// Per-toast custom icon takes priority
|
|
44
|
+
if (customIcon) {
|
|
45
|
+
if (typeof customIcon === "function") {
|
|
46
|
+
return customIcon({ color: accentColor, size: ICON_SIZE });
|
|
47
|
+
}
|
|
48
|
+
return customIcon;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Config-level custom icon
|
|
52
|
+
if (configIcon) {
|
|
53
|
+
return configIcon({ color: accentColor, size: ICON_SIZE });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Default icon
|
|
57
|
+
return <DefaultIcon type={type} accentColor={accentColor} />;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
interface AnimatedIconProps {
|
|
61
|
+
type: ToastType;
|
|
62
|
+
accentColor: string;
|
|
63
|
+
customIcon?: ReactNode | IconRenderFn;
|
|
64
|
+
configIcon?: IconRenderFn;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const AnimatedIcon = ({ type, accentColor, customIcon, configIcon }: AnimatedIconProps) => {
|
|
68
|
+
const progress = useSharedValue(0);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
progress.value = withTiming(1, { duration: 350, easing: Easing.out(Easing.back(1.5)) });
|
|
72
|
+
}, [progress]);
|
|
73
|
+
|
|
74
|
+
const style = useAnimatedStyle(() => ({
|
|
75
|
+
opacity: progress.value,
|
|
76
|
+
transform: [{ scale: 0.7 + progress.value * 0.3 }],
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
return <Animated.View style={style}>{resolveIcon(type, accentColor, customIcon, configIcon)}</Animated.View>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// singleton instance
|
|
83
|
+
export const ToastContainer = () => {
|
|
84
|
+
const [visibleToasts, setVisibleToasts] = useState<ToastData[]>([]);
|
|
85
|
+
const [theme, setTheme] = useState<ToastTheme>(() => toastStore.getTheme());
|
|
86
|
+
const { top, bottom } = useSafeAreaInsets();
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const initialState = toastStore.getState();
|
|
90
|
+
setVisibleToasts(initialState.visibleToasts);
|
|
91
|
+
setTheme(toastStore.getTheme());
|
|
92
|
+
|
|
93
|
+
return toastStore.subscribe((state: ToastState) => {
|
|
94
|
+
setVisibleToasts(state.visibleToasts);
|
|
95
|
+
setTheme(toastStore.getTheme());
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// Calculate visual index for each toast (exiting toasts don't count)
|
|
100
|
+
const getVisualIndex = useCallback(
|
|
101
|
+
(toastId: string) => {
|
|
102
|
+
let visualIndex = 0;
|
|
103
|
+
for (const t of visibleToasts) {
|
|
104
|
+
if (t.id === toastId) break;
|
|
105
|
+
if (!t.isExiting) visualIndex++;
|
|
106
|
+
}
|
|
107
|
+
return visualIndex;
|
|
108
|
+
},
|
|
109
|
+
[visibleToasts]
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Memoize the reversed array to avoid recreating on each render
|
|
113
|
+
const reversedToasts = useMemo(() => [...visibleToasts].reverse(), [visibleToasts]);
|
|
114
|
+
|
|
115
|
+
if (visibleToasts.length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isBottom = theme.position === "bottom";
|
|
120
|
+
const inset = isBottom ? bottom : top;
|
|
121
|
+
const positionStyle = isBottom ? { bottom: inset + theme.offset + 2 } : { top: inset + theme.offset + 2 };
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<View style={[styles.container, positionStyle]} pointerEvents="box-none">
|
|
125
|
+
{reversedToasts.map(toast => {
|
|
126
|
+
const index = toast.isExiting ? -1 : getVisualIndex(toast.id);
|
|
127
|
+
return <MemoizedToastItem key={toast.id} toast={toast} index={index} theme={theme} position={theme.position} />;
|
|
128
|
+
})}
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
interface ToastItemProps {
|
|
134
|
+
toast: ToastData;
|
|
135
|
+
index: number;
|
|
136
|
+
theme: ToastTheme;
|
|
137
|
+
position: ToastPosition;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const EASING = Easing.bezier(0.25, 0.1, 0.25, 1.0);
|
|
141
|
+
const ToY = 0;
|
|
142
|
+
const Duration = 400;
|
|
143
|
+
const ExitDuration = 350;
|
|
144
|
+
const MaxDragDown = 60;
|
|
145
|
+
|
|
146
|
+
const ToastItem = ({ toast, index, theme, position }: ToastItemProps) => {
|
|
147
|
+
const progress = useSharedValue(0);
|
|
148
|
+
const translationY = useSharedValue(0);
|
|
149
|
+
const isBeingDragged = useSharedValue(false);
|
|
150
|
+
const shouldDismiss = useSharedValue(false);
|
|
151
|
+
|
|
152
|
+
// Position-based animation values
|
|
153
|
+
const isBottom = position === "bottom";
|
|
154
|
+
const entryFromY = isBottom ? 80 : -80;
|
|
155
|
+
const exitToY = isBottom ? 100 : -100;
|
|
156
|
+
|
|
157
|
+
// Stack position animation
|
|
158
|
+
const stackIndex = useSharedValue(index);
|
|
159
|
+
|
|
160
|
+
// Title color animation on variant change
|
|
161
|
+
const colorProgress = useSharedValue(1);
|
|
162
|
+
const fromColor = useSharedValue(theme.colors[toast.type].accent);
|
|
163
|
+
const toColor = useSharedValue(theme.colors[toast.type].accent);
|
|
164
|
+
|
|
165
|
+
// Refs for tracking previous values to avoid unnecessary animations
|
|
166
|
+
const lastHandledType = useRef(toast.type);
|
|
167
|
+
const prevIndex = useRef(index);
|
|
168
|
+
const hasEntered = useRef(false);
|
|
169
|
+
|
|
170
|
+
// Combined animation effect for entry, exit, color transitions, and stack position
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
// Entry animation (only once on mount)
|
|
173
|
+
if (!hasEntered.current && !toast.isExiting) {
|
|
174
|
+
progress.value = withTiming(1, { duration: Duration, easing: EASING });
|
|
175
|
+
hasEntered.current = true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Exit animation when isExiting becomes true
|
|
179
|
+
if (toast.isExiting) {
|
|
180
|
+
progress.value = withTiming(0, { duration: ExitDuration, easing: EASING });
|
|
181
|
+
translationY.value = withTiming(exitToY, { duration: ExitDuration, easing: EASING });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Color transition when type changes
|
|
185
|
+
if (toast.type !== lastHandledType.current) {
|
|
186
|
+
fromColor.value = theme.colors[lastHandledType.current].accent;
|
|
187
|
+
toColor.value = theme.colors[toast.type].accent;
|
|
188
|
+
lastHandledType.current = toast.type;
|
|
189
|
+
colorProgress.value = 0;
|
|
190
|
+
colorProgress.value = withTiming(1, { duration: 300, easing: EASING });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Stack position animation when index changes
|
|
194
|
+
if (index >= 0 && prevIndex.current !== index) {
|
|
195
|
+
stackIndex.value = withTiming(index, { duration: 300, easing: EASING });
|
|
196
|
+
prevIndex.current = index;
|
|
197
|
+
}
|
|
198
|
+
}, [
|
|
199
|
+
toast.isExiting,
|
|
200
|
+
toast.type,
|
|
201
|
+
index,
|
|
202
|
+
progress,
|
|
203
|
+
translationY,
|
|
204
|
+
fromColor,
|
|
205
|
+
toColor,
|
|
206
|
+
colorProgress,
|
|
207
|
+
stackIndex,
|
|
208
|
+
exitToY,
|
|
209
|
+
theme.colors,
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
const titleColorStyle = useAnimatedStyle(() => ({
|
|
213
|
+
color: interpolateColor(colorProgress.value, [0, 1], [fromColor.value, toColor.value]),
|
|
214
|
+
}));
|
|
215
|
+
|
|
216
|
+
const dismissToast = useCallback(() => {
|
|
217
|
+
toastStore.hide(toast.id);
|
|
218
|
+
}, [toast.id]);
|
|
219
|
+
|
|
220
|
+
const panGesture = Gesture.Pan()
|
|
221
|
+
.onStart(() => {
|
|
222
|
+
"worklet";
|
|
223
|
+
isBeingDragged.value = true;
|
|
224
|
+
shouldDismiss.value = false;
|
|
225
|
+
})
|
|
226
|
+
.onUpdate(event => {
|
|
227
|
+
"worklet";
|
|
228
|
+
const rawY = event.translationY;
|
|
229
|
+
// For top: negative Y = dismiss direction, positive Y = resistance
|
|
230
|
+
// For bottom: positive Y = dismiss direction, negative Y = resistance
|
|
231
|
+
const dismissDrag = isBottom ? rawY : -rawY;
|
|
232
|
+
const resistDrag = isBottom ? -rawY : rawY;
|
|
233
|
+
|
|
234
|
+
if (dismissDrag > 0) {
|
|
235
|
+
// Moving toward dismiss direction
|
|
236
|
+
const clampedY = isBottom ? Math.min(rawY, 180) : Math.max(rawY, -180);
|
|
237
|
+
translationY.value = clampedY;
|
|
238
|
+
if (dismissDrag > 40 || (isBottom ? event.velocityY > 300 : event.velocityY < -300)) {
|
|
239
|
+
shouldDismiss.value = true;
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// Moving away from edge - apply resistance
|
|
243
|
+
const exponentialDrag = MaxDragDown * (1 - Math.exp(-resistDrag / 250));
|
|
244
|
+
translationY.value = isBottom
|
|
245
|
+
? -Math.min(exponentialDrag, MaxDragDown)
|
|
246
|
+
: Math.min(exponentialDrag, MaxDragDown);
|
|
247
|
+
shouldDismiss.value = false;
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
.onEnd(() => {
|
|
251
|
+
"worklet";
|
|
252
|
+
isBeingDragged.value = false;
|
|
253
|
+
|
|
254
|
+
if (shouldDismiss.value) {
|
|
255
|
+
progress.value = withTiming(0, {
|
|
256
|
+
duration: ExitDuration,
|
|
257
|
+
easing: EASING,
|
|
258
|
+
});
|
|
259
|
+
const exitOffset = isBottom ? 200 : -200;
|
|
260
|
+
translationY.value = withTiming(translationY.value + exitOffset, {
|
|
261
|
+
duration: ExitDuration,
|
|
262
|
+
easing: EASING,
|
|
263
|
+
});
|
|
264
|
+
scheduleOnRN(dismissToast);
|
|
265
|
+
} else {
|
|
266
|
+
translationY.value = withTiming(0, {
|
|
267
|
+
duration: 650,
|
|
268
|
+
easing: EASING,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
274
|
+
const baseTranslateY = interpolate(progress.value, [0, 1], [entryFromY, ToY]);
|
|
275
|
+
|
|
276
|
+
// Stack offset: each toast behind moves away from edge (up for top, down for bottom)
|
|
277
|
+
const stackOffsetY = isBottom ? stackIndex.value * 10 : stackIndex.value * -10;
|
|
278
|
+
|
|
279
|
+
// Stack scale: each toast behind scales down by 0.05
|
|
280
|
+
const stackScale = 1 - stackIndex.value * 0.05;
|
|
281
|
+
|
|
282
|
+
const finalTranslateY = baseTranslateY + translationY.value + stackOffsetY;
|
|
283
|
+
|
|
284
|
+
const progressOpacity = interpolate(progress.value, [0, 1], [0, 1]);
|
|
285
|
+
// For top: dragging up (negative) fades out. For bottom: dragging down (positive) fades out
|
|
286
|
+
const dismissDirection = isBottom ? translationY.value : -translationY.value;
|
|
287
|
+
const dragOpacity = dismissDirection > 0 ? interpolate(dismissDirection, [0, 130], [1, 0], "clamp") : 1;
|
|
288
|
+
const opacity = progressOpacity * dragOpacity;
|
|
289
|
+
|
|
290
|
+
const dragScale = interpolate(Math.abs(translationY.value), [0, 50], [1, 0.98], "clamp");
|
|
291
|
+
const scale = stackScale * dragScale;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
transform: [{ translateY: finalTranslateY }, { scale }],
|
|
295
|
+
opacity,
|
|
296
|
+
zIndex: 1000 - stackIndex.value,
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const accentColor = theme.colors[toast.type].accent;
|
|
301
|
+
const backgroundColor = theme.colors[toast.type].background;
|
|
302
|
+
const verticalAnchor = isBottom ? { bottom: 0 } : { top: 0 };
|
|
303
|
+
|
|
304
|
+
// Per-toast overrides from options
|
|
305
|
+
const { options } = toast;
|
|
306
|
+
const customIcon = options?.icon;
|
|
307
|
+
const configIcon = theme.icons[toast.type];
|
|
308
|
+
|
|
309
|
+
// Resolve dismissible and showCloseButton (per-toast overrides config)
|
|
310
|
+
const isDismissible = options?.dismissible ?? theme.dismissible;
|
|
311
|
+
const shouldShowCloseButton = toast.type !== "loading" && (options?.showCloseButton ?? theme.showCloseButton);
|
|
312
|
+
|
|
313
|
+
// Enable/disable gesture based on dismissible setting
|
|
314
|
+
const gesture = isDismissible ? panGesture : Gesture.Pan().enabled(false);
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<GestureDetector gesture={gesture}>
|
|
318
|
+
<Animated.View
|
|
319
|
+
style={[styles.toast, verticalAnchor, { backgroundColor }, theme.toastStyle, options?.style, animatedStyle]}
|
|
320
|
+
>
|
|
321
|
+
<View style={styles.content}>
|
|
322
|
+
<View style={styles.icon}>
|
|
323
|
+
<AnimatedIcon
|
|
324
|
+
key={toast.type}
|
|
325
|
+
type={toast.type}
|
|
326
|
+
accentColor={accentColor}
|
|
327
|
+
customIcon={customIcon}
|
|
328
|
+
configIcon={configIcon}
|
|
329
|
+
/>
|
|
330
|
+
</View>
|
|
331
|
+
<View style={styles.textContainer}>
|
|
332
|
+
<Animated.Text
|
|
333
|
+
maxFontSizeMultiplier={1.35}
|
|
334
|
+
allowFontScaling={false}
|
|
335
|
+
style={[styles.title, theme.titleStyle, options?.titleStyle, titleColorStyle]}
|
|
336
|
+
>
|
|
337
|
+
{toast.title}
|
|
338
|
+
</Animated.Text>
|
|
339
|
+
{toast.description && (
|
|
340
|
+
<Text
|
|
341
|
+
allowFontScaling={false}
|
|
342
|
+
maxFontSizeMultiplier={1.35}
|
|
343
|
+
style={[styles.description, theme.descriptionStyle, options?.descriptionStyle]}
|
|
344
|
+
>
|
|
345
|
+
{toast.description}
|
|
346
|
+
</Text>
|
|
347
|
+
)}
|
|
348
|
+
</View>
|
|
349
|
+
{shouldShowCloseButton && (
|
|
350
|
+
<Pressable style={styles.closeButton} onPress={dismissToast} hitSlop={12}>
|
|
351
|
+
<CloseIcon width={20} height={20} />
|
|
352
|
+
</Pressable>
|
|
353
|
+
)}
|
|
354
|
+
</View>
|
|
355
|
+
</Animated.View>
|
|
356
|
+
</GestureDetector>
|
|
357
|
+
);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const MemoizedToastItem = memo(ToastItem);
|
|
361
|
+
|
|
362
|
+
const styles = StyleSheet.create({
|
|
363
|
+
container: {
|
|
364
|
+
position: "absolute",
|
|
365
|
+
left: 16,
|
|
366
|
+
right: 16,
|
|
367
|
+
zIndex: 1000,
|
|
368
|
+
},
|
|
369
|
+
closeButton: {
|
|
370
|
+
padding: 4,
|
|
371
|
+
alignItems: "center",
|
|
372
|
+
justifyContent: "center",
|
|
373
|
+
},
|
|
374
|
+
icon: {
|
|
375
|
+
width: 48,
|
|
376
|
+
height: 48,
|
|
377
|
+
alignItems: "center",
|
|
378
|
+
justifyContent: "center",
|
|
379
|
+
marginLeft: 8,
|
|
380
|
+
},
|
|
381
|
+
content: {
|
|
382
|
+
alignItems: "center",
|
|
383
|
+
flexDirection: "row",
|
|
384
|
+
gap: 12,
|
|
385
|
+
minHeight: 36,
|
|
386
|
+
},
|
|
387
|
+
description: {
|
|
388
|
+
color: "#6B7280",
|
|
389
|
+
fontSize: 12,
|
|
390
|
+
fontWeight: "500",
|
|
391
|
+
lineHeight: 16,
|
|
392
|
+
},
|
|
393
|
+
textContainer: {
|
|
394
|
+
flex: 1,
|
|
395
|
+
gap: 1,
|
|
396
|
+
justifyContent: "center",
|
|
397
|
+
},
|
|
398
|
+
title: {
|
|
399
|
+
fontSize: 14,
|
|
400
|
+
fontWeight: "700",
|
|
401
|
+
lineHeight: 20,
|
|
402
|
+
},
|
|
403
|
+
toast: {
|
|
404
|
+
borderRadius: 20,
|
|
405
|
+
borderCurve: "continuous",
|
|
406
|
+
position: "absolute",
|
|
407
|
+
left: 0,
|
|
408
|
+
right: 0,
|
|
409
|
+
paddingHorizontal: 12,
|
|
410
|
+
paddingVertical: 10,
|
|
411
|
+
shadowColor: "#000",
|
|
412
|
+
shadowOffset: { width: 0, height: 8 },
|
|
413
|
+
shadowOpacity: 0.05,
|
|
414
|
+
shadowRadius: 24,
|
|
415
|
+
elevation: 8,
|
|
416
|
+
},
|
|
417
|
+
});
|