react-native-toast-message-library 0.0.0-stage → 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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +333 -2
- package/package.json +82 -4
- package/src/animations/toastAnimations.js +85 -0
- package/src/components/ToastAction.js +39 -0
- package/src/components/ToastContainer.js +109 -0
- package/src/components/ToastIcon.js +65 -0
- package/src/components/ToastItem.js +357 -0
- package/src/components/ToastProgress.js +69 -0
- package/src/components/ToastProvider.js +107 -0
- package/src/context/ToastContext.js +5 -0
- package/src/global.js +38 -0
- package/src/hooks/useReducedMotion.js +28 -0
- package/src/hooks/useToast.js +13 -0
- package/src/index.js +4 -0
- package/src/manager/ToastManager.js +399 -0
- package/src/manager/createToastApi.js +33 -0
- package/src/themes/dark.js +64 -0
- package/src/themes/index.js +73 -0
- package/src/themes/light.js +64 -0
- package/src/utils/constants.js +40 -0
- package/src/utils/createAnnouncer.js +46 -0
- package/src/utils/generateToastId.js +10 -0
- package/src/utils/normalizeToastOptions.js +42 -0
- package/src/utils/safeArea.js +34 -0
- package/src/utils/validateToastOptions.js +127 -0
- package/types/index.d.ts +203 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import { StyleSheet, View, useWindowDimensions } from 'react-native';
|
|
3
|
+
import useReducedMotion from '../hooks/useReducedMotion';
|
|
4
|
+
import { POSITIONS } from '../utils/constants';
|
|
5
|
+
import createAnnouncer from '../utils/createAnnouncer';
|
|
6
|
+
import useToastInsets from '../utils/safeArea';
|
|
7
|
+
import ToastItem from './ToastItem';
|
|
8
|
+
|
|
9
|
+
const SIDE_MARGIN = 16;
|
|
10
|
+
const MAX_WIDTH = 480;
|
|
11
|
+
|
|
12
|
+
function groupStyle(position, insets, offset) {
|
|
13
|
+
const horizontal = {
|
|
14
|
+
paddingLeft: SIDE_MARGIN + insets.left + offset.x,
|
|
15
|
+
paddingRight: SIDE_MARGIN + insets.right + offset.x,
|
|
16
|
+
};
|
|
17
|
+
const alignItems = position.endsWith('left')
|
|
18
|
+
? 'flex-start'
|
|
19
|
+
: position.endsWith('right')
|
|
20
|
+
? 'flex-end'
|
|
21
|
+
: 'center';
|
|
22
|
+
const base = {
|
|
23
|
+
position: 'absolute',
|
|
24
|
+
left: 0,
|
|
25
|
+
right: 0,
|
|
26
|
+
alignItems,
|
|
27
|
+
...horizontal,
|
|
28
|
+
};
|
|
29
|
+
if (position.startsWith('top')) {
|
|
30
|
+
return { ...base, top: insets.top + offset.y };
|
|
31
|
+
}
|
|
32
|
+
if (position.startsWith('bottom')) {
|
|
33
|
+
return { ...base, bottom: insets.bottom + offset.y };
|
|
34
|
+
}
|
|
35
|
+
return { ...base, top: 0, bottom: 0, justifyContent: 'center' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default function ToastContainer({
|
|
39
|
+
manager,
|
|
40
|
+
theme,
|
|
41
|
+
spacing,
|
|
42
|
+
offset,
|
|
43
|
+
insets: insetsProp,
|
|
44
|
+
}) {
|
|
45
|
+
const snapshot = useSyncExternalStore(
|
|
46
|
+
manager.subscribe,
|
|
47
|
+
manager.getSnapshot,
|
|
48
|
+
manager.getSnapshot,
|
|
49
|
+
);
|
|
50
|
+
const reducedMotion = useReducedMotion();
|
|
51
|
+
const insets = useToastInsets(insetsProp);
|
|
52
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
53
|
+
const announcer = useRef(null);
|
|
54
|
+
if (!announcer.current) {
|
|
55
|
+
announcer.current = createAnnouncer();
|
|
56
|
+
}
|
|
57
|
+
useEffect(() => () => announcer.current.cancel(), []);
|
|
58
|
+
|
|
59
|
+
const toastWidth = Math.min(
|
|
60
|
+
MAX_WIDTH,
|
|
61
|
+
windowWidth - 2 * SIDE_MARGIN - insets.left - insets.right - 2 * offset.x,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const groups = useMemo(() => {
|
|
65
|
+
const map = {};
|
|
66
|
+
snapshot.toasts.forEach(entry => {
|
|
67
|
+
const key = entry.options.position;
|
|
68
|
+
(map[key] = map[key] || []).push(entry);
|
|
69
|
+
});
|
|
70
|
+
return map;
|
|
71
|
+
}, [snapshot]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<View pointerEvents="box-none" style={styles.overlay}>
|
|
75
|
+
{POSITIONS.filter(position => groups[position]).map(position => (
|
|
76
|
+
<View
|
|
77
|
+
key={position}
|
|
78
|
+
pointerEvents="box-none"
|
|
79
|
+
style={groupStyle(position, insets, offset)}
|
|
80
|
+
>
|
|
81
|
+
{groups[position].map((entry, index) => (
|
|
82
|
+
<View
|
|
83
|
+
key={entry.id}
|
|
84
|
+
pointerEvents="box-none"
|
|
85
|
+
style={index === 0 ? null : { marginTop: spacing }}
|
|
86
|
+
>
|
|
87
|
+
<ToastItem
|
|
88
|
+
entry={entry}
|
|
89
|
+
manager={manager}
|
|
90
|
+
theme={theme}
|
|
91
|
+
reducedMotion={reducedMotion}
|
|
92
|
+
announce={announcer.current.announce}
|
|
93
|
+
width={toastWidth}
|
|
94
|
+
/>
|
|
95
|
+
</View>
|
|
96
|
+
))}
|
|
97
|
+
</View>
|
|
98
|
+
))}
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const styles = StyleSheet.create({
|
|
104
|
+
overlay: {
|
|
105
|
+
...StyleSheet.absoluteFillObject,
|
|
106
|
+
zIndex: 9999,
|
|
107
|
+
elevation: 9999,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
const GLYPHS = {
|
|
5
|
+
success: '✓',
|
|
6
|
+
error: '✕',
|
|
7
|
+
warning: '!',
|
|
8
|
+
info: 'i',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders the leading icon. `icon` may be:
|
|
13
|
+
* - false -> nothing
|
|
14
|
+
* - a React element -> rendered as is
|
|
15
|
+
* - a function -> called with { type, color, size }
|
|
16
|
+
* - undefined -> built-in glyph for the type (none for "custom")
|
|
17
|
+
* The glyph is decorative; the toast's accessibility label carries the type.
|
|
18
|
+
*/
|
|
19
|
+
export default function ToastIcon({ type, icon, color, glyphColor, size }) {
|
|
20
|
+
if (icon === false) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (typeof icon === 'function') {
|
|
24
|
+
return <View style={styles.wrapper}>{icon({ type, color, size })}</View>;
|
|
25
|
+
}
|
|
26
|
+
if (React.isValidElement(icon)) {
|
|
27
|
+
return <View style={styles.wrapper}>{icon}</View>;
|
|
28
|
+
}
|
|
29
|
+
const glyph = GLYPHS[type];
|
|
30
|
+
if (!glyph) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
<View
|
|
35
|
+
accessible={false}
|
|
36
|
+
importantForAccessibility="no-hide-descendants"
|
|
37
|
+
style={[
|
|
38
|
+
styles.badge,
|
|
39
|
+
{
|
|
40
|
+
width: size,
|
|
41
|
+
height: size,
|
|
42
|
+
borderRadius: size / 2,
|
|
43
|
+
backgroundColor: color,
|
|
44
|
+
},
|
|
45
|
+
]}
|
|
46
|
+
>
|
|
47
|
+
<Text
|
|
48
|
+
allowFontScaling={false}
|
|
49
|
+
style={[styles.glyph, { color: glyphColor, fontSize: size * 0.62 }]}
|
|
50
|
+
>
|
|
51
|
+
{glyph}
|
|
52
|
+
</Text>
|
|
53
|
+
</View>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const styles = StyleSheet.create({
|
|
58
|
+
wrapper: { marginRight: 12, justifyContent: 'center' },
|
|
59
|
+
badge: {
|
|
60
|
+
marginRight: 12,
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
},
|
|
64
|
+
glyph: { fontWeight: '800', textAlign: 'center', includeFontPadding: false },
|
|
65
|
+
});
|
|
@@ -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
|
+
});
|