diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/util/Link.tsx | 22 | ||||
-rw-r--r-- | src/view/com/util/Toast.style.tsx | 201 | ||||
-rw-r--r-- | src/view/com/util/Toast.tsx | 137 | ||||
-rw-r--r-- | src/view/com/util/Toast.web.tsx | 118 | ||||
-rw-r--r-- | src/view/screens/Storybook/Toasts.tsx | 102 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 2 |
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 |