react-native-toast-message-library 0.0.0-stage → 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.
@@ -0,0 +1,357 @@
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
+ import {
3
+ Animated,
4
+ PanResponder,
5
+ Platform,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ Vibration,
10
+ View,
11
+ useWindowDimensions,
12
+ } from 'react-native';
13
+ import {
14
+ getAnimatedStyle,
15
+ resolveAnimation,
16
+ runToastAnimation,
17
+ } from '../animations/toastAnimations';
18
+ import { TYPE_LABELS } from '../utils/constants';
19
+ import ToastAction from './ToastAction';
20
+ import ToastIcon from './ToastIcon';
21
+ import ToastProgress from './ToastProgress';
22
+
23
+ const SWIPE_THRESHOLD = 80;
24
+
25
+ function buildShadow(theme, shadow) {
26
+ if (shadow === false) {
27
+ return { elevation: 0 };
28
+ }
29
+ const s = { ...theme.shadow, ...(shadow || {}) };
30
+ return {
31
+ shadowColor: s.color,
32
+ shadowOpacity: s.opacity,
33
+ shadowRadius: s.radius,
34
+ shadowOffset: { width: 0, height: s.offsetY },
35
+ elevation: s.elevation,
36
+ };
37
+ }
38
+
39
+ function vibrate(type) {
40
+ try {
41
+ Vibration.vibrate(type === 'error' ? [0, 30, 60, 30] : 20);
42
+ } catch (error) {
43
+ // Missing VIBRATE permission on Android must never break the toast.
44
+ }
45
+ }
46
+
47
+ function ToastItem({ entry, manager, theme, reducedMotion, announce, width }) {
48
+ const { options, status, paused, remaining } = entry;
49
+ const { id, type, position } = options;
50
+ const { width: windowWidth } = useWindowDimensions();
51
+
52
+ const animation = resolveAnimation(options.animation, reducedMotion);
53
+ const animationRef = useRef({
54
+ animation,
55
+ duration: options.animationDuration,
56
+ });
57
+ animationRef.current = { animation, duration: options.animationDuration };
58
+
59
+ const initial = animation === 'none' ? 1 : 0;
60
+ const progress = useRef(new Animated.Value(initial)).current;
61
+ const swipeX = useRef(new Animated.Value(0)).current;
62
+
63
+ const palette = theme.colors[type] || theme.colors.custom;
64
+ const colors = {
65
+ background: options.backgroundColor || palette.background,
66
+ text: options.textColor || palette.text,
67
+ border: options.borderColor || palette.border,
68
+ icon: options.iconColor || palette.icon,
69
+ };
70
+ const typography = theme.typography;
71
+ const fontFamily = typography.fontFamily;
72
+
73
+ const title = options.title === undefined ? TYPE_LABELS[type] : options.title;
74
+ const showTitle = title !== null && title !== '' && title !== undefined;
75
+ const label =
76
+ options.accessibilityLabel ||
77
+ [
78
+ TYPE_LABELS[type],
79
+ showTitle && title !== TYPE_LABELS[type] ? title : '',
80
+ options.message,
81
+ ]
82
+ .filter(Boolean)
83
+ .join('. ');
84
+
85
+ // Latest values for the long-lived PanResponder and callbacks.
86
+ const live = useRef({});
87
+ live.current = { options, status, windowWidth };
88
+
89
+ // Enter / exit lifecycle.
90
+ useEffect(() => {
91
+ let handle = null;
92
+ if (status === 'visible') {
93
+ handle = runToastAnimation(progress, 1, animationRef.current);
94
+ } else {
95
+ handle = runToastAnimation(progress, 0, animationRef.current, result => {
96
+ if (result.finished) {
97
+ manager.remove(id);
98
+ }
99
+ });
100
+ }
101
+ return () => {
102
+ if (handle) {
103
+ handle.stop();
104
+ }
105
+ };
106
+ }, [status, id, manager, progress]);
107
+
108
+ // Mount-only side effects: haptics and iOS announcement.
109
+ useEffect(() => {
110
+ if (options.haptics) {
111
+ vibrate(type);
112
+ }
113
+ if (Platform.OS === 'ios' && label) {
114
+ announce(label);
115
+ }
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ }, []);
118
+
119
+ const info = { id, type, hide: () => manager.hide(id) };
120
+ const actionFired = useRef(false);
121
+ useEffect(() => {
122
+ actionFired.current = false;
123
+ }, [status]);
124
+
125
+ const handlePress = () => {
126
+ if (live.current.status !== 'visible') {
127
+ return;
128
+ }
129
+ if (live.current.options.onPress) {
130
+ live.current.options.onPress(info);
131
+ }
132
+ if (live.current.options.dismissOnPress) {
133
+ manager.hide(id, 'press');
134
+ }
135
+ };
136
+
137
+ const handleAction = () => {
138
+ const current = live.current;
139
+ if (current.status !== 'visible' || actionFired.current) {
140
+ return;
141
+ }
142
+ if (current.options.dismissOnAction) {
143
+ actionFired.current = true;
144
+ }
145
+ if (current.options.onAction) {
146
+ current.options.onAction(info);
147
+ }
148
+ if (current.options.dismissOnAction) {
149
+ manager.hide(id, 'action');
150
+ }
151
+ };
152
+
153
+ const panResponder = useMemo(
154
+ () =>
155
+ PanResponder.create({
156
+ onMoveShouldSetPanResponder: (_, g) =>
157
+ live.current.options.swipeToDismiss &&
158
+ live.current.status === 'visible' &&
159
+ Math.abs(g.dx) > 8 &&
160
+ Math.abs(g.dx) > Math.abs(g.dy) * 1.5,
161
+ onPanResponderGrant: () => manager.pause(id),
162
+ onPanResponderMove: (_, g) => swipeX.setValue(g.dx),
163
+ onPanResponderTerminationRequest: () => true,
164
+ onPanResponderRelease: (_, g) => {
165
+ if (Math.abs(g.dx) > SWIPE_THRESHOLD || Math.abs(g.vx) > 0.9) {
166
+ const direction = g.dx >= 0 ? 1 : -1;
167
+ manager.hide(id, 'swipe');
168
+ Animated.timing(swipeX, {
169
+ toValue: direction * live.current.windowWidth,
170
+ duration: 180,
171
+ useNativeDriver: true,
172
+ }).start();
173
+ } else {
174
+ Animated.spring(swipeX, {
175
+ toValue: 0,
176
+ useNativeDriver: true,
177
+ }).start();
178
+ manager.resume(id);
179
+ }
180
+ },
181
+ onPanResponderTerminate: () => {
182
+ Animated.spring(swipeX, {
183
+ toValue: 0,
184
+ useNativeDriver: true,
185
+ }).start();
186
+ manager.resume(id);
187
+ },
188
+ }),
189
+ [id, manager, swipeX],
190
+ );
191
+
192
+ const innerRadius = Math.max(
193
+ 0,
194
+ (options.borderRadius != null ? options.borderRadius : theme.borderRadius) -
195
+ theme.borderWidth,
196
+ );
197
+ const padding = options.padding != null ? options.padding : theme.padding;
198
+
199
+ const outerStyle = [
200
+ styles.container,
201
+ {
202
+ width,
203
+ backgroundColor: colors.background,
204
+ borderColor: colors.border,
205
+ borderWidth: theme.borderWidth,
206
+ borderRadius:
207
+ options.borderRadius != null
208
+ ? options.borderRadius
209
+ : theme.borderRadius,
210
+ },
211
+ buildShadow(theme, options.shadow),
212
+ getAnimatedStyle(animation, progress, position),
213
+ {
214
+ transform: [
215
+ ...(getAnimatedStyle(animation, progress, position).transform || []),
216
+ { translateX: swipeX },
217
+ ],
218
+ },
219
+ options.containerStyle,
220
+ ];
221
+
222
+ const textFont = fontFamily ? { fontFamily } : null;
223
+
224
+ const body = options.render ? (
225
+ options.render({ ...info, colors, theme })
226
+ ) : (
227
+ <>
228
+ <ToastIcon
229
+ type={type}
230
+ icon={options.icon}
231
+ color={colors.icon}
232
+ glyphColor={palette.iconText}
233
+ size={theme.iconSize}
234
+ />
235
+ <View style={styles.texts}>
236
+ {showTitle ? (
237
+ <Text
238
+ maxFontSizeMultiplier={1.6}
239
+ style={[
240
+ textFont,
241
+ {
242
+ color: colors.text,
243
+ fontSize: typography.titleSize,
244
+ fontWeight: typography.titleWeight,
245
+ },
246
+ options.titleStyle,
247
+ ]}
248
+ >
249
+ {title}
250
+ </Text>
251
+ ) : null}
252
+ {options.message ? (
253
+ <Text
254
+ maxFontSizeMultiplier={1.6}
255
+ style={[
256
+ textFont,
257
+ {
258
+ color: colors.text,
259
+ fontSize: typography.messageSize,
260
+ fontWeight: typography.messageWeight,
261
+ },
262
+ showTitle && styles.messageGap,
263
+ options.messageStyle,
264
+ ]}
265
+ >
266
+ {options.message}
267
+ </Text>
268
+ ) : null}
269
+ </View>
270
+ </>
271
+ );
272
+
273
+ return (
274
+ <Animated.View
275
+ {...panResponder.panHandlers}
276
+ pointerEvents={status === 'visible' ? 'auto' : 'none'}
277
+ style={outerStyle}
278
+ testID={`toast-${id}`}
279
+ >
280
+ <View style={[styles.clip, { borderRadius: innerRadius }]}>
281
+ <View style={[styles.row, { padding }]}>
282
+ <Pressable
283
+ accessibilityRole={options.onPress ? 'button' : 'alert'}
284
+ accessibilityLabel={
285
+ options.render ? options.accessibilityLabel : label
286
+ }
287
+ accessibilityLiveRegion="polite"
288
+ onPress={handlePress}
289
+ onPressIn={() => manager.pause(id)}
290
+ onPressOut={() => manager.resume(id)}
291
+ style={styles.body}
292
+ >
293
+ {body}
294
+ </Pressable>
295
+ {options.action ? (
296
+ <ToastAction
297
+ label={options.action.label}
298
+ accessibilityLabel={options.action.accessibilityLabel}
299
+ color={theme.actionColor || colors.icon}
300
+ fontFamily={fontFamily}
301
+ onPress={handleAction}
302
+ />
303
+ ) : null}
304
+ {options.closeButton ? (
305
+ <Pressable
306
+ accessibilityRole="button"
307
+ accessibilityLabel={
308
+ options.closeAccessibilityLabel || 'Dismiss notification'
309
+ }
310
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
311
+ onPress={() => manager.hide(id, 'close')}
312
+ style={styles.close}
313
+ >
314
+ <Text
315
+ allowFontScaling={false}
316
+ style={[
317
+ styles.closeGlyph,
318
+ { color: theme.closeColor || colors.text },
319
+ ]}
320
+ >
321
+ ✕
322
+ </Text>
323
+ </Pressable>
324
+ ) : null}
325
+ </View>
326
+ {options.progress && options.duration > 0 ? (
327
+ <ToastProgress
328
+ duration={options.duration}
329
+ remaining={remaining}
330
+ paused={paused || status !== 'visible'}
331
+ color={colors.icon}
332
+ trackColor={theme.progressTrack}
333
+ />
334
+ ) : null}
335
+ </View>
336
+ </Animated.View>
337
+ );
338
+ }
339
+
340
+ const styles = StyleSheet.create({
341
+ container: { alignSelf: 'auto' },
342
+ clip: { overflow: 'hidden' },
343
+ row: { flexDirection: 'row', alignItems: 'center' },
344
+ body: { flex: 1, flexDirection: 'row', alignItems: 'center' },
345
+ texts: { flex: 1 },
346
+ messageGap: { marginTop: 2 },
347
+ close: {
348
+ width: 32,
349
+ height: 32,
350
+ marginLeft: 8,
351
+ alignItems: 'center',
352
+ justifyContent: 'center',
353
+ },
354
+ closeGlyph: { fontSize: 16, fontWeight: '600' },
355
+ });
356
+
357
+ export default React.memo(ToastItem);
@@ -0,0 +1,69 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { Animated, Easing, StyleSheet, View } from 'react-native';
3
+
4
+ /**
5
+ * Bar that drains from full to empty over the toast's duration.
6
+ * It pauses while `paused` is true and continues from where it stopped.
7
+ *
8
+ * Trade-off: the bar animates `width`, a layout property, so it cannot use the
9
+ * native driver. It is a thin absolutely-positioned element, so the cost is small.
10
+ */
11
+ export default function ToastProgress({
12
+ duration,
13
+ remaining,
14
+ paused,
15
+ color,
16
+ trackColor,
17
+ }) {
18
+ const value = useRef(new Animated.Value(remaining / duration)).current;
19
+ const current = useRef(remaining / duration);
20
+
21
+ useEffect(() => {
22
+ const id = value.addListener(({ value: v }) => {
23
+ current.current = v;
24
+ });
25
+ return () => value.removeListener(id);
26
+ }, [value]);
27
+
28
+ useEffect(() => {
29
+ if (paused || current.current <= 0) {
30
+ return undefined;
31
+ }
32
+ const animation = Animated.timing(value, {
33
+ toValue: 0,
34
+ duration: current.current * duration,
35
+ easing: Easing.linear,
36
+ useNativeDriver: false,
37
+ });
38
+ animation.start();
39
+ return () => animation.stop();
40
+ }, [paused, duration, value]);
41
+
42
+ return (
43
+ <View
44
+ testID="toast-progress"
45
+ accessible={false}
46
+ importantForAccessibility="no-hide-descendants"
47
+ style={[styles.track, { backgroundColor: trackColor }]}
48
+ >
49
+ <Animated.View
50
+ style={[
51
+ styles.bar,
52
+ {
53
+ backgroundColor: color,
54
+ width: value.interpolate({
55
+ inputRange: [0, 1],
56
+ outputRange: ['0%', '100%'],
57
+ extrapolate: 'clamp',
58
+ }),
59
+ },
60
+ ]}
61
+ />
62
+ </View>
63
+ );
64
+ }
65
+
66
+ const styles = StyleSheet.create({
67
+ track: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 3 },
68
+ bar: { height: 3 },
69
+ });
@@ -0,0 +1,107 @@
1
+ import React, { useEffect, useMemo, useRef } from 'react';
2
+ import { StyleSheet, View, useColorScheme } from 'react-native';
3
+ import ToastContext from '../context/ToastContext';
4
+ import { registerGlobalToast } from '../global';
5
+ import createToastApi from '../manager/createToastApi';
6
+ import ToastManager from '../manager/ToastManager';
7
+ import { resolveTheme } from '../themes';
8
+ import { DEFAULT_CONFIG } from '../utils/constants';
9
+ import ToastContainer from './ToastContainer';
10
+
11
+ const NO_OFFSET = { x: 0, y: 0 };
12
+
13
+ export default function ToastProvider({
14
+ children,
15
+ theme = 'light',
16
+ position = DEFAULT_CONFIG.position,
17
+ duration = DEFAULT_CONFIG.duration,
18
+ animation = DEFAULT_CONFIG.animation,
19
+ animationDuration = DEFAULT_CONFIG.animationDuration,
20
+ maxVisible = DEFAULT_CONFIG.maxVisible,
21
+ closeButton = DEFAULT_CONFIG.closeButton,
22
+ swipeToDismiss = DEFAULT_CONFIG.swipeToDismiss,
23
+ progress = DEFAULT_CONFIG.progress,
24
+ haptics = DEFAULT_CONFIG.haptics,
25
+ spacing,
26
+ offset,
27
+ insets,
28
+ global = false,
29
+ style,
30
+ }) {
31
+ const config = {
32
+ position,
33
+ duration,
34
+ animation,
35
+ animationDuration,
36
+ maxVisible,
37
+ closeButton,
38
+ swipeToDismiss,
39
+ progress,
40
+ haptics,
41
+ };
42
+
43
+ const managerRef = useRef(null);
44
+ if (!managerRef.current) {
45
+ managerRef.current = new ToastManager(config);
46
+ }
47
+ const manager = managerRef.current;
48
+
49
+ useEffect(() => {
50
+ manager.configure(config);
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, [
53
+ manager,
54
+ position,
55
+ duration,
56
+ animation,
57
+ animationDuration,
58
+ maxVisible,
59
+ closeButton,
60
+ swipeToDismiss,
61
+ progress,
62
+ haptics,
63
+ ]);
64
+
65
+ // Clear every timer on unmount. `revive` makes this safe under React
66
+ // StrictMode, which runs effect cleanup and setup once more in development.
67
+ useEffect(() => {
68
+ manager.revive();
69
+ return () => manager.destroy();
70
+ }, [manager]);
71
+
72
+ const api = useMemo(() => createToastApi(manager), [manager]);
73
+
74
+ useEffect(() => {
75
+ if (!global) {
76
+ return undefined;
77
+ }
78
+ return registerGlobalToast(api);
79
+ }, [global, api]);
80
+
81
+ const colorScheme = useColorScheme();
82
+ const resolvedTheme = useMemo(
83
+ () => resolveTheme(theme, colorScheme === 'dark' ? 'dark' : 'light'),
84
+ [theme, colorScheme],
85
+ );
86
+
87
+ const resolvedOffset = useMemo(() => ({ ...NO_OFFSET, ...offset }), [offset]);
88
+
89
+ return (
90
+ <ToastContext.Provider value={api}>
91
+ <View style={[styles.root, style]}>
92
+ {children}
93
+ <ToastContainer
94
+ manager={manager}
95
+ theme={resolvedTheme}
96
+ spacing={spacing != null ? spacing : resolvedTheme.spacing}
97
+ offset={resolvedOffset}
98
+ insets={insets}
99
+ />
100
+ </View>
101
+ </ToastContext.Provider>
102
+ );
103
+ }
104
+
105
+ const styles = StyleSheet.create({
106
+ root: { flex: 1 },
107
+ });
@@ -0,0 +1,5 @@
1
+ import { createContext } from 'react';
2
+
3
+ /** null means "no ToastProvider above": useToast() turns that into an error. */
4
+ const ToastContext = createContext(null);
5
+ export default ToastContext;
package/src/global.js ADDED
@@ -0,0 +1,38 @@
1
+ let active = null;
2
+
3
+ /**
4
+ * Called by <ToastProvider global>. Returns an unregister function. If several
5
+ * global providers are mounted, the most recently mounted one wins and the
6
+ * previous one is restored when it unmounts.
7
+ */
8
+ export function registerGlobalToast(api) {
9
+ const previous = active;
10
+ active = api;
11
+ return () => {
12
+ if (active === api) {
13
+ active = previous;
14
+ }
15
+ };
16
+ }
17
+
18
+ function current() {
19
+ if (!active) {
20
+ throw new Error(
21
+ '[react-native-toast-message-library] Toast.* was called but no <ToastProvider global> is mounted. ' +
22
+ 'Render <ToastProvider global> near the root of your app, or use the useToast() hook.',
23
+ );
24
+ }
25
+ return active;
26
+ }
27
+
28
+ /** Imperative API. Requires <ToastProvider global>. Throws if none is mounted. */
29
+ export const Toast = {
30
+ show: input => current().show(input),
31
+ success: (message, options) => current().success(message, options),
32
+ error: (message, options) => current().error(message, options),
33
+ warning: (message, options) => current().warning(message, options),
34
+ info: (message, options) => current().info(message, options),
35
+ hide: id => current().hide(id),
36
+ hideAll: () => current().hideAll(),
37
+ isVisible: id => current().isVisible(id),
38
+ };
@@ -0,0 +1,28 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { AccessibilityInfo } from 'react-native';
3
+
4
+ /** Tracks the system "reduce motion" accessibility setting. */
5
+ export default function useReducedMotion() {
6
+ const [reduced, setReduced] = useState(false);
7
+
8
+ useEffect(() => {
9
+ let mounted = true;
10
+ AccessibilityInfo.isReduceMotionEnabled()
11
+ .then(value => {
12
+ if (mounted) {
13
+ setReduced(Boolean(value));
14
+ }
15
+ })
16
+ .catch(() => {});
17
+ const subscription = AccessibilityInfo.addEventListener(
18
+ 'reduceMotionChanged',
19
+ value => setReduced(Boolean(value)),
20
+ );
21
+ return () => {
22
+ mounted = false;
23
+ subscription.remove();
24
+ };
25
+ }, []);
26
+
27
+ return reduced;
28
+ }
@@ -0,0 +1,13 @@
1
+ import { useContext } from 'react';
2
+ import ToastContext from '../context/ToastContext';
3
+
4
+ export default function useToast() {
5
+ const api = useContext(ToastContext);
6
+ if (!api) {
7
+ throw new Error(
8
+ '[react-native-toast-message-library] useToast() was called outside of a <ToastProvider>. ' +
9
+ 'Wrap your app (usually in App.js) with <ToastProvider>...</ToastProvider>.',
10
+ );
11
+ }
12
+ return api;
13
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { default as ToastProvider } from './components/ToastProvider';
2
+ export { default as useToast } from './hooks/useToast';
3
+ export { Toast } from './global';
4
+ export { createTheme, light as lightTheme, dark as darkTheme } from './themes';