about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/util/Link.tsx22
-rw-r--r--src/view/com/util/Toast.style.tsx201
-rw-r--r--src/view/com/util/Toast.tsx137
-rw-r--r--src/view/com/util/Toast.web.tsx118
-rw-r--r--src/view/screens/Storybook/Toasts.tsx102
-rw-r--r--src/view/screens/Storybook/index.tsx2
6 files changed, 488 insertions, 94 deletions
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 6a931d9a4..496b77182 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -101,13 +101,9 @@ export const Link = memo(function Link({
     {name: 'activate', label: title},
   ]
 
-  const dataSet = useMemo(() => {
-    const ds = {...dataSetProp}
-    if (anchorNoUnderline) {
-      ds.noUnderline = 1
-    }
-    return ds
-  }, [dataSetProp, anchorNoUnderline])
+  const dataSet = anchorNoUnderline
+    ? {...dataSetProp, noUnderline: 1}
+    : dataSetProp
 
   if (noFeedback) {
     return (
@@ -125,6 +121,8 @@ export const Link = memo(function Link({
               onAccessibilityAction?.(e)
             }
           }}
+          // @ts-ignore web only -sfn
+          dataSet={dataSet}
           {...props}
           android_ripple={{
             color: t.atoms.bg_contrast_25.backgroundColor,
@@ -198,13 +196,9 @@ export const TextLink = memo(function TextLink({
     console.error('Unable to detect mismatching label')
   }
 
-  const dataSet = useMemo(() => {
-    const ds = {...dataSetProp}
-    if (anchorNoUnderline) {
-      ds.noUnderline = 1
-    }
-    return ds
-  }, [dataSetProp, anchorNoUnderline])
+  const dataSet = anchorNoUnderline
+    ? {...dataSetProp, noUnderline: 1}
+    : dataSetProp
 
   const onPress = useCallback(
     (e?: Event) => {
diff --git a/src/view/com/util/Toast.style.tsx b/src/view/com/util/Toast.style.tsx
new file mode 100644
index 000000000..3869e6890
--- /dev/null
+++ b/src/view/com/util/Toast.style.tsx
@@ -0,0 +1,201 @@
+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 56c6780ad..54ef7042d 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,55 @@ 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 {
+  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
 
+// 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'`
+ */
+export function show(message: string, type?: LegacyToastType): void
 export function show(
   message: string,
-  icon: FontAwesomeProps['icon'] = 'check',
-) {
+  type: ToastType | LegacyToastType = 'default',
+): void {
   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={convertLegacyToastType(type)}
+        destroy={() => item.destroy()}
+      />
+    ),
   )
 }
 
 function Toast({
   message,
-  icon,
+  type,
   destroy,
 }: {
   message: string
-  icon: FontAwesomeProps['icon']
+  type: ToastType
   destroy: () => void
 }) {
   const t = useTheme()
@@ -56,6 +74,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 +181,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..6b99b30bf 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -4,17 +4,23 @@
 
 import {useEffect, useState} from 'react'
 import {Pressable, StyleSheet, Text, View} from 'react-native'
+
 import {
-  FontAwesomeIcon,
-  type FontAwesomeIconStyle,
-  type Props as FontAwesomeProps,
-} from '@fortawesome/react-native-fontawesome'
+  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
-  icon: FontAwesomeProps['icon']
+  type: ToastType
 }
 type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
 
@@ -28,21 +34,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 +127,15 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
 // methods
 // =
 
-export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
+export function show(
+  text: string,
+  type: ToastType | LegacyToastType = 'default',
+) {
   if (toastTimeout) {
     clearTimeout(toastTimeout)
   }
-  globalSetActiveToast?.({text, icon})
+
+  globalSetActiveToast?.({text, type: convertLegacyToastType(type)})
   toastTimeout = setTimeout(() => {
     globalSetActiveToast?.(undefined)
   }, DURATION)
@@ -78,12 +149,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',
     borderRadius: 10,
+    borderWidth: 1,
   },
   dismissBackdrop: {
     position: 'absolute',
@@ -92,13 +163,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..4c17f1c33
--- /dev/null
+++ b/src/view/screens/Storybook/Toasts.tsx
@@ -0,0 +1,102 @@
+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>
+  )
+}
+
+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