From 8ec20026c042e1f26224ef2967dad6f0386e1eca Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Aug 2025 11:20:04 -0500 Subject: Yeah toast (#8878) * Split out into macro component * Add Action component * Add fallback * add button to view post after sending * Dismiss toast when clicking action button --------- Co-authored-by: Samuel Newman --- src/components/Toast/Toast.tsx | 269 +++++++++++++++++++++++++------ src/components/Toast/index.tsx | 57 ++++++- src/components/Toast/index.web.tsx | 42 ++++- src/components/Toast/sonner/index.ts | 3 + src/components/Toast/sonner/index.web.ts | 3 + 5 files changed, 311 insertions(+), 63 deletions(-) create mode 100644 src/components/Toast/sonner/index.ts create mode 100644 src/components/Toast/sonner/index.web.ts (limited to 'src/components') diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx index 4d782597d..53d5e5115 100644 --- a/src/components/Toast/Toast.tsx +++ b/src/components/Toast/Toast.tsx @@ -1,21 +1,32 @@ 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' +import {Text as BaseText} from '#/components/Typography' -type ContextType = { +type ToastConfigContextType = { + id: string +} + +type ToastThemeContextType = { type: ToastType } export type ToastComponentProps = { type?: ToastType - content: React.ReactNode + content: string } export const ICONS = { @@ -26,81 +37,239 @@ export const ICONS = { info: CircleInfo, } -const Context = createContext({ +const ToastConfigContext = createContext({ + id: '', +}) +ToastConfigContext.displayName = 'ToastConfigContext' + +export function ToastConfigProvider({ + children, + id, +}: { + children: React.ReactNode + id: string +}) { + return ( + ({id}), [id])}> + {children} + + ) +} + +const ToastThemeContext = createContext({ type: 'default', }) -Context.displayName = 'ToastContext' +ToastThemeContext.displayName = 'ToastThemeContext' -export function Toast({type = 'default', content}: ToastComponentProps) { - const {fonts} = useAlf() +export function Default({type = 'default', content}: ToastComponentProps) { + return ( + + + {content} + + ) +} + +export function Outer({ + children, + type = 'default', +}: { + children: React.ReactNode + type?: ToastType +}) { const t = useTheme() 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 ( - ({type}), [type])}> + ({type}), [type])}> - - - - {typeof content === 'string' ? ( - {content} - ) : ( - content - )} - + {children} - + ) } -export function ToastText({children}: {children: React.ReactNode}) { - const {type} = useContext(Context) +export function Icon({icon}: {icon?: React.ComponentType}) { + const {type} = useContext(ToastThemeContext) + const styles = useToastStyles({type}) + const IconComponent = icon || ICONS[type] + return +} + +export function Text({children}: {children: React.ReactNode}) { + const {type} = useContext(ToastThemeContext) const {textColor} = useToastStyles({type}) + const {fontScaleCompensation} = useToastFontScaleCompensation() return ( - - {children} - + + {children} + + + ) +} + +export function Action( + props: Omit & { + children: string + }, +) { + const t = useTheme() + const {fontScaleCompensation} = useToastFontScaleCompensation() + const {type} = useContext(ToastThemeContext) + 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 ( + + + + ) +} + +/** + * 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.tsx b/src/components/Toast/index.tsx index 286d414a1..16d62afcd 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, + Default as DefaultToast, + Outer as BaseOuter, type ToastComponentProps, + ToastConfigProvider, } from '#/components/Toast/Toast' import {type BaseToastOptions} from '#/components/Toast/types' export {DURATION} from '#/components/Toast/const' +export {Action, Icon, Text} 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 @@ -22,10 +28,24 @@ export function ToastOutlet() { /** * The toast UI component */ -export function Toast({type, content}: ToastComponentProps) { +export function Default({type, content}: ToastComponentProps) { return ( - + + + ) +} + +export function Outer({ + children, + type = 'default', +}: { + children: React.ReactNode + type?: ToastComponentProps['type'] +}) { + return ( + + {children} ) } @@ -42,8 +62,31 @@ export function show( content: React.ReactNode, {type, ...options}: BaseToastOptions = {}, ) { - sonner.custom(, { - ...options, - duration: options?.duration ?? DURATION, - }) + const id = nanoid() + + if (typeof content === 'string') { + sonner.custom( + + + , + { + ...options, + id, + duration: options?.duration ?? DURATION, + }, + ) + } else if (React.isValidElement(content)) { + sonner.custom( + {content}, + { + ...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..c4db20dca 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -1,10 +1,19 @@ +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 { + Default as DefaultToast, + 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. @@ -32,9 +41,30 @@ export function show( content: React.ReactNode, {type, ...options}: BaseToastOptions = {}, ) { - sonner(, { - unstyled: true, // required on web - ...options, - duration: options?.duration ?? DURATION, - }) + const id = nanoid() + + if (typeof content === 'string') { + sonner( + + + , + { + ...options, + unstyled: true, // required on web + id, + duration: options?.duration ?? DURATION, + }, + ) + } else if (React.isValidElement(content)) { + sonner({content}, { + ...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 -- cgit 1.4.1