deer social fork for personal usage. but you might see a use idk. github mirror

Some toasts cleanup and reorg (#8748)

* Reorg

* Move animation into css file

* Update style comment

* Extract core component, use platform-specific wrappers

* Pull out platform specific styles

* Just move styles into Toast component itself

* Rename cleanup

* Update API

* Add duration optional prop

* Add some type docs

* add exp eased slide aniamtions

* Make toasts full width on mobile web

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by Eric Bailey Samuel Newman and committed by GitHub 3bcfcba6 33e07149

+1 -1
src/App.web.tsx
··· 50 50 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 51 51 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 52 52 import * as Toast from '#/view/com/util/Toast' 53 - import {ToastContainer} from '#/view/com/util/Toast.web' 54 53 import {Shell} from '#/view/shell/index' 55 54 import {ThemeProvider as Alf} from '#/alf' 56 55 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' ··· 61 60 import {Provider as PortalProvider} from '#/components/Portal' 62 61 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext' 63 62 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' 63 + import {ToastContainer} from '#/components/Toast' 64 64 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 65 65 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder' 66 66
+205
src/components/Toast/Toast.tsx
··· 1 + import {createContext, useContext, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {atoms as a, select, useTheme} from '#/alf' 5 + import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' 6 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 7 + import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 8 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 9 + import {type ToastType} from '#/components/Toast/types' 10 + import {Text} from '#/components/Typography' 11 + 12 + type ContextType = { 13 + type: ToastType 14 + } 15 + 16 + export const ICONS = { 17 + default: SuccessIcon, 18 + success: SuccessIcon, 19 + error: ErrorIcon, 20 + warning: WarningIcon, 21 + info: CircleInfo, 22 + } 23 + 24 + const Context = createContext<ContextType>({ 25 + type: 'default', 26 + }) 27 + 28 + export function Toast({ 29 + type, 30 + content, 31 + }: { 32 + type: ToastType 33 + content: React.ReactNode 34 + }) { 35 + const t = useTheme() 36 + const styles = useToastStyles({type}) 37 + const Icon = ICONS[type] 38 + 39 + return ( 40 + <Context.Provider value={useMemo(() => ({type}), [type])}> 41 + <View 42 + style={[ 43 + a.flex_1, 44 + a.py_lg, 45 + a.pl_xl, 46 + a.pr_2xl, 47 + a.rounded_md, 48 + a.border, 49 + a.flex_row, 50 + a.gap_sm, 51 + t.atoms.shadow_sm, 52 + { 53 + backgroundColor: styles.backgroundColor, 54 + borderColor: styles.borderColor, 55 + }, 56 + ]}> 57 + <Icon size="md" fill={styles.iconColor} /> 58 + 59 + <View style={[a.flex_1]}> 60 + {typeof content === 'string' ? ( 61 + <ToastText>{content}</ToastText> 62 + ) : ( 63 + content 64 + )} 65 + </View> 66 + </View> 67 + </Context.Provider> 68 + ) 69 + } 70 + 71 + export function ToastText({children}: {children: React.ReactNode}) { 72 + const {type} = useContext(Context) 73 + const {textColor} = useToastStyles({type}) 74 + return ( 75 + <Text 76 + style={[ 77 + a.text_md, 78 + a.font_bold, 79 + a.leading_snug, 80 + { 81 + color: textColor, 82 + }, 83 + ]}> 84 + {children} 85 + </Text> 86 + ) 87 + } 88 + 89 + function useToastStyles({type}: {type: ToastType}) { 90 + const t = useTheme() 91 + return useMemo(() => { 92 + return { 93 + default: { 94 + backgroundColor: select(t.name, { 95 + light: t.atoms.bg_contrast_25.backgroundColor, 96 + dim: t.atoms.bg_contrast_100.backgroundColor, 97 + dark: t.atoms.bg_contrast_100.backgroundColor, 98 + }), 99 + borderColor: select(t.name, { 100 + light: t.atoms.border_contrast_low.borderColor, 101 + dim: t.atoms.border_contrast_high.borderColor, 102 + dark: t.atoms.border_contrast_high.borderColor, 103 + }), 104 + iconColor: select(t.name, { 105 + light: t.atoms.text_contrast_medium.color, 106 + dim: t.atoms.text_contrast_medium.color, 107 + dark: t.atoms.text_contrast_medium.color, 108 + }), 109 + textColor: select(t.name, { 110 + light: t.atoms.text_contrast_medium.color, 111 + dim: t.atoms.text_contrast_medium.color, 112 + dark: t.atoms.text_contrast_medium.color, 113 + }), 114 + }, 115 + success: { 116 + backgroundColor: select(t.name, { 117 + light: t.palette.primary_100, 118 + dim: t.palette.primary_100, 119 + dark: t.palette.primary_50, 120 + }), 121 + borderColor: select(t.name, { 122 + light: t.palette.primary_500, 123 + dim: t.palette.primary_500, 124 + dark: t.palette.primary_500, 125 + }), 126 + iconColor: select(t.name, { 127 + light: t.palette.primary_500, 128 + dim: t.palette.primary_600, 129 + dark: t.palette.primary_600, 130 + }), 131 + textColor: select(t.name, { 132 + light: t.palette.primary_500, 133 + dim: t.palette.primary_600, 134 + dark: t.palette.primary_600, 135 + }), 136 + }, 137 + error: { 138 + backgroundColor: select(t.name, { 139 + light: t.palette.negative_200, 140 + dim: t.palette.negative_25, 141 + dark: t.palette.negative_25, 142 + }), 143 + borderColor: select(t.name, { 144 + light: t.palette.negative_300, 145 + dim: t.palette.negative_300, 146 + dark: t.palette.negative_300, 147 + }), 148 + iconColor: select(t.name, { 149 + light: t.palette.negative_600, 150 + dim: t.palette.negative_600, 151 + dark: t.palette.negative_600, 152 + }), 153 + textColor: select(t.name, { 154 + light: t.palette.negative_600, 155 + dim: t.palette.negative_600, 156 + dark: t.palette.negative_600, 157 + }), 158 + }, 159 + warning: { 160 + backgroundColor: select(t.name, { 161 + light: t.atoms.bg_contrast_25.backgroundColor, 162 + dim: t.atoms.bg_contrast_100.backgroundColor, 163 + dark: t.atoms.bg_contrast_100.backgroundColor, 164 + }), 165 + borderColor: select(t.name, { 166 + light: t.atoms.border_contrast_low.borderColor, 167 + dim: t.atoms.border_contrast_high.borderColor, 168 + dark: t.atoms.border_contrast_high.borderColor, 169 + }), 170 + iconColor: select(t.name, { 171 + light: t.atoms.text_contrast_medium.color, 172 + dim: t.atoms.text_contrast_medium.color, 173 + dark: t.atoms.text_contrast_medium.color, 174 + }), 175 + textColor: select(t.name, { 176 + light: t.atoms.text_contrast_medium.color, 177 + dim: t.atoms.text_contrast_medium.color, 178 + dark: t.atoms.text_contrast_medium.color, 179 + }), 180 + }, 181 + info: { 182 + backgroundColor: select(t.name, { 183 + light: t.atoms.bg_contrast_25.backgroundColor, 184 + dim: t.atoms.bg_contrast_100.backgroundColor, 185 + dark: t.atoms.bg_contrast_100.backgroundColor, 186 + }), 187 + borderColor: select(t.name, { 188 + light: t.atoms.border_contrast_low.borderColor, 189 + dim: t.atoms.border_contrast_high.borderColor, 190 + dark: t.atoms.border_contrast_high.borderColor, 191 + }), 192 + iconColor: select(t.name, { 193 + light: t.atoms.text_contrast_medium.color, 194 + dim: t.atoms.text_contrast_medium.color, 195 + dark: t.atoms.text_contrast_medium.color, 196 + }), 197 + textColor: select(t.name, { 198 + light: t.atoms.text_contrast_medium.color, 199 + dim: t.atoms.text_contrast_medium.color, 200 + dark: t.atoms.text_contrast_medium.color, 201 + }), 202 + }, 203 + }[type] 204 + }, [t, type]) 205 + }
+1
src/components/Toast/const.ts
··· 1 + export const DEFAULT_TOAST_DURATION = 3000
+5
src/components/Toast/index.e2e.tsx
··· 1 + export function ToastContainer() { 2 + return null 3 + } 4 + 5 + export function show() {}
+197
src/components/Toast/index.tsx
··· 1 + import {useEffect, useMemo, useRef, useState} from 'react' 2 + import {AccessibilityInfo} from 'react-native' 3 + import { 4 + Gesture, 5 + GestureDetector, 6 + GestureHandlerRootView, 7 + } from 'react-native-gesture-handler' 8 + import Animated, { 9 + Easing, 10 + runOnJS, 11 + SlideInUp, 12 + SlideOutUp, 13 + useAnimatedReaction, 14 + useAnimatedStyle, 15 + useSharedValue, 16 + withDecay, 17 + withSpring, 18 + } from 'react-native-reanimated' 19 + import RootSiblings from 'react-native-root-siblings' 20 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 21 + 22 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 23 + import {atoms as a} from '#/alf' 24 + import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' 25 + import {Toast} from '#/components/Toast/Toast' 26 + import {type ToastApi, type ToastType} from '#/components/Toast/types' 27 + 28 + const TOAST_ANIMATION_DURATION = 300 29 + 30 + export function ToastContainer() { 31 + return null 32 + } 33 + 34 + export const toast: ToastApi = { 35 + show(props) { 36 + if (process.env.NODE_ENV === 'test') { 37 + return 38 + } 39 + 40 + AccessibilityInfo.announceForAccessibility(props.a11yLabel) 41 + 42 + const item = new RootSiblings( 43 + ( 44 + <AnimatedToast 45 + type={props.type} 46 + content={props.content} 47 + a11yLabel={props.a11yLabel} 48 + duration={props.duration ?? DEFAULT_TOAST_DURATION} 49 + destroy={() => item.destroy()} 50 + /> 51 + ), 52 + ) 53 + }, 54 + } 55 + 56 + function AnimatedToast({ 57 + type, 58 + content, 59 + a11yLabel, 60 + duration, 61 + destroy, 62 + }: { 63 + type: ToastType 64 + content: React.ReactNode 65 + a11yLabel: string 66 + duration: number 67 + destroy: () => void 68 + }) { 69 + const {top} = useSafeAreaInsets() 70 + const isPanning = useSharedValue(false) 71 + const dismissSwipeTranslateY = useSharedValue(0) 72 + const [cardHeight, setCardHeight] = useState(0) 73 + 74 + // for the exit animation to work on iOS the animated component 75 + // must not be the root component 76 + // so we need to wrap it in a view and unmount the toast ahead of time 77 + const [alive, setAlive] = useState(true) 78 + 79 + const hideAndDestroyImmediately = () => { 80 + setAlive(false) 81 + setTimeout(() => { 82 + destroy() 83 + }, 1e3) 84 + } 85 + 86 + const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 87 + const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { 88 + clearTimeout(destroyTimeoutRef.current) 89 + destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration) 90 + }) 91 + const pauseDestroy = useNonReactiveCallback(() => { 92 + clearTimeout(destroyTimeoutRef.current) 93 + }) 94 + 95 + useEffect(() => { 96 + hideAndDestroyAfterTimeout() 97 + }, [hideAndDestroyAfterTimeout]) 98 + 99 + const panGesture = useMemo(() => { 100 + return Gesture.Pan() 101 + .activeOffsetY([-10, 10]) 102 + .failOffsetX([-10, 10]) 103 + .maxPointers(1) 104 + .onStart(() => { 105 + 'worklet' 106 + if (!alive) return 107 + isPanning.set(true) 108 + runOnJS(pauseDestroy)() 109 + }) 110 + .onUpdate(e => { 111 + 'worklet' 112 + if (!alive) return 113 + dismissSwipeTranslateY.value = e.translationY 114 + }) 115 + .onEnd(e => { 116 + 'worklet' 117 + if (!alive) return 118 + runOnJS(hideAndDestroyAfterTimeout)() 119 + isPanning.set(false) 120 + if (e.velocityY < -100) { 121 + if (dismissSwipeTranslateY.value === 0) { 122 + // HACK: If the initial value is 0, withDecay() animation doesn't start. 123 + // This is a bug in Reanimated, but for now we'll work around it like this. 124 + dismissSwipeTranslateY.value = 1 125 + } 126 + dismissSwipeTranslateY.value = withDecay({ 127 + velocity: e.velocityY, 128 + velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), 129 + deceleration: 1, 130 + }) 131 + } else { 132 + dismissSwipeTranslateY.value = withSpring(0, { 133 + stiffness: 500, 134 + damping: 50, 135 + }) 136 + } 137 + }) 138 + }, [ 139 + dismissSwipeTranslateY, 140 + isPanning, 141 + alive, 142 + hideAndDestroyAfterTimeout, 143 + pauseDestroy, 144 + ]) 145 + 146 + const topOffset = top + 10 147 + 148 + useAnimatedReaction( 149 + () => 150 + !isPanning.get() && 151 + dismissSwipeTranslateY.get() < -topOffset - cardHeight, 152 + (isSwipedAway, prevIsSwipedAway) => { 153 + 'worklet' 154 + if (isSwipedAway && !prevIsSwipedAway) { 155 + runOnJS(destroy)() 156 + } 157 + }, 158 + ) 159 + 160 + const animatedStyle = useAnimatedStyle(() => { 161 + const translation = dismissSwipeTranslateY.get() 162 + return { 163 + transform: [ 164 + { 165 + translateY: translation > 0 ? translation ** 0.7 : translation, 166 + }, 167 + ], 168 + } 169 + }) 170 + 171 + return ( 172 + <GestureHandlerRootView 173 + style={[a.absolute, {top: topOffset, left: 16, right: 16}]} 174 + pointerEvents="box-none"> 175 + {alive && ( 176 + <Animated.View 177 + entering={SlideInUp.easing(Easing.out(Easing.exp)).duration( 178 + TOAST_ANIMATION_DURATION, 179 + )} 180 + exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration( 181 + TOAST_ANIMATION_DURATION * 0.7, 182 + )} 183 + onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} 184 + accessibilityRole="alert" 185 + accessible={true} 186 + accessibilityLabel={a11yLabel} 187 + accessibilityHint="" 188 + onAccessibilityEscape={hideAndDestroyImmediately} 189 + style={[a.flex_1, animatedStyle]}> 190 + <GestureDetector gesture={panGesture}> 191 + <Toast content={content} type={type} /> 192 + </GestureDetector> 193 + </Animated.View> 194 + )} 195 + </GestureHandlerRootView> 196 + ) 197 + }
+107
src/components/Toast/index.web.tsx
··· 1 + /* 2 + * Note: relies on styles in #/styles.css 3 + */ 4 + 5 + import {useEffect, useState} from 'react' 6 + import {AccessibilityInfo, Pressable, View} from 'react-native' 7 + import {msg} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + 10 + import {atoms as a, useBreakpoints} from '#/alf' 11 + import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const' 12 + import {Toast} from '#/components/Toast/Toast' 13 + import {type ToastApi, type ToastType} from '#/components/Toast/types' 14 + 15 + const TOAST_ANIMATION_STYLES = { 16 + entering: { 17 + animation: 'toastFadeIn 0.3s ease-out forwards', 18 + }, 19 + exiting: { 20 + animation: 'toastFadeOut 0.2s ease-in forwards', 21 + }, 22 + } 23 + 24 + interface ActiveToast { 25 + type: ToastType 26 + content: React.ReactNode 27 + a11yLabel: string 28 + } 29 + type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void 30 + let globalSetActiveToast: GlobalSetActiveToast | undefined 31 + let toastTimeout: NodeJS.Timeout | undefined 32 + type ToastContainerProps = {} 33 + 34 + export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { 35 + const {_} = useLingui() 36 + const {gtPhone} = useBreakpoints() 37 + const [activeToast, setActiveToast] = useState<ActiveToast | undefined>() 38 + const [isExiting, setIsExiting] = useState(false) 39 + 40 + useEffect(() => { 41 + globalSetActiveToast = (t: ActiveToast | undefined) => { 42 + if (!t && activeToast) { 43 + setIsExiting(true) 44 + setTimeout(() => { 45 + setActiveToast(t) 46 + setIsExiting(false) 47 + }, 200) 48 + } else { 49 + if (t) { 50 + AccessibilityInfo.announceForAccessibility(t.a11yLabel) 51 + } 52 + setActiveToast(t) 53 + setIsExiting(false) 54 + } 55 + } 56 + }, [activeToast]) 57 + 58 + return ( 59 + <> 60 + {activeToast && ( 61 + <View 62 + style={[ 63 + a.fixed, 64 + { 65 + left: a.px_xl.paddingLeft, 66 + right: a.px_xl.paddingLeft, 67 + bottom: a.px_xl.paddingLeft, 68 + ...(isExiting 69 + ? TOAST_ANIMATION_STYLES.exiting 70 + : TOAST_ANIMATION_STYLES.entering), 71 + }, 72 + gtPhone && [ 73 + { 74 + maxWidth: 380, 75 + }, 76 + ], 77 + ]}> 78 + <Toast content={activeToast.content} type={activeToast.type} /> 79 + <Pressable 80 + style={[a.absolute, a.inset_0]} 81 + accessibilityLabel={_(msg`Dismiss toast`)} 82 + accessibilityHint="" 83 + onPress={() => setActiveToast(undefined)} 84 + /> 85 + </View> 86 + )} 87 + </> 88 + ) 89 + } 90 + 91 + export const toast: ToastApi = { 92 + show(props) { 93 + if (toastTimeout) { 94 + clearTimeout(toastTimeout) 95 + } 96 + 97 + globalSetActiveToast?.({ 98 + type: props.type, 99 + content: props.content, 100 + a11yLabel: props.a11yLabel, 101 + }) 102 + 103 + toastTimeout = setTimeout(() => { 104 + globalSetActiveToast?.(undefined) 105 + }, props.duration || DEFAULT_TOAST_DURATION) 106 + }, 107 + }
+24
src/components/Toast/types.ts
··· 1 + export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' 2 + 3 + export type ToastApi = { 4 + show: (props: { 5 + /** 6 + * The type of toast to show. This determines the styling and icon used. 7 + */ 8 + type: ToastType 9 + /** 10 + * A string, `Text`, or `Span` components to render inside the toast. This 11 + * allows additional formatting of the content, but should not be used for 12 + * interactive elements link links or buttons. 13 + */ 14 + content: React.ReactNode | string 15 + /** 16 + * Accessibility label for the toast, used for screen readers. 17 + */ 18 + a11yLabel: string 19 + /** 20 + * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`. 21 + */ 22 + duration?: number 23 + }) => void 24 + }
+20
src/style.css
··· 369 369 transform: translateY(0); 370 370 } 371 371 } 372 + 373 + /* 374 + * #/components/Toast/index.web.tsx 375 + */ 376 + @keyframes toastFadeIn { 377 + from { 378 + opacity: 0; 379 + } 380 + to { 381 + opacity: 1; 382 + } 383 + } 384 + @keyframes toastFadeOut { 385 + from { 386 + opacity: 1; 387 + } 388 + to { 389 + opacity: 0; 390 + } 391 + }
-1
src/view/com/util/Toast.e2e.tsx
··· 1 - export function show() {}
-201
src/view/com/util/Toast.style.tsx
··· 1 - import {select, type Theme} from '#/alf' 2 - import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check' 3 - import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 4 - import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo' 5 - import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 6 - 7 - export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' 8 - 9 - export type LegacyToastType = 10 - | 'xmark' 11 - | 'exclamation-circle' 12 - | 'check' 13 - | 'clipboard-check' 14 - | 'circle-exclamation' 15 - 16 - export const convertLegacyToastType = ( 17 - type: ToastType | LegacyToastType, 18 - ): ToastType => { 19 - switch (type) { 20 - // these ones are fine 21 - case 'default': 22 - case 'success': 23 - case 'error': 24 - case 'warning': 25 - case 'info': 26 - return type 27 - // legacy ones need conversion 28 - case 'xmark': 29 - return 'error' 30 - case 'exclamation-circle': 31 - return 'warning' 32 - case 'check': 33 - return 'success' 34 - case 'clipboard-check': 35 - return 'success' 36 - case 'circle-exclamation': 37 - return 'warning' 38 - default: 39 - return 'default' 40 - } 41 - } 42 - 43 - export const TOAST_ANIMATION_CONFIG = { 44 - duration: 300, 45 - damping: 15, 46 - stiffness: 150, 47 - mass: 0.8, 48 - overshootClamping: false, 49 - restSpeedThreshold: 0.01, 50 - restDisplacementThreshold: 0.01, 51 - } 52 - 53 - export const TOAST_TYPE_TO_ICON = { 54 - default: SuccessIcon, 55 - success: SuccessIcon, 56 - error: ErrorIcon, 57 - warning: WarningIcon, 58 - info: CircleInfo, 59 - } 60 - 61 - export const getToastTypeStyles = (t: Theme) => ({ 62 - default: { 63 - backgroundColor: select(t.name, { 64 - light: t.atoms.bg_contrast_25.backgroundColor, 65 - dim: t.atoms.bg_contrast_100.backgroundColor, 66 - dark: t.atoms.bg_contrast_100.backgroundColor, 67 - }), 68 - borderColor: select(t.name, { 69 - light: t.atoms.border_contrast_low.borderColor, 70 - dim: t.atoms.border_contrast_high.borderColor, 71 - dark: t.atoms.border_contrast_high.borderColor, 72 - }), 73 - iconColor: select(t.name, { 74 - light: t.atoms.text_contrast_medium.color, 75 - dim: t.atoms.text_contrast_medium.color, 76 - dark: t.atoms.text_contrast_medium.color, 77 - }), 78 - textColor: select(t.name, { 79 - light: t.atoms.text_contrast_medium.color, 80 - dim: t.atoms.text_contrast_medium.color, 81 - dark: t.atoms.text_contrast_medium.color, 82 - }), 83 - }, 84 - success: { 85 - backgroundColor: select(t.name, { 86 - light: t.palette.primary_100, 87 - dim: t.palette.primary_100, 88 - dark: t.palette.primary_50, 89 - }), 90 - borderColor: select(t.name, { 91 - light: t.palette.primary_500, 92 - dim: t.palette.primary_500, 93 - dark: t.palette.primary_500, 94 - }), 95 - iconColor: select(t.name, { 96 - light: t.palette.primary_500, 97 - dim: t.palette.primary_600, 98 - dark: t.palette.primary_600, 99 - }), 100 - textColor: select(t.name, { 101 - light: t.palette.primary_500, 102 - dim: t.palette.primary_600, 103 - dark: t.palette.primary_600, 104 - }), 105 - }, 106 - error: { 107 - backgroundColor: select(t.name, { 108 - light: t.palette.negative_200, 109 - dim: t.palette.negative_25, 110 - dark: t.palette.negative_25, 111 - }), 112 - borderColor: select(t.name, { 113 - light: t.palette.negative_300, 114 - dim: t.palette.negative_300, 115 - dark: t.palette.negative_300, 116 - }), 117 - iconColor: select(t.name, { 118 - light: t.palette.negative_600, 119 - dim: t.palette.negative_600, 120 - dark: t.palette.negative_600, 121 - }), 122 - textColor: select(t.name, { 123 - light: t.palette.negative_600, 124 - dim: t.palette.negative_600, 125 - dark: t.palette.negative_600, 126 - }), 127 - }, 128 - warning: { 129 - backgroundColor: select(t.name, { 130 - light: t.atoms.bg_contrast_25.backgroundColor, 131 - dim: t.atoms.bg_contrast_100.backgroundColor, 132 - dark: t.atoms.bg_contrast_100.backgroundColor, 133 - }), 134 - borderColor: select(t.name, { 135 - light: t.atoms.border_contrast_low.borderColor, 136 - dim: t.atoms.border_contrast_high.borderColor, 137 - dark: t.atoms.border_contrast_high.borderColor, 138 - }), 139 - iconColor: select(t.name, { 140 - light: t.atoms.text_contrast_medium.color, 141 - dim: t.atoms.text_contrast_medium.color, 142 - dark: t.atoms.text_contrast_medium.color, 143 - }), 144 - textColor: select(t.name, { 145 - light: t.atoms.text_contrast_medium.color, 146 - dim: t.atoms.text_contrast_medium.color, 147 - dark: t.atoms.text_contrast_medium.color, 148 - }), 149 - }, 150 - info: { 151 - backgroundColor: select(t.name, { 152 - light: t.atoms.bg_contrast_25.backgroundColor, 153 - dim: t.atoms.bg_contrast_100.backgroundColor, 154 - dark: t.atoms.bg_contrast_100.backgroundColor, 155 - }), 156 - borderColor: select(t.name, { 157 - light: t.atoms.border_contrast_low.borderColor, 158 - dim: t.atoms.border_contrast_high.borderColor, 159 - dark: t.atoms.border_contrast_high.borderColor, 160 - }), 161 - iconColor: select(t.name, { 162 - light: t.atoms.text_contrast_medium.color, 163 - dim: t.atoms.text_contrast_medium.color, 164 - dark: t.atoms.text_contrast_medium.color, 165 - }), 166 - textColor: select(t.name, { 167 - light: t.atoms.text_contrast_medium.color, 168 - dim: t.atoms.text_contrast_medium.color, 169 - dark: t.atoms.text_contrast_medium.color, 170 - }), 171 - }, 172 - }) 173 - 174 - export const getToastWebAnimationStyles = () => ({ 175 - entering: { 176 - animation: 'toastFadeIn 0.3s ease-out forwards', 177 - }, 178 - exiting: { 179 - animation: 'toastFadeOut 0.2s ease-in forwards', 180 - }, 181 - }) 182 - 183 - export const TOAST_WEB_KEYFRAMES = ` 184 - @keyframes toastFadeIn { 185 - from { 186 - opacity: 0; 187 - } 188 - to { 189 - opacity: 1; 190 - } 191 - } 192 - 193 - @keyframes toastFadeOut { 194 - from { 195 - opacity: 1; 196 - } 197 - to { 198 - opacity: 0; 199 - } 200 - } 201 - `
+43 -223
src/view/com/util/Toast.tsx
··· 1 - import {useEffect, useMemo, useRef, useState} from 'react' 2 - import {AccessibilityInfo, View} from 'react-native' 3 - import { 4 - Gesture, 5 - GestureDetector, 6 - GestureHandlerRootView, 7 - } from 'react-native-gesture-handler' 8 - import Animated, { 9 - FadeIn, 10 - FadeOut, 11 - runOnJS, 12 - useAnimatedReaction, 13 - useAnimatedStyle, 14 - useSharedValue, 15 - withDecay, 16 - withSpring, 17 - } from 'react-native-reanimated' 18 - import RootSiblings from 'react-native-root-siblings' 19 - import {useSafeAreaInsets} from 'react-native-safe-area-context' 1 + import {toast} from '#/components/Toast' 2 + import {type ToastType} from '#/components/Toast/types' 20 3 21 - import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 22 - import { 23 - convertLegacyToastType, 24 - getToastTypeStyles, 25 - type LegacyToastType, 26 - TOAST_ANIMATION_CONFIG, 27 - TOAST_TYPE_TO_ICON, 28 - type ToastType, 29 - } from '#/view/com/util/Toast.style' 30 - import {atoms as a, useTheme} from '#/alf' 31 - import {Text} from '#/components/Typography' 4 + /** 5 + * @deprecated use {@link ToastType} and {@link toast} instead 6 + */ 7 + export type LegacyToastType = 8 + | 'xmark' 9 + | 'exclamation-circle' 10 + | 'check' 11 + | 'clipboard-check' 12 + | 'circle-exclamation' 32 13 33 - const TIMEOUT = 2e3 14 + export const convertLegacyToastType = ( 15 + type: ToastType | LegacyToastType, 16 + ): ToastType => { 17 + switch (type) { 18 + // these ones are fine 19 + case 'default': 20 + case 'success': 21 + case 'error': 22 + case 'warning': 23 + case 'info': 24 + return type 25 + // legacy ones need conversion 26 + case 'xmark': 27 + return 'error' 28 + case 'exclamation-circle': 29 + return 'warning' 30 + case 'check': 31 + return 'success' 32 + case 'clipboard-check': 33 + return 'success' 34 + case 'circle-exclamation': 35 + return 'warning' 36 + default: 37 + return 'default' 38 + } 39 + } 34 40 35 - // Use type overloading to mark certain types as deprecated -sfn 36 - // https://stackoverflow.com/a/78325851/13325987 37 - export function show(message: string, type?: ToastType): void 38 41 /** 39 - * @deprecated type is deprecated - use one of `'default' | 'success' | 'error' | 'warning' | 'info'` 42 + * @deprecated use {@link toast} instead 40 43 */ 41 - export function show(message: string, type?: LegacyToastType): void 42 44 export function show( 43 45 message: string, 44 46 type: ToastType | LegacyToastType = 'default', 45 47 ): void { 46 - if (process.env.NODE_ENV === 'test') { 47 - return 48 - } 49 - 50 - AccessibilityInfo.announceForAccessibility(message) 51 - const item = new RootSiblings( 52 - ( 53 - <Toast 54 - message={message} 55 - type={convertLegacyToastType(type)} 56 - destroy={() => item.destroy()} 57 - /> 58 - ), 59 - ) 60 - } 61 - 62 - function Toast({ 63 - message, 64 - type, 65 - destroy, 66 - }: { 67 - message: string 68 - type: ToastType 69 - destroy: () => void 70 - }) { 71 - const t = useTheme() 72 - const {top} = useSafeAreaInsets() 73 - const isPanning = useSharedValue(false) 74 - const dismissSwipeTranslateY = useSharedValue(0) 75 - const [cardHeight, setCardHeight] = useState(0) 76 - 77 - const toastStyles = getToastTypeStyles(t) 78 - const colors = toastStyles[type] 79 - const IconComponent = TOAST_TYPE_TO_ICON[type] 80 - 81 - // for the exit animation to work on iOS the animated component 82 - // must not be the root component 83 - // so we need to wrap it in a view and unmount the toast ahead of time 84 - const [alive, setAlive] = useState(true) 85 - 86 - const hideAndDestroyImmediately = () => { 87 - setAlive(false) 88 - setTimeout(() => { 89 - destroy() 90 - }, 1e3) 91 - } 92 - 93 - const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>() 94 - const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => { 95 - clearTimeout(destroyTimeoutRef.current) 96 - destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT) 97 - }) 98 - const pauseDestroy = useNonReactiveCallback(() => { 99 - clearTimeout(destroyTimeoutRef.current) 100 - }) 101 - 102 - useEffect(() => { 103 - hideAndDestroyAfterTimeout() 104 - }, [hideAndDestroyAfterTimeout]) 105 - 106 - const panGesture = useMemo(() => { 107 - return Gesture.Pan() 108 - .activeOffsetY([-10, 10]) 109 - .failOffsetX([-10, 10]) 110 - .maxPointers(1) 111 - .onStart(() => { 112 - 'worklet' 113 - if (!alive) return 114 - isPanning.set(true) 115 - runOnJS(pauseDestroy)() 116 - }) 117 - .onUpdate(e => { 118 - 'worklet' 119 - if (!alive) return 120 - dismissSwipeTranslateY.value = e.translationY 121 - }) 122 - .onEnd(e => { 123 - 'worklet' 124 - if (!alive) return 125 - runOnJS(hideAndDestroyAfterTimeout)() 126 - isPanning.set(false) 127 - if (e.velocityY < -100) { 128 - if (dismissSwipeTranslateY.value === 0) { 129 - // HACK: If the initial value is 0, withDecay() animation doesn't start. 130 - // This is a bug in Reanimated, but for now we'll work around it like this. 131 - dismissSwipeTranslateY.value = 1 132 - } 133 - dismissSwipeTranslateY.value = withDecay({ 134 - velocity: e.velocityY, 135 - velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), 136 - deceleration: 1, 137 - }) 138 - } else { 139 - dismissSwipeTranslateY.value = withSpring(0, { 140 - stiffness: 500, 141 - damping: 50, 142 - }) 143 - } 144 - }) 145 - }, [ 146 - dismissSwipeTranslateY, 147 - isPanning, 148 - alive, 149 - hideAndDestroyAfterTimeout, 150 - pauseDestroy, 151 - ]) 152 - 153 - const topOffset = top + 10 154 - 155 - useAnimatedReaction( 156 - () => 157 - !isPanning.get() && 158 - dismissSwipeTranslateY.get() < -topOffset - cardHeight, 159 - (isSwipedAway, prevIsSwipedAway) => { 160 - 'worklet' 161 - if (isSwipedAway && !prevIsSwipedAway) { 162 - runOnJS(destroy)() 163 - } 164 - }, 165 - ) 166 - 167 - const animatedStyle = useAnimatedStyle(() => { 168 - const translation = dismissSwipeTranslateY.get() 169 - return { 170 - transform: [ 171 - { 172 - translateY: translation > 0 ? translation ** 0.7 : translation, 173 - }, 174 - ], 175 - } 48 + const convertedType = convertLegacyToastType(type) 49 + toast.show({ 50 + type: convertedType, 51 + content: message, 52 + a11yLabel: message, 176 53 }) 177 - 178 - return ( 179 - <GestureHandlerRootView 180 - style={[a.absolute, {top: topOffset, left: 16, right: 16}]} 181 - pointerEvents="box-none"> 182 - {alive && ( 183 - <Animated.View 184 - entering={FadeIn.duration(TOAST_ANIMATION_CONFIG.duration)} 185 - exiting={FadeOut.duration(TOAST_ANIMATION_CONFIG.duration * 0.7)} 186 - onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)} 187 - accessibilityRole="alert" 188 - accessible={true} 189 - accessibilityLabel={message} 190 - accessibilityHint="" 191 - onAccessibilityEscape={hideAndDestroyImmediately} 192 - style={[ 193 - a.flex_1, 194 - {backgroundColor: colors.backgroundColor}, 195 - a.shadow_sm, 196 - {borderColor: colors.borderColor, borderWidth: 1}, 197 - a.rounded_sm, 198 - animatedStyle, 199 - ]}> 200 - <GestureDetector gesture={panGesture}> 201 - <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}> 202 - <View 203 - style={[ 204 - a.flex_shrink_0, 205 - a.rounded_full, 206 - {width: 32, height: 32}, 207 - a.align_center, 208 - a.justify_center, 209 - { 210 - backgroundColor: colors.backgroundColor, 211 - }, 212 - ]}> 213 - <IconComponent fill={colors.iconColor} size="sm" /> 214 - </View> 215 - <View 216 - style={[ 217 - a.h_full, 218 - a.justify_center, 219 - a.flex_1, 220 - a.justify_center, 221 - ]}> 222 - <Text 223 - style={[a.text_md, a.font_bold, {color: colors.textColor}]} 224 - emoji> 225 - {message} 226 - </Text> 227 - </View> 228 - </View> 229 - </GestureDetector> 230 - </Animated.View> 231 - )} 232 - </GestureHandlerRootView> 233 - ) 234 54 }
-180
src/view/com/util/Toast.web.tsx
··· 1 - /* 2 - * Note: the dataSet properties are used to leverage custom CSS in public/index.html 3 - */ 4 - 5 - import {useEffect, useState} from 'react' 6 - import {Pressable, StyleSheet, Text, View} from 'react-native' 7 - 8 - import { 9 - convertLegacyToastType, 10 - getToastTypeStyles, 11 - getToastWebAnimationStyles, 12 - type LegacyToastType, 13 - TOAST_TYPE_TO_ICON, 14 - TOAST_WEB_KEYFRAMES, 15 - type ToastType, 16 - } from '#/view/com/util/Toast.style' 17 - import {atoms as a, useTheme} from '#/alf' 18 - 19 - const DURATION = 3500 20 - 21 - interface ActiveToast { 22 - text: string 23 - type: ToastType 24 - } 25 - type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void 26 - 27 - // globals 28 - // = 29 - let globalSetActiveToast: GlobalSetActiveToast | undefined 30 - let toastTimeout: NodeJS.Timeout | undefined 31 - 32 - // components 33 - // = 34 - type ToastContainerProps = {} 35 - export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { 36 - const [activeToast, setActiveToast] = useState<ActiveToast | undefined>() 37 - const [isExiting, setIsExiting] = useState(false) 38 - 39 - useEffect(() => { 40 - globalSetActiveToast = (t: ActiveToast | undefined) => { 41 - if (!t && activeToast) { 42 - setIsExiting(true) 43 - setTimeout(() => { 44 - setActiveToast(t) 45 - setIsExiting(false) 46 - }, 200) 47 - } else { 48 - setActiveToast(t) 49 - setIsExiting(false) 50 - } 51 - } 52 - }, [activeToast]) 53 - 54 - useEffect(() => { 55 - const styleId = 'toast-animations' 56 - if (!document.getElementById(styleId)) { 57 - const style = document.createElement('style') 58 - style.id = styleId 59 - style.textContent = TOAST_WEB_KEYFRAMES 60 - document.head.appendChild(style) 61 - } 62 - }, []) 63 - 64 - const t = useTheme() 65 - 66 - const toastTypeStyles = getToastTypeStyles(t) 67 - const toastStyles = activeToast 68 - ? toastTypeStyles[activeToast.type] 69 - : toastTypeStyles.default 70 - 71 - const IconComponent = activeToast 72 - ? TOAST_TYPE_TO_ICON[activeToast.type] 73 - : TOAST_TYPE_TO_ICON.default 74 - 75 - const animationStyles = getToastWebAnimationStyles() 76 - 77 - return ( 78 - <> 79 - {activeToast && ( 80 - <View 81 - style={[ 82 - styles.container, 83 - { 84 - backgroundColor: toastStyles.backgroundColor, 85 - borderColor: toastStyles.borderColor, 86 - ...(isExiting 87 - ? animationStyles.exiting 88 - : animationStyles.entering), 89 - }, 90 - ]}> 91 - <View 92 - style={[ 93 - styles.iconContainer, 94 - { 95 - backgroundColor: 'transparent', 96 - }, 97 - ]}> 98 - <IconComponent 99 - fill={toastStyles.iconColor} 100 - size="sm" 101 - style={styles.icon} 102 - /> 103 - </View> 104 - <Text 105 - style={[ 106 - styles.text, 107 - a.text_sm, 108 - a.font_bold, 109 - {color: toastStyles.textColor}, 110 - ]}> 111 - {activeToast.text} 112 - </Text> 113 - <Pressable 114 - style={styles.dismissBackdrop} 115 - accessibilityLabel="Dismiss" 116 - accessibilityHint="" 117 - onPress={() => { 118 - setActiveToast(undefined) 119 - }} 120 - /> 121 - </View> 122 - )} 123 - </> 124 - ) 125 - } 126 - 127 - // methods 128 - // = 129 - 130 - export function show( 131 - text: string, 132 - type: ToastType | LegacyToastType = 'default', 133 - ) { 134 - if (toastTimeout) { 135 - clearTimeout(toastTimeout) 136 - } 137 - 138 - globalSetActiveToast?.({text, type: convertLegacyToastType(type)}) 139 - toastTimeout = setTimeout(() => { 140 - globalSetActiveToast?.(undefined) 141 - }, DURATION) 142 - } 143 - 144 - const styles = StyleSheet.create({ 145 - container: { 146 - // @ts-ignore web only 147 - position: 'fixed', 148 - left: 20, 149 - bottom: 20, 150 - // @ts-ignore web only 151 - width: 'calc(100% - 40px)', 152 - maxWidth: 380, 153 - padding: 20, 154 - flexDirection: 'row', 155 - alignItems: 'center', 156 - borderRadius: 10, 157 - borderWidth: 1, 158 - }, 159 - dismissBackdrop: { 160 - position: 'absolute', 161 - top: 0, 162 - left: 0, 163 - bottom: 0, 164 - right: 0, 165 - }, 166 - iconContainer: { 167 - width: 32, 168 - height: 32, 169 - borderRadius: 16, 170 - alignItems: 'center', 171 - justifyContent: 'center', 172 - flexShrink: 0, 173 - }, 174 - icon: { 175 - flexShrink: 0, 176 - }, 177 - text: { 178 - marginLeft: 10, 179 - }, 180 - })
+101 -87
src/view/screens/Storybook/Toasts.tsx
··· 1 1 import {Pressable, View} from 'react-native' 2 2 3 - import * as Toast from '#/view/com/util/Toast' 4 - import { 5 - getToastTypeStyles, 6 - TOAST_TYPE_TO_ICON, 7 - type ToastType, 8 - } from '#/view/com/util/Toast.style' 9 - import {atoms as a, useTheme} from '#/alf' 10 - import {H1, Text} from '#/components/Typography' 11 - 12 - function ToastPreview({message, type}: {message: string; type: ToastType}) { 13 - const t = useTheme() 14 - const toastStyles = getToastTypeStyles(t) 15 - const colors = toastStyles[type as keyof typeof toastStyles] 16 - const IconComponent = 17 - TOAST_TYPE_TO_ICON[type as keyof typeof TOAST_TYPE_TO_ICON] 18 - 19 - return ( 20 - <Pressable 21 - accessibilityRole="button" 22 - onPress={() => Toast.show(message, type)} 23 - style={[ 24 - {backgroundColor: colors.backgroundColor}, 25 - a.shadow_sm, 26 - {borderColor: colors.borderColor}, 27 - a.rounded_sm, 28 - a.border, 29 - a.px_sm, 30 - a.py_sm, 31 - a.flex_row, 32 - a.gap_sm, 33 - a.align_center, 34 - ]}> 35 - <View 36 - style={[ 37 - a.flex_shrink_0, 38 - a.rounded_full, 39 - {width: 24, height: 24}, 40 - a.align_center, 41 - a.justify_center, 42 - { 43 - backgroundColor: colors.backgroundColor, 44 - }, 45 - ]}> 46 - <IconComponent fill={colors.iconColor} size="xs" /> 47 - </View> 48 - <View style={[a.flex_1]}> 49 - <Text 50 - style={[ 51 - a.text_sm, 52 - a.font_bold, 53 - a.leading_snug, 54 - {color: colors.textColor}, 55 - ]} 56 - emoji> 57 - {message} 58 - </Text> 59 - </View> 60 - </Pressable> 61 - ) 62 - } 3 + import {show as deprecatedShow} from '#/view/com/util/Toast' 4 + import {atoms as a} from '#/alf' 5 + import {Button, ButtonText} from '#/components/Button' 6 + import {toast} from '#/components/Toast' 7 + import {Toast} from '#/components/Toast/Toast' 8 + import {H1} from '#/components/Typography' 63 9 64 10 export function Toasts() { 65 11 return ( ··· 67 13 <H1>Toast Examples</H1> 68 14 69 15 <View style={[a.gap_md]}> 70 - <View style={[a.gap_xs]}> 71 - <ToastPreview message="Default Toast" type="default" /> 72 - </View> 73 - 74 - <View style={[a.gap_xs]}> 75 - <ToastPreview 76 - message="Operation completed successfully!" 77 - type="success" 16 + <Pressable 17 + accessibilityRole="button" 18 + onPress={() => 19 + toast.show({ 20 + type: 'default', 21 + content: 'Default toast', 22 + a11yLabel: 'Default toast', 23 + }) 24 + }> 25 + <Toast content="Default toast" type="default" /> 26 + </Pressable> 27 + <Pressable 28 + accessibilityRole="button" 29 + onPress={() => 30 + toast.show({ 31 + type: 'default', 32 + content: 'Default toast, 6 seconds', 33 + a11yLabel: 'Default toast, 6 seconds', 34 + duration: 6e3, 35 + }) 36 + }> 37 + <Toast content="Default toast, 6 seconds" type="default" /> 38 + </Pressable> 39 + <Pressable 40 + accessibilityRole="button" 41 + onPress={() => 42 + toast.show({ 43 + type: 'default', 44 + content: 45 + 'This is a longer message to test how the toast handles multiple lines of text content.', 46 + a11yLabel: 47 + 'This is a longer message to test how the toast handles multiple lines of text content.', 48 + }) 49 + }> 50 + <Toast 51 + content="This is a longer message to test how the toast handles multiple lines of text content." 52 + type="default" 78 53 /> 79 - </View> 80 - 81 - <View style={[a.gap_xs]}> 82 - <ToastPreview message="Something went wrong!" type="error" /> 83 - </View> 84 - 85 - <View style={[a.gap_xs]}> 86 - <ToastPreview message="Please check your input" type="warning" /> 87 - </View> 88 - 89 - <View style={[a.gap_xs]}> 90 - <ToastPreview message="Here's some helpful information" type="info" /> 91 - </View> 54 + </Pressable> 55 + <Pressable 56 + accessibilityRole="button" 57 + onPress={() => 58 + toast.show({ 59 + type: 'success', 60 + content: 'Success toast', 61 + a11yLabel: 'Success toast', 62 + }) 63 + }> 64 + <Toast content="Success toast" type="success" /> 65 + </Pressable> 66 + <Pressable 67 + accessibilityRole="button" 68 + onPress={() => 69 + toast.show({ 70 + type: 'info', 71 + content: 'Info toast', 72 + a11yLabel: 'Info toast', 73 + }) 74 + }> 75 + <Toast content="Info" type="info" /> 76 + </Pressable> 77 + <Pressable 78 + accessibilityRole="button" 79 + onPress={() => 80 + toast.show({ 81 + type: 'warning', 82 + content: 'Warning toast', 83 + a11yLabel: 'Warning toast', 84 + }) 85 + }> 86 + <Toast content="Warning" type="warning" /> 87 + </Pressable> 88 + <Pressable 89 + accessibilityRole="button" 90 + onPress={() => 91 + toast.show({ 92 + type: 'error', 93 + content: 'Error toast', 94 + a11yLabel: 'Error toast', 95 + }) 96 + }> 97 + <Toast content="Error" type="error" /> 98 + </Pressable> 92 99 93 - <View style={[a.gap_xs]}> 94 - <ToastPreview 95 - message="This is a longer message to test how the toast handles multiple lines of text content." 96 - type="info" 97 - /> 98 - </View> 100 + <Button 101 + label="Deprecated toast example" 102 + onPress={() => 103 + deprecatedShow( 104 + 'This is a deprecated toast example', 105 + 'exclamation-circle', 106 + ) 107 + } 108 + size="large" 109 + variant="solid" 110 + color="secondary"> 111 + <ButtonText>Deprecated toast example</ButtonText> 112 + </Button> 99 113 </View> 100 114 </View> 101 115 )
+2 -1
src/view/screens/Storybook/index.tsx
··· 91 91 </Button> 92 92 </View> 93 93 94 + <Toasts /> 95 + 94 96 <Button 95 97 variant="solid" 96 98 color="primary" ··· 123 125 <Breakpoints /> 124 126 <Dialogs /> 125 127 <Admonitions /> 126 - <Toasts /> 127 128 <Settings /> 128 129 129 130 <Button