about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorAna <anastasiyauraleva@gmail.com>2025-07-29 18:15:32 -0700
committerAna <anastasiyauraleva@gmail.com>2025-07-29 22:12:36 -0700
commit34ea6e8f3499eeeb1013dfbf7c4dcd3bdcf149a3 (patch)
tree462207a6246da0dc24cce08ab8c3fba2e7c2c951 /src
parent890dee3eef38700c8ebf850c37e1bf79c54aec2e (diff)
downloadvoidsky-34ea6e8f3499eeeb1013dfbf7c4dcd3bdcf149a3.tar.zst
update: toast styles that reuse consistent style
Diffstat (limited to 'src')
-rw-r--r--src/view/com/util/Toast.tsx182
-rw-r--r--src/view/com/util/Toast.web.tsx148
-rw-r--r--src/view/screens/Storybook/Toasts.tsx139
3 files changed, 216 insertions, 253 deletions
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index fc9bdf672..2f8888bef 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -6,8 +6,8 @@ import {
   GestureHandlerRootView,
 } from 'react-native-gesture-handler'
 import Animated, {
-  FadeInUp,
-  FadeOutUp,
+  FadeIn,
+  FadeOut,
   runOnJS,
   useAnimatedReaction,
   useAnimatedStyle,
@@ -17,53 +17,36 @@ import Animated, {
 } from 'react-native-reanimated'
 import RootSiblings from 'react-native-root-siblings'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {
-  FontAwesomeIcon,
-  type Props as FontAwesomeProps,
-} from '@fortawesome/react-native-fontawesome'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {isWeb} from '#/platform/detection'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
+import {
+  type ToastType,
+  TOAST_TYPE_TO_ICON,
+  getToastTypeStyles,
+  TOAST_ANIMATION_CONFIG,
+} from './Toast.style'
 
 const TIMEOUT = 2e3
 
-export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
-
-const TOAST_TYPE_TO_ICON: Record<ToastType, FontAwesomeProps['icon']> = {
-  default: 'check',
-  success: 'check',
-  error: 'exclamation',
-  warning: 'circle-exclamation',
-  info: 'info',
-}
-
-export function show(
-  message: string,
-  type: ToastType | FontAwesomeProps['icon'] = 'default',
-) {
+export function show(message: string, type: ToastType = 'default') {
   if (process.env.NODE_ENV === 'test') {
     return
   }
 
-  const icon =
-    typeof type === 'string' && type in TOAST_TYPE_TO_ICON
-      ? TOAST_TYPE_TO_ICON[type as ToastType]
-      : (type as FontAwesomeProps['icon'])
-
   AccessibilityInfo.announceForAccessibility(message)
   const item = new RootSiblings(
-    <Toast message={message} icon={icon} destroy={() => item.destroy()} />,
+    <Toast message={message} type={type} destroy={() => item.destroy()} />,
   )
 }
 
 function Toast({
   message,
-  icon,
+  type,
   destroy,
 }: {
   message: string
-  icon: FontAwesomeProps['icon']
+  type: ToastType
   destroy: () => void
 }) {
   const t = useTheme()
@@ -72,6 +55,10 @@ function Toast({
   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
@@ -169,103 +156,58 @@ function Toast({
     }
   })
 
-  // Web-specific styles for better compatibility
-  const webContainerStyle = isWeb
-    ? {
-        position: 'absolute' as const,
-        top: topOffset,
-        left: 16,
-        right: 16,
-        zIndex: 9999,
-        pointerEvents: 'auto' as const,
-      }
-    : {}
-
-  const webToastStyle = isWeb
-    ? {
-        backgroundColor:
-          t.name === 'dark' ? t.palette.contrast_25 : t.palette.white,
-        shadowColor: '#000',
-        shadowOffset: {width: 0, height: 10},
-        shadowOpacity: 0.1,
-        shadowRadius: 15,
-        elevation: 10,
-        borderColor: t.palette.contrast_300,
-        borderWidth: 1,
-        borderRadius: 8,
-        minHeight: 60,
-      }
-    : {}
-
   return (
     <GestureHandlerRootView
-      style={[
-        a.absolute,
-        {top: topOffset, left: 16, right: 16},
-        isWeb && webContainerStyle,
-      ]}
+      style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
       pointerEvents="box-none">
       {alive && (
         <Animated.View
-          entering={FadeInUp}
-          exiting={FadeOutUp}
-          style={[a.flex_1]}>
-          <Animated.View
-            onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
-            accessibilityRole="alert"
-            accessible={true}
-            accessibilityLabel={message}
-            accessibilityHint=""
-            onAccessibilityEscape={hideAndDestroyImmediately}
-            style={[
-              a.flex_1,
-              isWeb
-                ? webToastStyle
-                : [
-                    t.name === 'dark' ? t.atoms.bg_contrast_25 : t.atoms.bg,
-                    a.shadow_lg,
-                    t.atoms.border_contrast_medium,
-                    a.rounded_sm,
-                    a.border,
-                  ],
-              !isWeb && 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:
-                        t.name === 'dark'
-                          ? t.palette.black
-                          : t.palette.primary_50,
-                    },
-                  ]}>
-                  <FontAwesomeIcon
-                    icon={icon}
-                    size={16}
-                    style={t.atoms.text_contrast_medium}
-                  />
-                </View>
-                <View
-                  style={[
-                    a.h_full,
-                    a.justify_center,
-                    a.flex_1,
-                    a.justify_center,
-                  ]}>
-                  <Text style={a.text_md} emoji>
-                    {message}
-                  </Text>
-                </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>
-            </GestureDetector>
-          </Animated.View>
+            </View>
+          </GestureDetector>
         </Animated.View>
       )}
     </GestureHandlerRootView>
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index 949dce7ef..331a8b539 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -4,28 +4,19 @@
 
 import {useEffect, useState} from 'react'
 import {Pressable, StyleSheet, Text, View} from 'react-native'
+import {atoms as a, useTheme} from '#/alf'
 import {
-  FontAwesomeIcon,
-  type FontAwesomeIconStyle,
-  type Props as FontAwesomeProps,
-} from '@fortawesome/react-native-fontawesome'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-
-const DURATION = 60000
-
-export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
-
-const TOAST_TYPE_TO_ICON: Record<ToastType, FontAwesomeProps['icon']> = {
-  default: 'check',
-  success: 'check',
-  error: 'exclamation',
-  warning: 'circle-exclamation',
-  info: 'info',
-}
+  type ToastType,
+  TOAST_TYPE_TO_ICON,
+  getToastTypeStyles,
+  getToastWebAnimationStyles,
+  TOAST_WEB_KEYFRAMES,
+} from './Toast.style'
+
+const DURATION = 3500
 
 interface ActiveToast {
   text: string
-  icon: FontAwesomeProps['icon']
   type: ToastType
 }
 type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
@@ -40,50 +31,45 @@ let toastTimeout: NodeJS.Timeout | undefined
 type ToastContainerProps = {}
 export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
   const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
+  const [isExiting, setIsExiting] = useState(false)
+
   useEffect(() => {
     globalSetActiveToast = (t: ActiveToast | undefined) => {
-      setActiveToast(t)
+      if (!t && activeToast) {
+        setIsExiting(true)
+        setTimeout(() => {
+          setActiveToast(t)
+          setIsExiting(false)
+        }, 200)
+      } else {
+        setActiveToast(t)
+        setIsExiting(false)
+      }
     }
-  })
+  }, [activeToast])
 
-  const t = useTheme()
+  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 TOAST_TYPE_TO_STYLES = {
-    default: {
-      backgroundColor: t.atoms.text_contrast_low.color,
-      borderColor: t.atoms.border_contrast_medium.borderColor,
-      iconColor: '#fff',
-      textColor: '#fff',
-    },
-    success: {
-      backgroundColor: '#059669',
-      borderColor: '#047857',
-      iconColor: '#fff',
-      textColor: '#fff',
-    },
-    error: {
-      backgroundColor: t.palette.negative_100,
-      borderColor: t.palette.negative_400,
-      iconColor: t.palette.negative_600,
-      textColor: t.palette.negative_600,
-    },
-    warning: {
-      backgroundColor: t.palette.negative_500,
-      borderColor: t.palette.negative_600,
-      iconColor: '#fff',
-      textColor: '#fff',
-    },
-    info: {
-      backgroundColor: t.atoms.text_contrast_low.color,
-      borderColor: t.atoms.border_contrast_medium.borderColor,
-      iconColor: '#fff',
-      textColor: '#fff',
-    },
-  }
+  const t = useTheme()
 
+  const toastTypeStyles = getToastTypeStyles(t)
   const toastStyles = activeToast
-    ? TOAST_TYPE_TO_STYLES[activeToast.type]
-    : TOAST_TYPE_TO_STYLES.default
+    ? toastTypeStyles[activeToast.type]
+    : toastTypeStyles.default
+
+  const IconComponent = activeToast
+    ? TOAST_TYPE_TO_ICON[activeToast.type]
+    : TOAST_TYPE_TO_ICON.default
+
+  const animationStyles = getToastWebAnimationStyles()
 
   return (
     <>
@@ -94,18 +80,24 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
             {
               backgroundColor: toastStyles.backgroundColor,
               borderColor: toastStyles.borderColor,
+              ...(isExiting
+                ? animationStyles.exiting
+                : animationStyles.entering),
             },
           ]}>
-          <FontAwesomeIcon
-            icon={activeToast.icon}
-            size={20}
-            style={
-              [
-                styles.icon,
-                {color: toastStyles.iconColor},
-              ] as FontAwesomeIconStyle
-            }
-          />
+          <View
+            style={[
+              styles.iconContainer,
+              {
+                backgroundColor: 'transparent',
+              },
+            ]}>
+            <IconComponent
+              fill={toastStyles.iconColor}
+              size="sm"
+              style={styles.icon}
+            />
+          </View>
           <Text
             style={[
               styles.text,
@@ -132,22 +124,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
 // methods
 // =
 
-export function show(
-  text: string,
-  type: ToastType | FontAwesomeProps['icon'] = 'default',
-) {
+export function show(text: string, type: ToastType = 'default') {
   if (toastTimeout) {
     clearTimeout(toastTimeout)
   }
 
-  // Determine if type is a semantic type or direct icon
-  const isSemanticType = typeof type === 'string' && type in TOAST_TYPE_TO_ICON
-  const icon = isSemanticType
-    ? TOAST_TYPE_TO_ICON[type as ToastType]
-    : (type as FontAwesomeProps['icon'])
-  const toastType = isSemanticType ? (type as ToastType) : 'default'
-
-  globalSetActiveToast?.({text, icon, type: toastType})
+  globalSetActiveToast?.({text, type})
   toastTimeout = setTimeout(() => {
     globalSetActiveToast?.(undefined)
   }, DURATION)
@@ -161,10 +143,10 @@ const styles = StyleSheet.create({
     bottom: 20,
     // @ts-ignore web only
     width: 'calc(100% - 40px)',
-    maxWidth: 350,
+    maxWidth: 380,
     padding: 20,
     flexDirection: 'row',
-    alignItems: 'center',
+    alignItems: 'flex-start',
     borderRadius: 10,
     borderWidth: 1,
   },
@@ -175,6 +157,14 @@ const styles = StyleSheet.create({
     bottom: 0,
     right: 0,
   },
+  iconContainer: {
+    width: 32,
+    height: 32,
+    borderRadius: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    flexShrink: 0,
+  },
   icon: {
     flexShrink: 0,
   },
diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx
index 714afdfbd..5197ec2f4 100644
--- a/src/view/screens/Storybook/Toasts.tsx
+++ b/src/view/screens/Storybook/Toasts.tsx
@@ -1,68 +1,99 @@
-import {View} from 'react-native'
+import {View, Pressable} from 'react-native'
 
-import {atoms as a} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import {H1} from '#/components/Typography'
+import {atoms as a, useTheme} from '#/alf'
+import {Text, H1} from '#/components/Typography'
+import {
+  type ToastType,
+  TOAST_TYPE_TO_ICON,
+  getToastTypeStyles,
+} from '#/view/com/util/Toast.style'
 import * as Toast from '#/view/com/util/Toast'
-import * as ToastHelpers from '#/view/com/util/ToastHelpers'
+
+function ToastPreview({message, type}: {message: string; type: ToastType}) {
+  const t = useTheme()
+  const toastStyles = getToastTypeStyles(t)
+  const colors = toastStyles[type]
+  const IconComponent = TOAST_TYPE_TO_ICON[type]
+
+  return (
+    <Pressable
+      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>
+  )
+}
 
 export function Toasts() {
   return (
     <View style={[a.gap_md]}>
-      <H1>Toasts</H1>
+      <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_sm]}>
-        <Button
-          variant="solid"
-          color="primary"
-          size="small"
-          label="Show success toast"
-          onPress={() =>
-            Toast.show('Operation completed successfully!', 'success')
-          }>
-          <ButtonText>Success!</ButtonText>
-        </Button>
+        <View style={[a.gap_xs]}>
+          <ToastPreview
+            message="Operation completed successfully!"
+            type="success"
+          />
+        </View>
 
-        <Button
-          variant="solid"
-          color="negative"
-          size="small"
-          label="Show error toast"
-          onPress={() => Toast.show('Something went wrong!', 'error')}>
-          <ButtonText>Error</ButtonText>
-        </Button>
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Something went wrong!" type="error" />
+        </View>
 
-        <Button
-          variant="solid"
-          color="secondary"
-          size="small"
-          label="Show warning toast"
-          onPress={() => Toast.show('Please check your input', 'warning')}>
-          <ButtonText>Warning</ButtonText>
-        </Button>
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Please check your input" type="warning" />
+        </View>
 
-        <Button
-          variant="solid"
-          color="secondary"
-          size="small"
-          label="Show info toast"
-          onPress={() => Toast.show("Here's some helpful information", 'info')}>
-          <ButtonText>Info </ButtonText>
-        </Button>
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Here's some helpful information" type="info" />
+        </View>
 
-        <Button
-          variant="outline"
-          color="secondary"
-          size="small"
-          label="Show toast with long message"
-          onPress={() =>
-            Toast.show(
-              'This is a longer message to test how the toast handles multiple lines of text content.',
-              'info',
-            )
-          }>
-          <ButtonText>Long Message </ButtonText>
-        </Button>
+        <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>
       </View>
     </View>
   )