Skip to content
Source
Sign inSign up

Toast.tsx

226 lines8.0 KB

1import { cn } from "../../lib/cn";
2import { MOBILE_MOTION, MOBILE_SHAPE } from "../../lib/designTokens";
3import { useCallback, useEffect, useLayoutEffect, useState } from "react";
4import { AccessibilityInfo, Platform, StyleSheet, View, useWindowDimensions } from "react-native";
5import { GestureDetector, usePanGesture } from "react-native-gesture-handler";
6import Animated, {
7 cancelAnimation,
8 Easing,
9 FadeOut,
10 ReduceMotion,
11 runOnJS,
12 useAnimatedStyle,
13 useSharedValue,
14 withTiming,
15 type EntryAnimationsValues,
16} from "react-native-reanimated";
17​
18import { AppText } from "./AppText";
19import { Button } from "./Button";
20import { GlassSurface } from "./GlassSurface";
21import { useControlTheme } from "./useControlTheme";
22import { IconButton } from "./IconButton";
23import { AppActivityIndicator } from "./AppActivityIndicator";
24import { SymbolView } from "./AppSymbol";
25​
26export type ToastProps = {
27 readonly message: string;
28 readonly description?: string;
29 readonly singleLine?: boolean;
30 readonly tone?: "default" | "success" | "danger";
31 readonly busy?: boolean;
32 readonly action?: { readonly label: string; readonly onPress: () => void };
33} & (
34 | { readonly dismissMode: "swipe"; readonly onDismiss: () => void }
35 | { readonly dismissMode?: "button"; readonly onDismiss?: () => void }
36);
37​
38export function Toast(props: ToastProps) {
39 return props.dismissMode === "swipe" ? (
40 <SwipeableToast {...props} />
41 ) : (
42 <ToastContent {...props} />
43 );
45​
46function ToastContent(props: ToastProps & { readonly glassReady?: boolean }) {
47 const { fontScale } = useWindowDimensions();
48 const { colorScheme } = useControlTheme();
49 const borderRadius = Platform.OS === "ios" ? 28 : MOBILE_SHAPE.toast;
50 // Explicit single-line notices reserve the action's width and truncate the text.
51 const inlineAction = props.singleLine || (!props.description && fontScale < 1.5);
52 const message =
53 props.singleLine && props.description
54 ? `${props.message}: ${props.description}`
55 : props.message;
56 const action = props.action ? (
57 <Button
58 label={props.action.label}
59 onPress={props.action.onPress}
60 size={Platform.OS === "ios" ? "compact" : "default"}
61 />
62 ) : null;
63 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- announces the toast message to VoiceOver through AccessibilityInfo
64 useEffect(() => {
65 if (Platform.OS === "ios") AccessibilityInfo.announceForAccessibility(props.message);
66 }, [props.message]);
67 const surface = (
68 <GlassSurface
69 key={props.glassReady === false ? "entering" : "settled"}
70 chrome={Platform.OS === "ios" && colorScheme === "dark" ? "none" : undefined}
71 tintColor={Platform.OS === "ios" ? null : undefined}
72 className={Platform.OS === "ios" ? "bg-transparent p-2" : "p-3"}
73 style={{ borderRadius }}
74 >
75 <View
76 className={cn(
77 "flex-row items-center gap-3",
78 // Match the leading inset to the action label's, which sits inside the button's padding.
79 Platform.OS === "ios" && inlineAction && action && "pl-5",
80 )}
81 >
82 {props.busy ? (
83 <AppActivityIndicator />
84 ) : (
85 <SymbolView
86 name={props.tone === "danger" ? "exclamationmark.triangle" : "checkmark.circle"}
87 size={20}
88 tintColorClassName={
89 props.tone === "danger" ? "accent-danger-foreground" : "accent-icon"
90 }
91 />
92 )}
93 <View className="min-w-0 flex-1" accessibilityLiveRegion="polite">
94 <AppText
95 textRole="footnote"
96 className="font-t3-semibold"
97 numberOfLines={props.singleLine ? 1 : undefined}
98 accessibilityLabel={props.singleLine ? message : undefined}
99 >
100 {message}
101 </AppText>
102 {props.description && !props.singleLine ? (
103 <AppText textRole="label" className="text-foreground-muted">
104 {props.description}
105 </AppText>
106 ) : null}
107 </View>
108 {inlineAction && action ? <View className="self-center">{action}</View> : null}
109 {props.onDismiss && props.dismissMode !== "swipe" ? (
110 <IconButton accessibilityLabel="Dismiss message" icon="xmark" onPress={props.onDismiss} />
111 ) : null}
112 </View>
113 {action && !inlineAction ? <View className="mt-2 self-end">{action}</View> : null}
114 </GlassSurface>
115 );
116 return Platform.OS === "ios" ? (
117 <View
118 style={{
119 borderRadius,
120 boxShadow: [
121 {
122 offsetX: 0,
123 offsetY: 0,
124 blurRadius: 8,
125 color: colorScheme === "dark" ? "rgba(0, 0, 0, 0.22)" : "rgba(0, 0, 0, 0.08)",
126 },
127 ],
128 }}
129 >
130 {surface}
131 </View>
132 ) : (
133 surface
134 );
136​
137const SWIPE_TIMING = { duration: 160, reduceMotion: ReduceMotion.System };
138const ENTER_TIMING = {
139 duration: MOBILE_MOTION.enter,
140 easing: Easing.out(Easing.cubic),
141 reduceMotion: ReduceMotion.System,
142};
143const TOAST_EXITING = FadeOut.duration(MOBILE_MOTION.exit).reduceMotion(ReduceMotion.System);
144function SwipeableToast(props: ToastProps & { readonly onDismiss: () => void }) {
145 const [glassReady, setGlassReady] = useState(Platform.OS !== "ios");
146 const finishEntering = useCallback(() => setGlassReady(true), []);
147 // UIKit can skip glass on a moving host; remount the surface after entry so its blur installs.
148 const toastEntering = (values: EntryAnimationsValues) => {
149 "worklet";
150 return {
151 initialValues: { originY: values.targetOriginY + values.targetHeight + 12 },
152 animations: { originY: withTiming(values.targetOriginY, ENTER_TIMING) },
153 callback: (finished: boolean) => {
154 if (finished) runOnJS(finishEntering)();
155 },
156 };
157 };
158 const { onDismiss } = props;
159 const translation = useSharedValue(0);
160 const width = useSharedValue(0);
161 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the Reanimated swipe translation animation on unmount
162 useLayoutEffect(
163 () => () => {
164 cancelAnimation(translation);
165 },
166 [translation],
167 );
168 const gesture = usePanGesture({
169 activeOffsetX: [-10, 10],
170 failOffsetY: [-12, 12],
171 onUpdate: (event) => {
172 translation.set(event.translationX);
173 },
174 onDeactivate: (event) => {
175 if (
176 !event.canceled &&
177 (Math.abs(event.translationX) > width.value * 0.25 ||
178 (Math.abs(event.velocityX) > 800 && event.translationX * event.velocityX > 0))
179 ) {
180 const direction = Math.sign(event.translationX || event.velocityX);
181 translation.set(
182 withTiming(direction * (width.value + 24), SWIPE_TIMING, (finished) => {
183 if (finished) runOnJS(onDismiss)();
184 }),
185 );
186 } else {
187 translation.set(withTiming(0, SWIPE_TIMING));
188 }
189 },
190 });
191 const animatedStyle = useAnimatedStyle(() => ({
192 transform: [{ translateX: translation.value }],
193 }));
194 return (
195 <Animated.View
196 className={Platform.OS === "android" ? "overflow-hidden" : undefined}
197 pointerEvents="box-none"
198 exiting={TOAST_EXITING}
199 onAccessibilityEscape={onDismiss}
200 onLayout={(event) => {
201 width.set(event.nativeEvent.layout.width);
202 }}
203 >
204 <GestureDetector gesture={gesture}>
205 <Animated.View collapsable={false} entering={toastEntering} style={animatedStyle}>
206 {Platform.OS === "android" ? (
207 // Keep the dismiss target separate so TalkBack can also focus the action button.
208 <View
209 accessible
210 accessibilityRole="alert"
211 accessibilityLiveRegion="assertive"
212 accessibilityLabel={props.message}
213 accessibilityActions={[{ name: "dismiss", label: "Dismiss message" }]}
214 onAccessibilityAction={({ nativeEvent }) => {
215 if (nativeEvent.actionName === "dismiss") onDismiss();
216 }}
217 pointerEvents="none"
218 style={StyleSheet.absoluteFill}
219 />
220 ) : null}
221 <ToastContent {...props} glassReady={glassReady} />
222 </Animated.View>
223 </GestureDetector>
224 </Animated.View>
225 );