diff options
Diffstat (limited to 'src/components/Toast/index.tsx')
-rw-r--r-- | src/components/Toast/index.tsx | 230 |
1 files changed, 41 insertions, 189 deletions
diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index 131a796b3..286d414a1 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -1,197 +1,49 @@ -import {useEffect, useMemo, useRef, useState} from 'react' -import {AccessibilityInfo} from 'react-native' -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler' -import Animated, { - Easing, - runOnJS, - SlideInUp, - SlideOutUp, - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, - withDecay, - withSpring, -} from 'react-native-reanimated' -import RootSiblings from 'react-native-root-siblings' -import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {View} from 'react-native' +import {toast as sonner, Toaster} from 'sonner-native' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a} from '#/alf' -import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' -import {Toast} from '#/components/Toast/Toast' -import {type ToastApi, type ToastType} from '#/components/Toast/types' - -const TOAST_ANIMATION_DURATION = 300 - -export function ToastContainer() { - return null -} - -export const toast: ToastApi = { - show(props) { - if (process.env.NODE_ENV === 'test') { - return - } - - AccessibilityInfo.announceForAccessibility(props.a11yLabel) - - const item = new RootSiblings( - ( - <AnimatedToast - type={props.type} - content={props.content} - a11yLabel={props.a11yLabel} - duration={props.duration ?? DEFAULT_TOAST_DURATION} - destroy={() => item.destroy()} - /> - ), - ) - }, +import {DURATION} from '#/components/Toast/const' +import { + Toast as BaseToast, + type ToastComponentProps, +} from '#/components/Toast/Toast' +import {type BaseToastOptions} from '#/components/Toast/types' + +export {DURATION} from '#/components/Toast/const' + +/** + * Toasts are rendered in a global outlet, which is placed at the top of the + * component tree. + */ +export function ToastOutlet() { + return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} /> } -function AnimatedToast({ - type, - content, - a11yLabel, - duration, - destroy, -}: { - type: ToastType - content: React.ReactNode - a11yLabel: string - duration: number - destroy: () => void -}) { - const {top} = useSafeAreaInsets() - const isPanning = useSharedValue(false) - const dismissSwipeTranslateY = useSharedValue(0) - const [cardHeight, setCardHeight] = useState(0) - - // for the exit animation to work on iOS the animated component - // must not be the root component - // so we need to wrap it in a view and unmount the toast ahead of time - const [alive, setAlive] = useState(true) - - const hideAndDestroyImmediately = () => { - setAlive(false) - setTimeout(() => { - destroy() - }, 1e3) - } - - const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() - const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { - clearTimeout(destroyTimeoutRef.current) - destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration) - }) - const pauseDestroy = useNonReactiveCallback(() => { - clearTimeout(destroyTimeoutRef.current) - }) - - useEffect(() => { - hideAndDestroyAfterTimeout() - }, [hideAndDestroyAfterTimeout]) - - const panGesture = useMemo(() => { - return Gesture.Pan() - .activeOffsetY([-10, 10]) - .failOffsetX([-10, 10]) - .maxPointers(1) - .onStart(() => { - 'worklet' - if (!alive) return - isPanning.set(true) - runOnJS(pauseDestroy)() - }) - .onUpdate(e => { - 'worklet' - if (!alive) return - dismissSwipeTranslateY.value = e.translationY - }) - .onEnd(e => { - 'worklet' - if (!alive) return - runOnJS(hideAndDestroyAfterTimeout)() - isPanning.set(false) - if (e.velocityY < -100) { - if (dismissSwipeTranslateY.value === 0) { - // HACK: If the initial value is 0, withDecay() animation doesn't start. - // This is a bug in Reanimated, but for now we'll work around it like this. - dismissSwipeTranslateY.value = 1 - } - dismissSwipeTranslateY.value = withDecay({ - velocity: e.velocityY, - velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), - deceleration: 1, - }) - } else { - dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 500, - damping: 50, - }) - } - }) - }, [ - dismissSwipeTranslateY, - isPanning, - alive, - hideAndDestroyAfterTimeout, - pauseDestroy, - ]) - - const topOffset = top + 10 - - useAnimatedReaction( - () => - !isPanning.get() && - dismissSwipeTranslateY.get() < -topOffset - cardHeight, - (isSwipedAway, prevIsSwipedAway) => { - 'worklet' - if (isSwipedAway && !prevIsSwipedAway) { - runOnJS(destroy)() - } - }, +/** + * The toast UI component + */ +export function Toast({type, content}: ToastComponentProps) { + return ( + <View style={[a.px_xl, a.w_full]}> + <BaseToast content={content} type={type} /> + </View> ) +} - const animatedStyle = useAnimatedStyle(() => { - const translation = dismissSwipeTranslateY.get() - return { - transform: [ - { - translateY: translation > 0 ? translation ** 0.7 : translation, - }, - ], - } +/** + * Access the full Sonner API + */ +export const api = sonner + +/** + * Our base toast API, using the `Toast` export of this file. + */ +export function show( + content: React.ReactNode, + {type, ...options}: BaseToastOptions = {}, +) { + sonner.custom(<Toast content={content} type={type} />, { + ...options, + duration: options?.duration ?? DURATION, }) - - return ( - <GestureHandlerRootView - style={[a.absolute, {top: topOffset, left: 16, right: 16}]} - pointerEvents="box-none"> - {alive && ( - <Animated.View - entering={SlideInUp.easing(Easing.out(Easing.exp)).duration( - TOAST_ANIMATION_DURATION, - )} - exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration( - TOAST_ANIMATION_DURATION * 0.7, - )} - onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} - accessibilityRole="alert" - accessible={true} - accessibilityLabel={a11yLabel} - accessibilityHint="" - onAccessibilityEscape={hideAndDestroyImmediately} - style={[a.flex_1, animatedStyle]}> - <GestureDetector gesture={panGesture}> - <Toast content={content} type={type} /> - </GestureDetector> - </Animated.View> - )} - </GestureHandlerRootView> - ) } |