about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/view/com/util/Toast.tsx124
-rw-r--r--src/view/com/util/Toast.web.tsx114
-rw-r--r--src/view/screens/Storybook/Toasts.tsx100
-rw-r--r--src/view/screens/Storybook/index.tsx2
4 files changed, 256 insertions, 84 deletions
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 56c6780ad..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,37 +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 {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 function show(
-  message: string,
-  icon: FontAwesomeProps['icon'] = 'check',
-) {
+export function show(message: string, type: ToastType = 'default') {
   if (process.env.NODE_ENV === 'test') {
     return
   }
+
   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()
@@ -56,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
@@ -159,55 +162,52 @@ function Toast({
       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,
-              t.name === 'dark' ? t.atoms.bg_contrast_25 : t.atoms.bg,
-              a.shadow_lg,
-              t.atoms.border_contrast_medium,
-              a.rounded_sm,
-              a.border,
-              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]}>
-                  <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 d3b7bda33..331a8b539 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -4,17 +4,20 @@
 
 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'
+  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
 
@@ -28,21 +31,82 @@ 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])
+
+  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}>
-          <FontAwesomeIcon
-            icon={activeToast.icon}
-            size={20}
-            style={styles.icon as FontAwesomeIconStyle}
-          />
-          <Text style={styles.text}>{activeToast.text}</Text>
+        <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"
@@ -60,11 +124,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
 // methods
 // =
 
-export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
+export function show(text: string, type: ToastType = 'default') {
   if (toastTimeout) {
     clearTimeout(toastTimeout)
   }
-  globalSetActiveToast?.({text, icon})
+
+  globalSetActiveToast?.({text, type})
   toastTimeout = setTimeout(() => {
     globalSetActiveToast?.(undefined)
   }, DURATION)
@@ -78,12 +143,12 @@ const styles = StyleSheet.create({
     bottom: 20,
     // @ts-ignore web only
     width: 'calc(100% - 40px)',
-    maxWidth: 350,
+    maxWidth: 380,
     padding: 20,
     flexDirection: 'row',
-    alignItems: 'center',
-    backgroundColor: '#000c',
+    alignItems: 'flex-start',
     borderRadius: 10,
+    borderWidth: 1,
   },
   dismissBackdrop: {
     position: 'absolute',
@@ -92,13 +157,18 @@ const styles = StyleSheet.create({
     bottom: 0,
     right: 0,
   },
+  iconContainer: {
+    width: 32,
+    height: 32,
+    borderRadius: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    flexShrink: 0,
+  },
   icon: {
-    color: '#fff',
     flexShrink: 0,
   },
   text: {
-    color: '#fff',
-    fontSize: 18,
     marginLeft: 10,
   },
 })
diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx
new file mode 100644
index 000000000..5197ec2f4
--- /dev/null
+++ b/src/view/screens/Storybook/Toasts.tsx
@@ -0,0 +1,100 @@
+import {View, Pressable} from 'react-native'
+
+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'
+
+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>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"
+          />
+        </View>
+
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Something went wrong!" type="error" />
+        </View>
+
+        <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>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index a6c2ecdde..afcc1c4e7 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -20,6 +20,7 @@ import {Settings} from './Settings'
 import {Shadows} from './Shadows'
 import {Spacing} from './Spacing'
 import {Theming} from './Theming'
+import {Toasts} from './Toasts'
 import {Typography} from './Typography'
 
 export function Storybook() {
@@ -122,6 +123,7 @@ function StorybookInner() {
             <Breakpoints />
             <Dialogs />
             <Admonitions />
+            <Toasts />
             <Settings />
 
             <Button