diff options
author | Eric Bailey <git@esb.lol> | 2025-08-14 09:51:40 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-14 09:51:40 -0500 |
commit | 7b2e61bf4dd1e10ade956b2ac091dbb44d41d525 (patch) | |
tree | a29f4b3543bb4846e97af2d4425e311c86826947 /src/components/Toast | |
parent | 221623f55aa6c1bbe699c8d409832da110923c76 (diff) | |
download | voidsky-7b2e61bf4dd1e10ade956b2ac091dbb44d41d525.tar.zst |
Integrate Sonner for toasts (#8839)
* Integrate Sonner for toasts * Fix animation on iOS * Refactor API * Update e2e file
Diffstat (limited to 'src/components/Toast')
-rw-r--r-- | src/components/Toast/Toast.tsx | 15 | ||||
-rw-r--r-- | src/components/Toast/const.ts | 2 | ||||
-rw-r--r-- | src/components/Toast/index.e2e.tsx | 19 | ||||
-rw-r--r-- | src/components/Toast/index.tsx | 230 | ||||
-rw-r--r-- | src/components/Toast/index.web.tsx | 134 | ||||
-rw-r--r-- | src/components/Toast/types.ts | 47 |
6 files changed, 120 insertions, 327 deletions
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx index 908b470a4..28220cb8d 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -13,6 +13,11 @@ type ContextType = { type: ToastType } +export type ToastComponentProps = { + type?: ToastType + content: React.ReactNode +} + export const ICONS = { default: CircleCheck, success: CircleCheck, @@ -26,13 +31,7 @@ const Context = createContext<ContextType>({ }) Context.displayName = 'ToastContext' -export function Toast({ - type, - content, -}: { - type: ToastType - content: React.ReactNode -}) { +export function Toast({type = 'default', content}: ToastComponentProps) { const {fonts} = useAlf() const t = useTheme() const styles = useToastStyles({type}) @@ -90,10 +89,12 @@ export function ToastText({children}: {children: React.ReactNode}) { const {textColor} = useToastStyles({type}) return ( <Text + selectable={false} style={[ a.text_md, a.font_bold, a.leading_snug, + a.pointer_events_none, { color: textColor, }, diff --git a/src/components/Toast/const.ts b/src/components/Toast/const.ts index 034d0a2fc..d63832bdd 100644 --- a/src/components/Toast/const.ts +++ b/src/components/Toast/const.ts @@ -1 +1 @@ -export const DEFAULT_TOAST_DURATION = 3000 +export const DURATION = 3e3 diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx index 64072d88d..357bd8dda 100644 --- a/src/components/Toast/index.e2e.tsx +++ b/src/components/Toast/index.e2e.tsx @@ -1,9 +1,16 @@ -import {type ToastApi} from '#/components/Toast/types' - -export function ToastContainer() { +export function ToastOutlet() { return null } -export const toast: ToastApi = { - show() {}, -} +export const api = () => {} +api.success = () => {} +api.wiggle = () => {} +api.error = () => {} +api.warning = () => {} +api.info = () => {} +api.promise = () => {} +api.custom = () => {} +api.loading = () => {} +api.dismiss = () => {} + +export function show() {} 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> - ) } diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index f2517e28d..857ed7b39 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -1,112 +1,40 @@ -/* - * Note: relies on styles in #/styles.css - */ - -import {useEffect, useState} from 'react' -import {AccessibilityInfo, Pressable, View} from 'react-native' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' +import {toast as sonner, Toaster} from 'sonner' -import {atoms as a, useBreakpoints} from '#/alf' -import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' +import {atoms as a} from '#/alf' +import {DURATION} from '#/components/Toast/const' import {Toast} from '#/components/Toast/Toast' -import {type ToastApi, type ToastType} from '#/components/Toast/types' - -const TOAST_ANIMATION_STYLES = { - entering: { - animation: 'toastFadeIn 0.3s ease-out forwards', - }, - exiting: { - animation: 'toastFadeOut 0.2s ease-in forwards', - }, -} - -interface ActiveToast { - type: ToastType - content: React.ReactNode - a11yLabel: string -} -type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void -let globalSetActiveToast: GlobalSetActiveToast | undefined -let toastTimeout: NodeJS.Timeout | undefined -type ToastContainerProps = {} - -export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { - const {_} = useLingui() - const {gtPhone} = useBreakpoints() - const [activeToast, setActiveToast] = useState<ActiveToast | undefined>() - const [isExiting, setIsExiting] = useState(false) - - useEffect(() => { - globalSetActiveToast = (t: ActiveToast | undefined) => { - if (!t && activeToast) { - setIsExiting(true) - setTimeout(() => { - setActiveToast(t) - setIsExiting(false) - }, 200) - } else { - if (t) { - AccessibilityInfo.announceForAccessibility(t.a11yLabel) - } - setActiveToast(t) - setIsExiting(false) - } - } - }, [activeToast]) +import {type BaseToastOptions} from '#/components/Toast/types' +/** + * Toasts are rendered in a global outlet, which is placed at the top of the + * component tree. + */ +export function ToastOutlet() { return ( - <> - {activeToast && ( - <View - style={[ - a.fixed, - { - left: a.px_xl.paddingLeft, - right: a.px_xl.paddingLeft, - bottom: a.px_xl.paddingLeft, - ...(isExiting - ? TOAST_ANIMATION_STYLES.exiting - : TOAST_ANIMATION_STYLES.entering), - }, - gtPhone && [ - { - maxWidth: 380, - }, - ], - ]}> - <Toast content={activeToast.content} type={activeToast.type} /> - <Pressable - style={[a.absolute, a.inset_0]} - accessibilityLabel={_( - msg({ - message: `Dismiss message`, - comment: `Accessibility label for dismissing a toast notification`, - }), - )} - accessibilityHint="" - onPress={() => setActiveToast(undefined)} - /> - </View> - )} - </> + <Toaster + position="bottom-left" + gap={a.gap_sm.gap} + offset={a.p_xl.padding} + mobileOffset={a.p_xl.padding} + /> ) } -export const toast: ToastApi = { - show(props) { - if (toastTimeout) { - clearTimeout(toastTimeout) - } - - globalSetActiveToast?.({ - type: props.type, - content: props.content, - a11yLabel: props.a11yLabel, - }) +/** + * Access the full Sonner API + */ +export const api = sonner - toastTimeout = setTimeout(() => { - globalSetActiveToast?.(undefined) - }, props.duration || DEFAULT_TOAST_DURATION) - }, +/** + * Our base toast API, using the `Toast` export of this file. + */ +export function show( + content: React.ReactNode, + {type, ...options}: BaseToastOptions = {}, +) { + sonner(<Toast content={content} type={type} />, { + unstyled: true, // required on web + ...options, + duration: options?.duration ?? DURATION, + }) } diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts index 9f1245fa2..463e6d66c 100644 --- a/src/components/Toast/types.ts +++ b/src/components/Toast/types.ts @@ -1,24 +1,29 @@ +import {type toast as sonner} from 'sonner-native' + +/** + * This is not exported from `sonner-native` so just hacking it in here. + */ +export type ExternalToast = Exclude< + Parameters<typeof sonner.custom>[1], + undefined +> + export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' -export type ToastApi = { - show: (props: { - /** - * The type of toast to show. This determines the styling and icon used. - */ - type: ToastType - /** - * A string, `Text`, or `Span` components to render inside the toast. This - * allows additional formatting of the content, but should not be used for - * interactive elements link links or buttons. - */ - content: React.ReactNode | string - /** - * Accessibility label for the toast, used for screen readers. - */ - a11yLabel: string - /** - * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. - */ - duration?: number - }) => void +/** + * Not all properties are available on all platforms, so we pick out only those + * we support. Add more here as needed. + */ +export type BaseToastOptions = Pick< + ExternalToast, + 'duration' | 'dismissible' | 'promiseOptions' +> & { + type?: ToastType + + /** + * These methods differ between web/native implementations + */ + onDismiss?: () => void + onPress?: () => void + onAutoClose?: () => void } |