diff options
author | Eric Bailey <git@esb.lol> | 2025-07-31 10:15:35 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-07-31 10:15:35 -0500 |
commit | 3bcfcba6d8176bac03202b496110915da748b0f1 (patch) | |
tree | 68c75c7c80945a8a5f5a32522dd9aa29f119e02a /src | |
parent | 33e071494881b13696e24b334857e594f29a4b1d (diff) | |
download | voidsky-3bcfcba6d8176bac03202b496110915da748b0f1.tar.zst |
Some toasts cleanup and reorg (#8748)
* Reorg * Move animation into css file * Update style comment * Extract core component, use platform-specific wrappers * Pull out platform specific styles * Just move styles into Toast component itself * Rename cleanup * Update API * Add duration optional prop * Add some type docs * add exp eased slide aniamtions * Make toasts full width on mobile web --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/App.web.tsx | 2 | ||||
-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 | ||||
-rw-r--r-- | src/style.css | 20 | ||||
-rw-r--r-- | src/view/com/util/Toast.e2e.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/Toast.style.tsx | 201 | ||||
-rw-r--r-- | src/view/com/util/Toast.tsx | 268 | ||||
-rw-r--r-- | src/view/com/util/Toast.web.tsx | 180 | ||||
-rw-r--r-- | src/view/screens/Storybook/Toasts.tsx | 188 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 3 |
14 files changed, 707 insertions, 695 deletions
diff --git a/src/App.web.tsx b/src/App.web.tsx index 04de8529f..1f795cb3e 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -50,7 +50,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import * as Toast from '#/view/com/util/Toast' -import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' @@ -61,6 +60,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo import {Provider as PortalProvider} from '#/components/Portal' import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ToastContainer} from '#/components/Toast' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 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 +} diff --git a/src/style.css b/src/style.css index 35ffe0d3a..4c5677fbf 100644 --- a/src/style.css +++ b/src/style.css @@ -369,3 +369,23 @@ input[type='range'][orient='vertical']::-moz-range-thumb { transform: translateY(0); } } + +/* + * #/components/Toast/index.web.tsx + */ +@keyframes toastFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes toastFadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} diff --git a/src/view/com/util/Toast.e2e.tsx b/src/view/com/util/Toast.e2e.tsx deleted file mode 100644 index c5582ff0a..000000000 --- a/src/view/com/util/Toast.e2e.tsx +++ /dev/null @@ -1 +0,0 @@ -export function show() {} diff --git a/src/view/com/util/Toast.style.tsx b/src/view/com/util/Toast.style.tsx deleted file mode 100644 index 3869e6890..000000000 --- a/src/view/com/util/Toast.style.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import {select, type Theme} 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' - -export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' - -export type LegacyToastType = - | 'xmark' - | 'exclamation-circle' - | 'check' - | 'clipboard-check' - | 'circle-exclamation' - -export const convertLegacyToastType = ( - type: ToastType | LegacyToastType, -): ToastType => { - switch (type) { - // these ones are fine - case 'default': - case 'success': - case 'error': - case 'warning': - case 'info': - return type - // legacy ones need conversion - case 'xmark': - return 'error' - case 'exclamation-circle': - return 'warning' - case 'check': - return 'success' - case 'clipboard-check': - return 'success' - case 'circle-exclamation': - return 'warning' - default: - return 'default' - } -} - -export const TOAST_ANIMATION_CONFIG = { - duration: 300, - damping: 15, - stiffness: 150, - mass: 0.8, - overshootClamping: false, - restSpeedThreshold: 0.01, - restDisplacementThreshold: 0.01, -} - -export const TOAST_TYPE_TO_ICON = { - default: SuccessIcon, - success: SuccessIcon, - error: ErrorIcon, - warning: WarningIcon, - info: CircleInfo, -} - -export const getToastTypeStyles = (t: Theme) => ({ - 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, - }), - }, -}) - -export const getToastWebAnimationStyles = () => ({ - entering: { - animation: 'toastFadeIn 0.3s ease-out forwards', - }, - exiting: { - animation: 'toastFadeOut 0.2s ease-in forwards', - }, -}) - -export const TOAST_WEB_KEYFRAMES = ` - @keyframes toastFadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - - @keyframes toastFadeOut { - from { - opacity: 1; - } - to { - opacity: 0; - } - } -` diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 54ef7042d..37ec6acb5 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,234 +1,54 @@ -import {useEffect, useMemo, useRef, useState} from 'react' -import {AccessibilityInfo, View} from 'react-native' -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler' -import Animated, { - FadeIn, - FadeOut, - runOnJS, - 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 {toast} from '#/components/Toast' +import {type ToastType} from '#/components/Toast/types' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import { - convertLegacyToastType, - getToastTypeStyles, - type LegacyToastType, - TOAST_ANIMATION_CONFIG, - TOAST_TYPE_TO_ICON, - type ToastType, -} from '#/view/com/util/Toast.style' -import {atoms as a, useTheme} from '#/alf' -import {Text} from '#/components/Typography' - -const TIMEOUT = 2e3 +/** + * @deprecated use {@link ToastType} and {@link toast} instead + */ +export type LegacyToastType = + | 'xmark' + | 'exclamation-circle' + | 'check' + | 'clipboard-check' + | 'circle-exclamation' + +export const convertLegacyToastType = ( + type: ToastType | LegacyToastType, +): ToastType => { + switch (type) { + // these ones are fine + case 'default': + case 'success': + case 'error': + case 'warning': + case 'info': + return type + // legacy ones need conversion + case 'xmark': + return 'error' + case 'exclamation-circle': + return 'warning' + case 'check': + return 'success' + case 'clipboard-check': + return 'success' + case 'circle-exclamation': + return 'warning' + default: + return 'default' + } +} -// Use type overloading to mark certain types as deprecated -sfn -// https://stackoverflow.com/a/78325851/13325987 -export function show(message: string, type?: ToastType): void /** - * @deprecated type is deprecated - use one of `'default' | 'success' | 'error' | 'warning' | 'info'` + * @deprecated use {@link toast} instead */ -export function show(message: string, type?: LegacyToastType): void export function show( message: string, type: ToastType | LegacyToastType = 'default', ): void { - if (process.env.NODE_ENV === 'test') { - return - } - - AccessibilityInfo.announceForAccessibility(message) - const item = new RootSiblings( - ( - <Toast - message={message} - type={convertLegacyToastType(type)} - destroy={() => item.destroy()} - /> - ), - ) -} - -function Toast({ - message, - type, - destroy, -}: { - message: string - type: ToastType - destroy: () => void -}) { - const t = useTheme() - const {top} = useSafeAreaInsets() - const isPanning = useSharedValue(false) - const dismissSwipeTranslateY = useSharedValue(0) - const [cardHeight, setCardHeight] = useState(0) - - const toastStyles = getToastTypeStyles(t) - const colors = toastStyles[type] - const IconComponent = TOAST_TYPE_TO_ICON[type] - - // 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, TIMEOUT) + const convertedType = convertLegacyToastType(type) + toast.show({ + type: convertedType, + content: message, + a11yLabel: message, }) - 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={FadeIn.duration(TOAST_ANIMATION_CONFIG.duration)} - exiting={FadeOut.duration(TOAST_ANIMATION_CONFIG.duration * 0.7)} - onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} - accessibilityRole="alert" - accessible={true} - accessibilityLabel={message} - accessibilityHint="" - onAccessibilityEscape={hideAndDestroyImmediately} - style={[ - a.flex_1, - {backgroundColor: colors.backgroundColor}, - a.shadow_sm, - {borderColor: colors.borderColor, borderWidth: 1}, - a.rounded_sm, - animatedStyle, - ]}> - <GestureDetector gesture={panGesture}> - <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> - <View - style={[ - a.flex_shrink_0, - a.rounded_full, - {width: 32, height: 32}, - a.align_center, - a.justify_center, - { - backgroundColor: colors.backgroundColor, - }, - ]}> - <IconComponent fill={colors.iconColor} size="sm" /> - </View> - <View - style={[ - a.h_full, - a.justify_center, - a.flex_1, - a.justify_center, - ]}> - <Text - style={[a.text_md, a.font_bold, {color: colors.textColor}]} - emoji> - {message} - </Text> - </View> - </View> - </GestureDetector> - </Animated.View> - )} - </GestureHandlerRootView> - ) } diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx deleted file mode 100644 index 6b99b30bf..000000000 --- a/src/view/com/util/Toast.web.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Note: the dataSet properties are used to leverage custom CSS in public/index.html - */ - -import {useEffect, useState} from 'react' -import {Pressable, StyleSheet, Text, View} from 'react-native' - -import { - convertLegacyToastType, - getToastTypeStyles, - getToastWebAnimationStyles, - type LegacyToastType, - TOAST_TYPE_TO_ICON, - TOAST_WEB_KEYFRAMES, - type ToastType, -} from '#/view/com/util/Toast.style' -import {atoms as a, useTheme} from '#/alf' - -const DURATION = 3500 - -interface ActiveToast { - text: string - type: ToastType -} -type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void - -// globals -// = -let globalSetActiveToast: GlobalSetActiveToast | undefined -let toastTimeout: NodeJS.Timeout | undefined - -// components -// = -type ToastContainerProps = {} -export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { - 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 { - setActiveToast(t) - setIsExiting(false) - } - } - }, [activeToast]) - - useEffect(() => { - const styleId = 'toast-animations' - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = TOAST_WEB_KEYFRAMES - document.head.appendChild(style) - } - }, []) - - const t = useTheme() - - const toastTypeStyles = getToastTypeStyles(t) - const toastStyles = activeToast - ? toastTypeStyles[activeToast.type] - : toastTypeStyles.default - - const IconComponent = activeToast - ? TOAST_TYPE_TO_ICON[activeToast.type] - : TOAST_TYPE_TO_ICON.default - - const animationStyles = getToastWebAnimationStyles() - - return ( - <> - {activeToast && ( - <View - style={[ - styles.container, - { - backgroundColor: toastStyles.backgroundColor, - borderColor: toastStyles.borderColor, - ...(isExiting - ? animationStyles.exiting - : animationStyles.entering), - }, - ]}> - <View - style={[ - styles.iconContainer, - { - backgroundColor: 'transparent', - }, - ]}> - <IconComponent - fill={toastStyles.iconColor} - size="sm" - style={styles.icon} - /> - </View> - <Text - style={[ - styles.text, - a.text_sm, - a.font_bold, - {color: toastStyles.textColor}, - ]}> - {activeToast.text} - </Text> - <Pressable - style={styles.dismissBackdrop} - accessibilityLabel="Dismiss" - accessibilityHint="" - onPress={() => { - setActiveToast(undefined) - }} - /> - </View> - )} - </> - ) -} - -// methods -// = - -export function show( - text: string, - type: ToastType | LegacyToastType = 'default', -) { - if (toastTimeout) { - clearTimeout(toastTimeout) - } - - globalSetActiveToast?.({text, type: convertLegacyToastType(type)}) - toastTimeout = setTimeout(() => { - globalSetActiveToast?.(undefined) - }, DURATION) -} - -const styles = StyleSheet.create({ - container: { - // @ts-ignore web only - position: 'fixed', - left: 20, - bottom: 20, - // @ts-ignore web only - width: 'calc(100% - 40px)', - maxWidth: 380, - padding: 20, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 10, - borderWidth: 1, - }, - dismissBackdrop: { - position: 'absolute', - top: 0, - left: 0, - bottom: 0, - right: 0, - }, - iconContainer: { - width: 32, - height: 32, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - }, - icon: { - flexShrink: 0, - }, - text: { - marginLeft: 10, - }, -}) diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 4c17f1c33..8fc6f095f 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -1,65 +1,11 @@ import {Pressable, View} from 'react-native' -import * as Toast from '#/view/com/util/Toast' -import { - getToastTypeStyles, - TOAST_TYPE_TO_ICON, - type ToastType, -} from '#/view/com/util/Toast.style' -import {atoms as a, useTheme} from '#/alf' -import {H1, Text} from '#/components/Typography' - -function ToastPreview({message, type}: {message: string; type: ToastType}) { - const t = useTheme() - const toastStyles = getToastTypeStyles(t) - const colors = toastStyles[type as keyof typeof toastStyles] - const IconComponent = - TOAST_TYPE_TO_ICON[type as keyof typeof TOAST_TYPE_TO_ICON] - - return ( - <Pressable - accessibilityRole="button" - onPress={() => Toast.show(message, type)} - style={[ - {backgroundColor: colors.backgroundColor}, - a.shadow_sm, - {borderColor: colors.borderColor}, - a.rounded_sm, - a.border, - a.px_sm, - a.py_sm, - a.flex_row, - a.gap_sm, - a.align_center, - ]}> - <View - style={[ - a.flex_shrink_0, - a.rounded_full, - {width: 24, height: 24}, - a.align_center, - a.justify_center, - { - backgroundColor: colors.backgroundColor, - }, - ]}> - <IconComponent fill={colors.iconColor} size="xs" /> - </View> - <View style={[a.flex_1]}> - <Text - style={[ - a.text_sm, - a.font_bold, - a.leading_snug, - {color: colors.textColor}, - ]} - emoji> - {message} - </Text> - </View> - </Pressable> - ) -} +import {show as deprecatedShow} from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {toast} from '#/components/Toast' +import {Toast} from '#/components/Toast/Toast' +import {H1} from '#/components/Typography' export function Toasts() { return ( @@ -67,35 +13,103 @@ export function Toasts() { <H1>Toast Examples</H1> <View style={[a.gap_md]}> - <View style={[a.gap_xs]}> - <ToastPreview message="Default Toast" type="default" /> - </View> - - <View style={[a.gap_xs]}> - <ToastPreview - message="Operation completed successfully!" - type="success" + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'default', + content: 'Default toast', + a11yLabel: 'Default toast', + }) + }> + <Toast content="Default toast" type="default" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'default', + content: 'Default toast, 6 seconds', + a11yLabel: 'Default toast, 6 seconds', + duration: 6e3, + }) + }> + <Toast content="Default toast, 6 seconds" type="default" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'default', + content: + 'This is a longer message to test how the toast handles multiple lines of text content.', + a11yLabel: + 'This is a longer message to test how the toast handles multiple lines of text content.', + }) + }> + <Toast + content="This is a longer message to test how the toast handles multiple lines of text content." + type="default" /> - </View> - - <View style={[a.gap_xs]}> - <ToastPreview message="Something went wrong!" type="error" /> - </View> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'success', + content: 'Success toast', + a11yLabel: 'Success toast', + }) + }> + <Toast content="Success toast" type="success" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'info', + content: 'Info toast', + a11yLabel: 'Info toast', + }) + }> + <Toast content="Info" type="info" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'warning', + content: 'Warning toast', + a11yLabel: 'Warning toast', + }) + }> + <Toast content="Warning" type="warning" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'error', + content: 'Error toast', + a11yLabel: 'Error toast', + }) + }> + <Toast content="Error" type="error" /> + </Pressable> - <View style={[a.gap_xs]}> - <ToastPreview message="Please check your input" type="warning" /> - </View> - - <View style={[a.gap_xs]}> - <ToastPreview message="Here's some helpful information" type="info" /> - </View> - - <View style={[a.gap_xs]}> - <ToastPreview - message="This is a longer message to test how the toast handles multiple lines of text content." - type="info" - /> - </View> + <Button + label="Deprecated toast example" + onPress={() => + deprecatedShow( + 'This is a deprecated toast example', + 'exclamation-circle', + ) + } + size="large" + variant="solid" + color="secondary"> + <ButtonText>Deprecated toast example</ButtonText> + </Button> </View> </View> ) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index afcc1c4e7..40ef79cca 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -91,6 +91,8 @@ function StorybookInner() { </Button> </View> + <Toasts /> + <Button variant="solid" color="primary" @@ -123,7 +125,6 @@ function StorybookInner() { <Breakpoints /> <Dialogs /> <Admonitions /> - <Toasts /> <Settings /> <Button |