Bluesky app fork with some witchin' additions 💫

Merge pull request #8734 from internet-development/@APiligrim/update-toast

[APP-1345] Update Toast UI on the web and add to storybook

authored by

jim and committed by
GitHub
658dd448 0a00e02c

+256 -84
+62 -62
src/view/com/util/Toast.tsx
··· 6 6 GestureHandlerRootView, 7 7 } from 'react-native-gesture-handler' 8 8 import Animated, { 9 - FadeInUp, 10 - FadeOutUp, 9 + FadeIn, 10 + FadeOut, 11 11 runOnJS, 12 12 useAnimatedReaction, 13 13 useAnimatedStyle, ··· 17 17 } from 'react-native-reanimated' 18 18 import RootSiblings from 'react-native-root-siblings' 19 19 import {useSafeAreaInsets} from 'react-native-safe-area-context' 20 - import { 21 - FontAwesomeIcon, 22 - type Props as FontAwesomeProps, 23 - } from '@fortawesome/react-native-fontawesome' 24 - 25 20 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 26 21 import {atoms as a, useTheme} from '#/alf' 27 22 import {Text} from '#/components/Typography' 23 + import { 24 + type ToastType, 25 + TOAST_TYPE_TO_ICON, 26 + getToastTypeStyles, 27 + TOAST_ANIMATION_CONFIG, 28 + } from './Toast.style' 28 29 29 30 const TIMEOUT = 2e3 30 31 31 - export function show( 32 - message: string, 33 - icon: FontAwesomeProps['icon'] = 'check', 34 - ) { 32 + export function show(message: string, type: ToastType = 'default') { 35 33 if (process.env.NODE_ENV === 'test') { 36 34 return 37 35 } 36 + 38 37 AccessibilityInfo.announceForAccessibility(message) 39 38 const item = new RootSiblings( 40 - <Toast message={message} icon={icon} destroy={() => item.destroy()} />, 39 + <Toast message={message} type={type} destroy={() => item.destroy()} />, 41 40 ) 42 41 } 43 42 44 43 function Toast({ 45 44 message, 46 - icon, 45 + type, 47 46 destroy, 48 47 }: { 49 48 message: string 50 - icon: FontAwesomeProps['icon'] 49 + type: ToastType 51 50 destroy: () => void 52 51 }) { 53 52 const t = useTheme() ··· 55 54 const isPanning = useSharedValue(false) 56 55 const dismissSwipeTranslateY = useSharedValue(0) 57 56 const [cardHeight, setCardHeight] = useState(0) 57 + 58 + const toastStyles = getToastTypeStyles(t) 59 + const colors = toastStyles[type] 60 + const IconComponent = TOAST_TYPE_TO_ICON[type] 58 61 59 62 // for the exit animation to work on iOS the animated component 60 63 // must not be the root component ··· 159 162 pointerEvents="box-none"> 160 163 {alive && ( 161 164 <Animated.View 162 - entering={FadeInUp} 163 - exiting={FadeOutUp} 164 - style={[a.flex_1]}> 165 - <Animated.View 166 - onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} 167 - accessibilityRole="alert" 168 - accessible={true} 169 - accessibilityLabel={message} 170 - accessibilityHint="" 171 - onAccessibilityEscape={hideAndDestroyImmediately} 172 - style={[ 173 - a.flex_1, 174 - t.name === 'dark' ? t.atoms.bg_contrast_25 : t.atoms.bg, 175 - a.shadow_lg, 176 - t.atoms.border_contrast_medium, 177 - a.rounded_sm, 178 - a.border, 179 - animatedStyle, 180 - ]}> 181 - <GestureDetector gesture={panGesture}> 182 - <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> 183 - <View 184 - style={[ 185 - a.flex_shrink_0, 186 - a.rounded_full, 187 - {width: 32, height: 32}, 188 - a.align_center, 189 - a.justify_center, 190 - { 191 - backgroundColor: 192 - t.name === 'dark' 193 - ? t.palette.black 194 - : t.palette.primary_50, 195 - }, 196 - ]}> 197 - <FontAwesomeIcon 198 - icon={icon} 199 - size={16} 200 - style={t.atoms.text_contrast_medium} 201 - /> 202 - </View> 203 - <View style={[a.h_full, a.justify_center, a.flex_1]}> 204 - <Text style={a.text_md} emoji> 205 - {message} 206 - </Text> 207 - </View> 165 + entering={FadeIn.duration(TOAST_ANIMATION_CONFIG.duration)} 166 + exiting={FadeOut.duration(TOAST_ANIMATION_CONFIG.duration * 0.7)} 167 + onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} 168 + accessibilityRole="alert" 169 + accessible={true} 170 + accessibilityLabel={message} 171 + accessibilityHint="" 172 + onAccessibilityEscape={hideAndDestroyImmediately} 173 + style={[ 174 + a.flex_1, 175 + {backgroundColor: colors.backgroundColor}, 176 + a.shadow_sm, 177 + {borderColor: colors.borderColor, borderWidth: 1}, 178 + a.rounded_sm, 179 + animatedStyle, 180 + ]}> 181 + <GestureDetector gesture={panGesture}> 182 + <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> 183 + <View 184 + style={[ 185 + a.flex_shrink_0, 186 + a.rounded_full, 187 + {width: 32, height: 32}, 188 + a.align_center, 189 + a.justify_center, 190 + { 191 + backgroundColor: colors.backgroundColor, 192 + }, 193 + ]}> 194 + <IconComponent fill={colors.iconColor} size="sm" /> 195 + </View> 196 + <View 197 + style={[ 198 + a.h_full, 199 + a.justify_center, 200 + a.flex_1, 201 + a.justify_center, 202 + ]}> 203 + <Text 204 + style={[a.text_md, a.font_bold, {color: colors.textColor}]} 205 + emoji> 206 + {message} 207 + </Text> 208 208 </View> 209 - </GestureDetector> 210 - </Animated.View> 209 + </View> 210 + </GestureDetector> 211 211 </Animated.View> 212 212 )} 213 213 </GestureHandlerRootView>
+92 -22
src/view/com/util/Toast.web.tsx
··· 4 4 5 5 import {useEffect, useState} from 'react' 6 6 import {Pressable, StyleSheet, Text, View} from 'react-native' 7 + import {atoms as a, useTheme} from '#/alf' 7 8 import { 8 - FontAwesomeIcon, 9 - type FontAwesomeIconStyle, 10 - type Props as FontAwesomeProps, 11 - } from '@fortawesome/react-native-fontawesome' 9 + type ToastType, 10 + TOAST_TYPE_TO_ICON, 11 + getToastTypeStyles, 12 + getToastWebAnimationStyles, 13 + TOAST_WEB_KEYFRAMES, 14 + } from './Toast.style' 12 15 13 16 const DURATION = 3500 14 17 15 18 interface ActiveToast { 16 19 text: string 17 - icon: FontAwesomeProps['icon'] 20 + type: ToastType 18 21 } 19 22 type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void 20 23 ··· 28 31 type ToastContainerProps = {} 29 32 export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { 30 33 const [activeToast, setActiveToast] = useState<ActiveToast | undefined>() 34 + const [isExiting, setIsExiting] = useState(false) 35 + 31 36 useEffect(() => { 32 37 globalSetActiveToast = (t: ActiveToast | undefined) => { 33 - setActiveToast(t) 38 + if (!t && activeToast) { 39 + setIsExiting(true) 40 + setTimeout(() => { 41 + setActiveToast(t) 42 + setIsExiting(false) 43 + }, 200) 44 + } else { 45 + setActiveToast(t) 46 + setIsExiting(false) 47 + } 48 + } 49 + }, [activeToast]) 50 + 51 + useEffect(() => { 52 + const styleId = 'toast-animations' 53 + if (!document.getElementById(styleId)) { 54 + const style = document.createElement('style') 55 + style.id = styleId 56 + style.textContent = TOAST_WEB_KEYFRAMES 57 + document.head.appendChild(style) 34 58 } 35 - }) 59 + }, []) 60 + 61 + const t = useTheme() 62 + 63 + const toastTypeStyles = getToastTypeStyles(t) 64 + const toastStyles = activeToast 65 + ? toastTypeStyles[activeToast.type] 66 + : toastTypeStyles.default 67 + 68 + const IconComponent = activeToast 69 + ? TOAST_TYPE_TO_ICON[activeToast.type] 70 + : TOAST_TYPE_TO_ICON.default 71 + 72 + const animationStyles = getToastWebAnimationStyles() 73 + 36 74 return ( 37 75 <> 38 76 {activeToast && ( 39 - <View style={styles.container}> 40 - <FontAwesomeIcon 41 - icon={activeToast.icon} 42 - size={20} 43 - style={styles.icon as FontAwesomeIconStyle} 44 - /> 45 - <Text style={styles.text}>{activeToast.text}</Text> 77 + <View 78 + style={[ 79 + styles.container, 80 + { 81 + backgroundColor: toastStyles.backgroundColor, 82 + borderColor: toastStyles.borderColor, 83 + ...(isExiting 84 + ? animationStyles.exiting 85 + : animationStyles.entering), 86 + }, 87 + ]}> 88 + <View 89 + style={[ 90 + styles.iconContainer, 91 + { 92 + backgroundColor: 'transparent', 93 + }, 94 + ]}> 95 + <IconComponent 96 + fill={toastStyles.iconColor} 97 + size="sm" 98 + style={styles.icon} 99 + /> 100 + </View> 101 + <Text 102 + style={[ 103 + styles.text, 104 + a.text_sm, 105 + a.font_bold, 106 + {color: toastStyles.textColor}, 107 + ]}> 108 + {activeToast.text} 109 + </Text> 46 110 <Pressable 47 111 style={styles.dismissBackdrop} 48 112 accessibilityLabel="Dismiss" ··· 60 124 // methods 61 125 // = 62 126 63 - export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { 127 + export function show(text: string, type: ToastType = 'default') { 64 128 if (toastTimeout) { 65 129 clearTimeout(toastTimeout) 66 130 } 67 - globalSetActiveToast?.({text, icon}) 131 + 132 + globalSetActiveToast?.({text, type}) 68 133 toastTimeout = setTimeout(() => { 69 134 globalSetActiveToast?.(undefined) 70 135 }, DURATION) ··· 78 143 bottom: 20, 79 144 // @ts-ignore web only 80 145 width: 'calc(100% - 40px)', 81 - maxWidth: 350, 146 + maxWidth: 380, 82 147 padding: 20, 83 148 flexDirection: 'row', 84 - alignItems: 'center', 85 - backgroundColor: '#000c', 149 + alignItems: 'flex-start', 86 150 borderRadius: 10, 151 + borderWidth: 1, 87 152 }, 88 153 dismissBackdrop: { 89 154 position: 'absolute', ··· 92 157 bottom: 0, 93 158 right: 0, 94 159 }, 160 + iconContainer: { 161 + width: 32, 162 + height: 32, 163 + borderRadius: 16, 164 + alignItems: 'center', 165 + justifyContent: 'center', 166 + flexShrink: 0, 167 + }, 95 168 icon: { 96 - color: '#fff', 97 169 flexShrink: 0, 98 170 }, 99 171 text: { 100 - color: '#fff', 101 - fontSize: 18, 102 172 marginLeft: 10, 103 173 }, 104 174 })
+100
src/view/screens/Storybook/Toasts.tsx
··· 1 + import {View, Pressable} from 'react-native' 2 + 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {Text, H1} from '#/components/Typography' 5 + import { 6 + type ToastType, 7 + TOAST_TYPE_TO_ICON, 8 + getToastTypeStyles, 9 + } from '#/view/com/util/Toast.style' 10 + import * as Toast from '#/view/com/util/Toast' 11 + 12 + function ToastPreview({message, type}: {message: string; type: ToastType}) { 13 + const t = useTheme() 14 + const toastStyles = getToastTypeStyles(t) 15 + const colors = toastStyles[type] 16 + const IconComponent = TOAST_TYPE_TO_ICON[type] 17 + 18 + return ( 19 + <Pressable 20 + onPress={() => Toast.show(message, type)} 21 + style={[ 22 + {backgroundColor: colors.backgroundColor}, 23 + a.shadow_sm, 24 + {borderColor: colors.borderColor}, 25 + a.rounded_sm, 26 + a.border, 27 + a.px_sm, 28 + a.py_sm, 29 + a.flex_row, 30 + a.gap_sm, 31 + a.align_center, 32 + ]}> 33 + <View 34 + style={[ 35 + a.flex_shrink_0, 36 + a.rounded_full, 37 + {width: 24, height: 24}, 38 + a.align_center, 39 + a.justify_center, 40 + { 41 + backgroundColor: colors.backgroundColor, 42 + }, 43 + ]}> 44 + <IconComponent fill={colors.iconColor} size="xs" /> 45 + </View> 46 + <View style={[a.flex_1]}> 47 + <Text 48 + style={[ 49 + a.text_sm, 50 + a.font_bold, 51 + a.leading_snug, 52 + {color: colors.textColor}, 53 + ]} 54 + emoji> 55 + {message} 56 + </Text> 57 + </View> 58 + </Pressable> 59 + ) 60 + } 61 + 62 + export function Toasts() { 63 + return ( 64 + <View style={[a.gap_md]}> 65 + <H1>Toast Examples</H1> 66 + 67 + <View style={[a.gap_md]}> 68 + <View style={[a.gap_xs]}> 69 + <ToastPreview message="Default Toast" type="default" /> 70 + </View> 71 + 72 + <View style={[a.gap_xs]}> 73 + <ToastPreview 74 + message="Operation completed successfully!" 75 + type="success" 76 + /> 77 + </View> 78 + 79 + <View style={[a.gap_xs]}> 80 + <ToastPreview message="Something went wrong!" type="error" /> 81 + </View> 82 + 83 + <View style={[a.gap_xs]}> 84 + <ToastPreview message="Please check your input" type="warning" /> 85 + </View> 86 + 87 + <View style={[a.gap_xs]}> 88 + <ToastPreview message="Here's some helpful information" type="info" /> 89 + </View> 90 + 91 + <View style={[a.gap_xs]}> 92 + <ToastPreview 93 + message="This is a longer message to test how the toast handles multiple lines of text content." 94 + type="info" 95 + /> 96 + </View> 97 + </View> 98 + </View> 99 + ) 100 + }
+2
src/view/screens/Storybook/index.tsx
··· 20 20 import {Shadows} from './Shadows' 21 21 import {Spacing} from './Spacing' 22 22 import {Theming} from './Theming' 23 + import {Toasts} from './Toasts' 23 24 import {Typography} from './Typography' 24 25 25 26 export function Storybook() { ··· 122 123 <Breakpoints /> 123 124 <Dialogs /> 124 125 <Admonitions /> 126 + <Toasts /> 125 127 <Settings /> 126 128 127 129 <Button