diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Toast/Toast.tsx | 205 | ||||
-rw-r--r-- | src/components/Toast/const.ts | 1 | ||||
-rw-r--r-- | src/components/Toast/index.e2e.tsx | 5 | ||||
-rw-r--r-- | src/components/Toast/index.tsx | 197 | ||||
-rw-r--r-- | src/components/Toast/index.web.tsx | 107 | ||||
-rw-r--r-- | src/components/Toast/types.ts | 24 |
6 files changed, 539 insertions, 0 deletions
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx new file mode 100644 index 000000000..0dc9d4b07 --- /dev/null +++ b/src/components/Toast/Toast.tsx @@ -0,0 +1,205 @@ +import {createContext, useContext, useMemo} from 'react' +import {View} from 'react-native' + +import {atoms as a, select, useTheme} from '#/alf' +import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import {type ToastType} from '#/components/Toast/types' +import {Text} from '#/components/Typography' + +type ContextType = { + type: ToastType +} + +export const ICONS = { + default: SuccessIcon, + success: SuccessIcon, + error: ErrorIcon, + warning: WarningIcon, + info: CircleInfo, +} + +const Context = createContext<ContextType>({ + type: 'default', +}) + +export function Toast({ + type, + content, +}: { + type: ToastType + content: React.ReactNode +}) { + const t = useTheme() + const styles = useToastStyles({type}) + const Icon = ICONS[type] + + return ( + <Context.Provider value={useMemo(() => ({type}), [type])}> + <View + style={[ + a.flex_1, + a.py_lg, + a.pl_xl, + a.pr_2xl, + a.rounded_md, + a.border, + a.flex_row, + a.gap_sm, + t.atoms.shadow_sm, + { + backgroundColor: styles.backgroundColor, + borderColor: styles.borderColor, + }, + ]}> + <Icon size="md" fill={styles.iconColor} /> + + <View style={[a.flex_1]}> + {typeof content === 'string' ? ( + <ToastText>{content}</ToastText> + ) : ( + content + )} + </View> + </View> + </Context.Provider> + ) +} + +export function ToastText({children}: {children: React.ReactNode}) { + const {type} = useContext(Context) + const {textColor} = useToastStyles({type}) + return ( + <Text + style={[ + a.text_md, + a.font_bold, + a.leading_snug, + { + color: textColor, + }, + ]}> + {children} + </Text> + ) +} + +function useToastStyles({type}: {type: ToastType}) { + const t = useTheme() + return useMemo(() => { + return { + default: { + backgroundColor: select(t.name, { + light: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_100.backgroundColor, + dark: t.atoms.bg_contrast_100.backgroundColor, + }), + borderColor: select(t.name, { + light: t.atoms.border_contrast_low.borderColor, + dim: t.atoms.border_contrast_high.borderColor, + dark: t.atoms.border_contrast_high.borderColor, + }), + iconColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + textColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + }, + success: { + backgroundColor: select(t.name, { + light: t.palette.primary_100, + dim: t.palette.primary_100, + dark: t.palette.primary_50, + }), + borderColor: select(t.name, { + light: t.palette.primary_500, + dim: t.palette.primary_500, + dark: t.palette.primary_500, + }), + iconColor: select(t.name, { + light: t.palette.primary_500, + dim: t.palette.primary_600, + dark: t.palette.primary_600, + }), + textColor: select(t.name, { + light: t.palette.primary_500, + dim: t.palette.primary_600, + dark: t.palette.primary_600, + }), + }, + error: { + backgroundColor: select(t.name, { + light: t.palette.negative_200, + dim: t.palette.negative_25, + dark: t.palette.negative_25, + }), + borderColor: select(t.name, { + light: t.palette.negative_300, + dim: t.palette.negative_300, + dark: t.palette.negative_300, + }), + iconColor: select(t.name, { + light: t.palette.negative_600, + dim: t.palette.negative_600, + dark: t.palette.negative_600, + }), + textColor: select(t.name, { + light: t.palette.negative_600, + dim: t.palette.negative_600, + dark: t.palette.negative_600, + }), + }, + warning: { + backgroundColor: select(t.name, { + light: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_100.backgroundColor, + dark: t.atoms.bg_contrast_100.backgroundColor, + }), + borderColor: select(t.name, { + light: t.atoms.border_contrast_low.borderColor, + dim: t.atoms.border_contrast_high.borderColor, + dark: t.atoms.border_contrast_high.borderColor, + }), + iconColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + textColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + }, + info: { + backgroundColor: select(t.name, { + light: t.atoms.bg_contrast_25.backgroundColor, + dim: t.atoms.bg_contrast_100.backgroundColor, + dark: t.atoms.bg_contrast_100.backgroundColor, + }), + borderColor: select(t.name, { + light: t.atoms.border_contrast_low.borderColor, + dim: t.atoms.border_contrast_high.borderColor, + dark: t.atoms.border_contrast_high.borderColor, + }), + iconColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + textColor: select(t.name, { + light: t.atoms.text_contrast_medium.color, + dim: t.atoms.text_contrast_medium.color, + dark: t.atoms.text_contrast_medium.color, + }), + }, + }[type] + }, [t, type]) +} diff --git a/src/components/Toast/const.ts b/src/components/Toast/const.ts new file mode 100644 index 000000000..034d0a2fc --- /dev/null +++ b/src/components/Toast/const.ts @@ -0,0 +1 @@ +export const DEFAULT_TOAST_DURATION = 3000 diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx new file mode 100644 index 000000000..57daf5bf0 --- /dev/null +++ b/src/components/Toast/index.e2e.tsx @@ -0,0 +1,5 @@ +export function ToastContainer() { + return null +} + +export function show() {} diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx new file mode 100644 index 000000000..131a796b3 --- /dev/null +++ b/src/components/Toast/index.tsx @@ -0,0 +1,197 @@ +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 {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()} + /> + ), + ) + }, +} + +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)() + } + }, + ) + + const animatedStyle = useAnimatedStyle(() => { + const translation = dismissSwipeTranslateY.get() + return { + transform: [ + { + translateY: translation > 0 ? translation ** 0.7 : translation, + }, + ], + } + }) + + 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 new file mode 100644 index 000000000..f6ceda568 --- /dev/null +++ b/src/components/Toast/index.web.tsx @@ -0,0 +1,107 @@ +/* + * 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 {atoms as a, useBreakpoints} 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_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]) + + 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`Dismiss toast`)} + accessibilityHint="" + onPress={() => setActiveToast(undefined)} + /> + </View> + )} + </> + ) +} + +export const toast: ToastApi = { + show(props) { + if (toastTimeout) { + clearTimeout(toastTimeout) + } + + globalSetActiveToast?.({ + type: props.type, + content: props.content, + a11yLabel: props.a11yLabel, + }) + + toastTimeout = setTimeout(() => { + globalSetActiveToast?.(undefined) + }, props.duration || DEFAULT_TOAST_DURATION) + }, +} diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts new file mode 100644 index 000000000..9f1245fa2 --- /dev/null +++ b/src/components/Toast/types.ts @@ -0,0 +1,24 @@ +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 +} |