about summary refs log tree commit diff
path: root/src/components/Toast
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/Toast')
-rw-r--r--src/components/Toast/Toast.tsx284
-rw-r--r--src/components/Toast/index.e2e.tsx8
-rw-r--r--src/components/Toast/index.tsx55
-rw-r--r--src/components/Toast/index.web.tsx54
-rw-r--r--src/components/Toast/sonner/index.ts3
-rw-r--r--src/components/Toast/sonner/index.web.ts3
6 files changed, 317 insertions, 90 deletions
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx
index 4d782597d..ac5bc4889 100644
--- a/src/components/Toast/Toast.tsx
+++ b/src/components/Toast/Toast.tsx
@@ -1,22 +1,20 @@
 import {createContext, useContext, useMemo} from 'react'
-import {View} from 'react-native'
+import {type GestureResponderEvent, View} from 'react-native'
 
 import {atoms as a, select, useAlf, useTheme} from '#/alf'
+import {
+  Button,
+  type ButtonProps,
+  type UninheritableButtonProps,
+} from '#/components/Button'
+import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
+import {type Props as SVGIconProps} from '#/components/icons/common'
 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {dismiss} from '#/components/Toast/sonner'
 import {type ToastType} from '#/components/Toast/types'
-import {Text} from '#/components/Typography'
-import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '../icons/CircleCheck'
-
-type ContextType = {
-  type: ToastType
-}
-
-export type ToastComponentProps = {
-  type?: ToastType
-  content: React.ReactNode
-}
+import {Text as BaseText} from '#/components/Typography'
 
 export const ICONS = {
   default: CircleCheck,
@@ -26,81 +24,225 @@ export const ICONS = {
   info: CircleInfo,
 }
 
-const Context = createContext<ContextType>({
+const ToastConfigContext = createContext<{
+  id: string
+  type: ToastType
+}>({
+  id: '',
   type: 'default',
 })
-Context.displayName = 'ToastContext'
+ToastConfigContext.displayName = 'ToastConfigContext'
 
-export function Toast({type = 'default', content}: ToastComponentProps) {
-  const {fonts} = useAlf()
+export function ToastConfigProvider({
+  children,
+  id,
+  type,
+}: {
+  children: React.ReactNode
+  id: string
+  type: ToastType
+}) {
+  return (
+    <ToastConfigContext.Provider
+      value={useMemo(() => ({id, type}), [id, type])}>
+      {children}
+    </ToastConfigContext.Provider>
+  )
+}
+
+export function Outer({children}: {children: React.ReactNode}) {
   const t = useTheme()
+  const {type} = useContext(ToastConfigContext)
   const styles = useToastStyles({type})
-  const Icon = ICONS[type]
-  /**
-   * Vibes-based number, adjusts `top` of `View` that wraps the text to
-   * compensate for different type sizes and keep the first line of text
-   * aligned with the icon. - esb
-   */
-  const fontScaleCompensation = useMemo(
-    () => parseInt(fonts.scale) * -1 * 0.65,
-    [fonts.scale],
-  )
 
   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,
-            {
-              top: fontScaleCompensation,
-            },
-          ]}>
-          {typeof content === 'string' ? (
-            <ToastText>{content}</ToastText>
-          ) : (
-            content
-          )}
-        </View>
-      </View>
-    </Context.Provider>
+    <View
+      style={[
+        a.flex_1,
+        a.p_lg,
+        a.rounded_md,
+        a.border,
+        a.flex_row,
+        a.gap_sm,
+        t.atoms.shadow_sm,
+        {
+          paddingVertical: 14, // 16 seems too big
+          backgroundColor: styles.backgroundColor,
+          borderColor: styles.borderColor,
+        },
+      ]}>
+      {children}
+    </View>
   )
 }
 
-export function ToastText({children}: {children: React.ReactNode}) {
-  const {type} = useContext(Context)
+export function Icon({icon}: {icon?: React.ComponentType<SVGIconProps>}) {
+  const {type} = useContext(ToastConfigContext)
+  const styles = useToastStyles({type})
+  const IconComponent = icon || ICONS[type]
+  return <IconComponent size="md" fill={styles.iconColor} />
+}
+
+export function Text({children}: {children: React.ReactNode}) {
+  const {type} = useContext(ToastConfigContext)
   const {textColor} = useToastStyles({type})
+  const {fontScaleCompensation} = useToastFontScaleCompensation()
   return (
-    <Text
-      selectable={false}
+    <View
       style={[
-        a.text_md,
-        a.font_medium,
-        a.leading_snug,
-        a.pointer_events_none,
+        a.flex_1,
+        a.pr_lg,
         {
-          color: textColor,
+          top: fontScaleCompensation,
         },
       ]}>
-      {children}
-    </Text>
+      <BaseText
+        selectable={false}
+        style={[
+          a.text_md,
+          a.font_medium,
+          a.leading_snug,
+          a.pointer_events_none,
+          {
+            color: textColor,
+          },
+        ]}>
+        {children}
+      </BaseText>
+    </View>
+  )
+}
+
+export function Action(
+  props: Omit<ButtonProps, UninheritableButtonProps | 'children'> & {
+    children: React.ReactNode
+  },
+) {
+  const t = useTheme()
+  const {fontScaleCompensation} = useToastFontScaleCompensation()
+  const {type} = useContext(ToastConfigContext)
+  const {id} = useContext(ToastConfigContext)
+  const styles = useMemo(() => {
+    const base = {
+      base: {
+        textColor: t.palette.contrast_600,
+        backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+      },
+      interacted: {
+        textColor: t.atoms.text.color,
+        backgroundColor: t.atoms.bg_contrast_50.backgroundColor,
+      },
+    }
+    return {
+      default: base,
+      success: {
+        base: {
+          textColor: select(t.name, {
+            light: t.palette.primary_800,
+            dim: t.palette.primary_900,
+            dark: t.palette.primary_900,
+          }),
+          backgroundColor: t.palette.primary_25,
+        },
+        interacted: {
+          textColor: select(t.name, {
+            light: t.palette.primary_900,
+            dim: t.palette.primary_975,
+            dark: t.palette.primary_975,
+          }),
+          backgroundColor: t.palette.primary_50,
+        },
+      },
+      error: {
+        base: {
+          textColor: select(t.name, {
+            light: t.palette.negative_700,
+            dim: t.palette.negative_900,
+            dark: t.palette.negative_900,
+          }),
+          backgroundColor: t.palette.negative_25,
+        },
+        interacted: {
+          textColor: select(t.name, {
+            light: t.palette.negative_900,
+            dim: t.palette.negative_975,
+            dark: t.palette.negative_975,
+          }),
+          backgroundColor: t.palette.negative_50,
+        },
+      },
+      warning: base,
+      info: base,
+    }[type]
+  }, [t, type])
+
+  const onPress = (e: GestureResponderEvent) => {
+    console.log('Toast Action pressed, dismissing toast', id)
+    dismiss(id)
+    props.onPress?.(e)
+  }
+
+  return (
+    <View style={{top: fontScaleCompensation}}>
+      <Button {...props} onPress={onPress}>
+        {s => {
+          const interacted = s.pressed || s.hovered || s.focused
+          return (
+            <>
+              <View
+                style={[
+                  a.absolute,
+                  a.curve_continuous,
+                  {
+                    // tiny button styles
+                    top: -5,
+                    bottom: -5,
+                    left: -9,
+                    right: -9,
+                    borderRadius: 6,
+                    backgroundColor: interacted
+                      ? styles.interacted.backgroundColor
+                      : styles.base.backgroundColor,
+                  },
+                ]}
+              />
+              <BaseText
+                style={[
+                  a.text_md,
+                  a.font_medium,
+                  a.leading_snug,
+                  {
+                    color: interacted
+                      ? styles.interacted.textColor
+                      : styles.base.textColor,
+                  },
+                ]}>
+                {props.children}
+              </BaseText>
+            </>
+          )
+        }}
+      </Button>
+    </View>
+  )
+}
+
+/**
+ * Vibes-based number, provides t `top` value to wrap the text to compensate
+ * for different type sizes and keep the first line of text aligned with the
+ * icon. - esb
+ */
+function useToastFontScaleCompensation() {
+  const {fonts} = useAlf()
+  const fontScaleCompensation = useMemo(
+    () => parseInt(fonts.scale) * -1 * 0.65,
+    [fonts.scale],
+  )
+  return useMemo(
+    () => ({
+      fontScaleCompensation,
+    }),
+    [fontScaleCompensation],
   )
 }
 
diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx
index 357bd8dda..a4056323d 100644
--- a/src/components/Toast/index.e2e.tsx
+++ b/src/components/Toast/index.e2e.tsx
@@ -1,3 +1,11 @@
+export const DURATION = 0
+
+export const Action = () => null
+export const Icon = () => null
+export const Outer = () => null
+export const Text = () => null
+export const ToastConfigProvider = () => null
+
 export function ToastOutlet() {
   return null
 }
diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx
index 286d414a1..d70a8ad16 100644
--- a/src/components/Toast/index.tsx
+++ b/src/components/Toast/index.tsx
@@ -1,15 +1,21 @@
+import React from 'react'
 import {View} from 'react-native'
+import {nanoid} from 'nanoid/non-secure'
 import {toast as sonner, Toaster} from 'sonner-native'
 
 import {atoms as a} from '#/alf'
 import {DURATION} from '#/components/Toast/const'
 import {
-  Toast as BaseToast,
-  type ToastComponentProps,
+  Icon as ToastIcon,
+  Outer as BaseOuter,
+  Text as ToastText,
+  ToastConfigProvider,
 } from '#/components/Toast/Toast'
 import {type BaseToastOptions} from '#/components/Toast/types'
 
 export {DURATION} from '#/components/Toast/const'
+export {Action, Icon, Text, ToastConfigProvider} from '#/components/Toast/Toast'
+export {type ToastType} from '#/components/Toast/types'
 
 /**
  * Toasts are rendered in a global outlet, which is placed at the top of the
@@ -19,13 +25,10 @@ export function ToastOutlet() {
   return <Toaster pauseWhenPageIsHidden gap={a.gap_sm.gap} />
 }
 
-/**
- * The toast UI component
- */
-export function Toast({type, content}: ToastComponentProps) {
+export function Outer({children}: {children: React.ReactNode}) {
   return (
     <View style={[a.px_xl, a.w_full]}>
-      <BaseToast content={content} type={type} />
+      <BaseOuter>{children}</BaseOuter>
     </View>
   )
 }
@@ -40,10 +43,38 @@ export const api = sonner
  */
 export function show(
   content: React.ReactNode,
-  {type, ...options}: BaseToastOptions = {},
+  {type = 'default', ...options}: BaseToastOptions = {},
 ) {
-  sonner.custom(<Toast content={content} type={type} />, {
-    ...options,
-    duration: options?.duration ?? DURATION,
-  })
+  const id = nanoid()
+
+  if (typeof content === 'string') {
+    sonner.custom(
+      <ToastConfigProvider id={id} type={type}>
+        <Outer>
+          <ToastIcon />
+          <ToastText>{content}</ToastText>
+        </Outer>
+      </ToastConfigProvider>,
+      {
+        ...options,
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else if (React.isValidElement(content)) {
+    sonner.custom(
+      <ToastConfigProvider id={id} type={type}>
+        {content}
+      </ToastConfigProvider>,
+      {
+        ...options,
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else {
+    throw new Error(
+      `Toast can be a string or a React element, got ${typeof content}`,
+    )
+  }
 }
diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx
index 857ed7b39..8b2028db9 100644
--- a/src/components/Toast/index.web.tsx
+++ b/src/components/Toast/index.web.tsx
@@ -1,10 +1,21 @@
+import React from 'react'
+import {nanoid} from 'nanoid/non-secure'
 import {toast as sonner, Toaster} from 'sonner'
 
 import {atoms as a} from '#/alf'
 import {DURATION} from '#/components/Toast/const'
-import {Toast} from '#/components/Toast/Toast'
+import {
+  Icon as ToastIcon,
+  Outer as ToastOuter,
+  Text as ToastText,
+  ToastConfigProvider,
+} from '#/components/Toast/Toast'
 import {type BaseToastOptions} from '#/components/Toast/types'
 
+export {DURATION} from '#/components/Toast/const'
+export * from '#/components/Toast/Toast'
+export {type ToastType} from '#/components/Toast/types'
+
 /**
  * Toasts are rendered in a global outlet, which is placed at the top of the
  * component tree.
@@ -30,11 +41,40 @@ export const api = sonner
  */
 export function show(
   content: React.ReactNode,
-  {type, ...options}: BaseToastOptions = {},
+  {type = 'default', ...options}: BaseToastOptions = {},
 ) {
-  sonner(<Toast content={content} type={type} />, {
-    unstyled: true, // required on web
-    ...options,
-    duration: options?.duration ?? DURATION,
-  })
+  const id = nanoid()
+
+  if (typeof content === 'string') {
+    sonner(
+      <ToastConfigProvider id={id} type={type}>
+        <ToastOuter>
+          <ToastIcon />
+          <ToastText>{content}</ToastText>
+        </ToastOuter>
+      </ToastConfigProvider>,
+      {
+        ...options,
+        unstyled: true, // required on web
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else if (React.isValidElement(content)) {
+    sonner(
+      <ToastConfigProvider id={id} type={type}>
+        {content}
+      </ToastConfigProvider>,
+      {
+        ...options,
+        unstyled: true, // required on web
+        id,
+        duration: options?.duration ?? DURATION,
+      },
+    )
+  } else {
+    throw new Error(
+      `Toast can be a string or a React element, got ${typeof content}`,
+    )
+  }
 }
diff --git a/src/components/Toast/sonner/index.ts b/src/components/Toast/sonner/index.ts
new file mode 100644
index 000000000..35f8552c7
--- /dev/null
+++ b/src/components/Toast/sonner/index.ts
@@ -0,0 +1,3 @@
+import {toast} from 'sonner-native'
+
+export const dismiss = toast.dismiss
diff --git a/src/components/Toast/sonner/index.web.ts b/src/components/Toast/sonner/index.web.ts
new file mode 100644
index 000000000..12c4741d6
--- /dev/null
+++ b/src/components/Toast/sonner/index.web.ts
@@ -0,0 +1,3 @@
+import {toast} from 'sonner'
+
+export const dismiss = toast.dismiss