diff options
author | Ana <anastasiyauraleva@gmail.com> | 2025-07-29 18:15:32 -0700 |
---|---|---|
committer | Ana <anastasiyauraleva@gmail.com> | 2025-07-29 22:12:36 -0700 |
commit | 34ea6e8f3499eeeb1013dfbf7c4dcd3bdcf149a3 (patch) | |
tree | 462207a6246da0dc24cce08ab8c3fba2e7c2c951 /src/view | |
parent | 890dee3eef38700c8ebf850c37e1bf79c54aec2e (diff) | |
download | voidsky-34ea6e8f3499eeeb1013dfbf7c4dcd3bdcf149a3.tar.zst |
update: toast styles that reuse consistent style
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/util/Toast.tsx | 182 | ||||
-rw-r--r-- | src/view/com/util/Toast.web.tsx | 148 | ||||
-rw-r--r-- | src/view/screens/Storybook/Toasts.tsx | 139 |
3 files changed, 216 insertions, 253 deletions
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index fc9bdf672..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,53 +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 {isWeb} from '#/platform/detection' 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 type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' - -const TOAST_TYPE_TO_ICON: Record<ToastType, FontAwesomeProps['icon']> = { - default: 'check', - success: 'check', - error: 'exclamation', - warning: 'circle-exclamation', - info: 'info', -} - -export function show( - message: string, - type: ToastType | FontAwesomeProps['icon'] = 'default', -) { +export function show(message: string, type: ToastType = 'default') { if (process.env.NODE_ENV === 'test') { return } - const icon = - typeof type === 'string' && type in TOAST_TYPE_TO_ICON - ? TOAST_TYPE_TO_ICON[type as ToastType] - : (type as FontAwesomeProps['icon']) - 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() @@ -72,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 @@ -169,103 +156,58 @@ function Toast({ } }) - // Web-specific styles for better compatibility - const webContainerStyle = isWeb - ? { - position: 'absolute' as const, - top: topOffset, - left: 16, - right: 16, - zIndex: 9999, - pointerEvents: 'auto' as const, - } - : {} - - const webToastStyle = isWeb - ? { - backgroundColor: - t.name === 'dark' ? t.palette.contrast_25 : t.palette.white, - shadowColor: '#000', - shadowOffset: {width: 0, height: 10}, - shadowOpacity: 0.1, - shadowRadius: 15, - elevation: 10, - borderColor: t.palette.contrast_300, - borderWidth: 1, - borderRadius: 8, - minHeight: 60, - } - : {} - return ( <GestureHandlerRootView - style={[ - a.absolute, - {top: topOffset, left: 16, right: 16}, - isWeb && webContainerStyle, - ]} + style={[a.absolute, {top: topOffset, left: 16, right: 16}]} 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, - isWeb - ? webToastStyle - : [ - t.name === 'dark' ? t.atoms.bg_contrast_25 : t.atoms.bg, - a.shadow_lg, - t.atoms.border_contrast_medium, - a.rounded_sm, - a.border, - ], - !isWeb && 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, - a.justify_center, - ]}> - <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 949dce7ef..331a8b539 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -4,28 +4,19 @@ 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' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' - -const DURATION = 60000 - -export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' - -const TOAST_TYPE_TO_ICON: Record<ToastType, FontAwesomeProps['icon']> = { - default: 'check', - success: 'check', - error: 'exclamation', - warning: 'circle-exclamation', - info: 'info', -} + 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 @@ -40,50 +31,45 @@ 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]) - const t = useTheme() + 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 TOAST_TYPE_TO_STYLES = { - default: { - backgroundColor: t.atoms.text_contrast_low.color, - borderColor: t.atoms.border_contrast_medium.borderColor, - iconColor: '#fff', - textColor: '#fff', - }, - success: { - backgroundColor: '#059669', - borderColor: '#047857', - iconColor: '#fff', - textColor: '#fff', - }, - error: { - backgroundColor: t.palette.negative_100, - borderColor: t.palette.negative_400, - iconColor: t.palette.negative_600, - textColor: t.palette.negative_600, - }, - warning: { - backgroundColor: t.palette.negative_500, - borderColor: t.palette.negative_600, - iconColor: '#fff', - textColor: '#fff', - }, - info: { - backgroundColor: t.atoms.text_contrast_low.color, - borderColor: t.atoms.border_contrast_medium.borderColor, - iconColor: '#fff', - textColor: '#fff', - }, - } + const t = useTheme() + const toastTypeStyles = getToastTypeStyles(t) const toastStyles = activeToast - ? TOAST_TYPE_TO_STYLES[activeToast.type] - : TOAST_TYPE_TO_STYLES.default + ? toastTypeStyles[activeToast.type] + : toastTypeStyles.default + + const IconComponent = activeToast + ? TOAST_TYPE_TO_ICON[activeToast.type] + : TOAST_TYPE_TO_ICON.default + + const animationStyles = getToastWebAnimationStyles() return ( <> @@ -94,18 +80,24 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { { backgroundColor: toastStyles.backgroundColor, borderColor: toastStyles.borderColor, + ...(isExiting + ? animationStyles.exiting + : animationStyles.entering), }, ]}> - <FontAwesomeIcon - icon={activeToast.icon} - size={20} - style={ - [ - styles.icon, - {color: toastStyles.iconColor}, - ] as FontAwesomeIconStyle - } - /> + <View + style={[ + styles.iconContainer, + { + backgroundColor: 'transparent', + }, + ]}> + <IconComponent + fill={toastStyles.iconColor} + size="sm" + style={styles.icon} + /> + </View> <Text style={[ styles.text, @@ -132,22 +124,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { // methods // = -export function show( - text: string, - type: ToastType | FontAwesomeProps['icon'] = 'default', -) { +export function show(text: string, type: ToastType = 'default') { if (toastTimeout) { clearTimeout(toastTimeout) } - // Determine if type is a semantic type or direct icon - const isSemanticType = typeof type === 'string' && type in TOAST_TYPE_TO_ICON - const icon = isSemanticType - ? TOAST_TYPE_TO_ICON[type as ToastType] - : (type as FontAwesomeProps['icon']) - const toastType = isSemanticType ? (type as ToastType) : 'default' - - globalSetActiveToast?.({text, icon, type: toastType}) + globalSetActiveToast?.({text, type}) toastTimeout = setTimeout(() => { globalSetActiveToast?.(undefined) }, DURATION) @@ -161,10 +143,10 @@ const styles = StyleSheet.create({ bottom: 20, // @ts-ignore web only width: 'calc(100% - 40px)', - maxWidth: 350, + maxWidth: 380, padding: 20, flexDirection: 'row', - alignItems: 'center', + alignItems: 'flex-start', borderRadius: 10, borderWidth: 1, }, @@ -175,6 +157,14 @@ const styles = StyleSheet.create({ bottom: 0, right: 0, }, + iconContainer: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, icon: { flexShrink: 0, }, diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 714afdfbd..5197ec2f4 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -1,68 +1,99 @@ -import {View} from 'react-native' +import {View, Pressable} from 'react-native' -import {atoms as a} from '#/alf' -import {Button, ButtonText} from '#/components/Button' -import {H1} from '#/components/Typography' +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' -import * as ToastHelpers from '#/view/com/util/ToastHelpers' + +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>Toasts</H1> + <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_sm]}> - <Button - variant="solid" - color="primary" - size="small" - label="Show success toast" - onPress={() => - Toast.show('Operation completed successfully!', 'success') - }> - <ButtonText>Success!</ButtonText> - </Button> + <View style={[a.gap_xs]}> + <ToastPreview + message="Operation completed successfully!" + type="success" + /> + </View> - <Button - variant="solid" - color="negative" - size="small" - label="Show error toast" - onPress={() => Toast.show('Something went wrong!', 'error')}> - <ButtonText>Error</ButtonText> - </Button> + <View style={[a.gap_xs]}> + <ToastPreview message="Something went wrong!" type="error" /> + </View> - <Button - variant="solid" - color="secondary" - size="small" - label="Show warning toast" - onPress={() => Toast.show('Please check your input', 'warning')}> - <ButtonText>Warning</ButtonText> - </Button> + <View style={[a.gap_xs]}> + <ToastPreview message="Please check your input" type="warning" /> + </View> - <Button - variant="solid" - color="secondary" - size="small" - label="Show info toast" - onPress={() => Toast.show("Here's some helpful information", 'info')}> - <ButtonText>Info </ButtonText> - </Button> + <View style={[a.gap_xs]}> + <ToastPreview message="Here's some helpful information" type="info" /> + </View> - <Button - variant="outline" - color="secondary" - size="small" - label="Show toast with long message" - onPress={() => - Toast.show( - 'This is a longer message to test how the toast handles multiple lines of text content.', - 'info', - ) - }> - <ButtonText>Long Message </ButtonText> - </Button> + <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> ) |