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>
This commit is contained in:
Eric Bailey
2025-07-31 10:15:35 -05:00
committed by GitHub
parent 33e0714948
commit 3bcfcba6d8
14 changed files with 709 additions and 697 deletions
-1
View File
@@ -1 +0,0 @@
export function show() {}
-201
View File
@@ -1,201 +0,0 @@
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;
}
}
`
+46 -226
View File
@@ -1,234 +1,54 @@
import {useEffect, useMemo, useRef, useState} from 'react'
import {AccessibilityInfo, View} from 'react-native'
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler'
import Animated, {
FadeIn,
FadeOut,
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withDecay,
withSpring,
} from 'react-native-reanimated'
import RootSiblings from 'react-native-root-siblings'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {toast} from '#/components/Toast'
import {type ToastType} from '#/components/Toast/types'
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'`
* @deprecated use {@link ToastType} and {@link toast} instead
*/
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'
}
}
/**
* @deprecated use {@link toast} instead
*/
export function show(message: string, type?: LegacyToastType): void
export function show(
message: string,
type: ToastType | LegacyToastType = 'default',
): void {
if (process.env.NODE_ENV === 'test') {
return
}
AccessibilityInfo.announceForAccessibility(message)
const item = new RootSiblings(
(
<Toast
message={message}
type={convertLegacyToastType(type)}
destroy={() => item.destroy()}
/>
),
)
}
function Toast({
message,
type,
destroy,
}: {
message: string
type: ToastType
destroy: () => void
}) {
const t = useTheme()
const {top} = useSafeAreaInsets()
const isPanning = useSharedValue(false)
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
const [alive, setAlive] = useState(true)
const hideAndDestroyImmediately = () => {
setAlive(false)
setTimeout(() => {
destroy()
}, 1e3)
}
const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
clearTimeout(destroyTimeoutRef.current)
destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT)
})
const pauseDestroy = useNonReactiveCallback(() => {
clearTimeout(destroyTimeoutRef.current)
})
useEffect(() => {
hideAndDestroyAfterTimeout()
}, [hideAndDestroyAfterTimeout])
const panGesture = useMemo(() => {
return Gesture.Pan()
.activeOffsetY([-10, 10])
.failOffsetX([-10, 10])
.maxPointers(1)
.onStart(() => {
'worklet'
if (!alive) return
isPanning.set(true)
runOnJS(pauseDestroy)()
})
.onUpdate(e => {
'worklet'
if (!alive) return
dismissSwipeTranslateY.value = e.translationY
})
.onEnd(e => {
'worklet'
if (!alive) return
runOnJS(hideAndDestroyAfterTimeout)()
isPanning.set(false)
if (e.velocityY < -100) {
if (dismissSwipeTranslateY.value === 0) {
// HACK: If the initial value is 0, withDecay() animation doesn't start.
// This is a bug in Reanimated, but for now we'll work around it like this.
dismissSwipeTranslateY.value = 1
}
dismissSwipeTranslateY.value = withDecay({
velocity: e.velocityY,
velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
deceleration: 1,
})
} else {
dismissSwipeTranslateY.value = withSpring(0, {
stiffness: 500,
damping: 50,
})
}
})
}, [
dismissSwipeTranslateY,
isPanning,
alive,
hideAndDestroyAfterTimeout,
pauseDestroy,
])
const topOffset = top + 10
useAnimatedReaction(
() =>
!isPanning.get() &&
dismissSwipeTranslateY.get() < -topOffset - cardHeight,
(isSwipedAway, prevIsSwipedAway) => {
'worklet'
if (isSwipedAway && !prevIsSwipedAway) {
runOnJS(destroy)()
}
},
)
const animatedStyle = useAnimatedStyle(() => {
const translation = dismissSwipeTranslateY.get()
return {
transform: [
{
translateY: translation > 0 ? translation ** 0.7 : translation,
},
],
}
})
return (
<GestureHandlerRootView
style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
pointerEvents="box-none">
{alive && (
<Animated.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>
</View>
</GestureDetector>
</Animated.View>
)}
</GestureHandlerRootView>
)
const convertedType = convertLegacyToastType(type)
toast.show({
type: convertedType,
content: message,
a11yLabel: message,
})
}
-180
View File
@@ -1,180 +0,0 @@
/*
* Note: the dataSet properties are used to leverage custom CSS in public/index.html
*/
import {useEffect, useState} from 'react'
import {Pressable, StyleSheet, Text, View} from 'react-native'
import {
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
type: ToastType
}
type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
// globals
// =
let globalSetActiveToast: GlobalSetActiveToast | undefined
let toastTimeout: NodeJS.Timeout | undefined
// components
// =
type ToastContainerProps = {}
export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
const [isExiting, setIsExiting] = useState(false)
useEffect(() => {
globalSetActiveToast = (t: ActiveToast | undefined) => {
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,
{
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"
accessibilityHint=""
onPress={() => {
setActiveToast(undefined)
}}
/>
</View>
)}
</>
)
}
// methods
// =
export function show(
text: string,
type: ToastType | LegacyToastType = 'default',
) {
if (toastTimeout) {
clearTimeout(toastTimeout)
}
globalSetActiveToast?.({text, type: convertLegacyToastType(type)})
toastTimeout = setTimeout(() => {
globalSetActiveToast?.(undefined)
}, DURATION)
}
const styles = StyleSheet.create({
container: {
// @ts-ignore web only
position: 'fixed',
left: 20,
bottom: 20,
// @ts-ignore web only
width: 'calc(100% - 40px)',
maxWidth: 380,
padding: 20,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 10,
borderWidth: 1,
},
dismissBackdrop: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
right: 0,
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
icon: {
flexShrink: 0,
},
text: {
marginLeft: 10,
},
})