diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/util/Toast.e2e.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/Toast.style.tsx | 201 | ||||
-rw-r--r-- | src/view/com/util/Toast.tsx | 268 | ||||
-rw-r--r-- | src/view/com/util/Toast.web.tsx | 180 | ||||
-rw-r--r-- | src/view/screens/Storybook/Toasts.tsx | 188 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 3 |
6 files changed, 147 insertions, 694 deletions
diff --git a/src/view/com/util/Toast.e2e.tsx b/src/view/com/util/Toast.e2e.tsx deleted file mode 100644 index c5582ff0a..000000000 --- a/src/view/com/util/Toast.e2e.tsx +++ /dev/null @@ -1 +0,0 @@ -export function show() {} diff --git a/src/view/com/util/Toast.style.tsx b/src/view/com/util/Toast.style.tsx deleted file mode 100644 index 3869e6890..000000000 --- a/src/view/com/util/Toast.style.tsx +++ /dev/null @@ -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; - } - } -` diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 54ef7042d..37ec6acb5 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -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 +/** + * @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' + } +} -// 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 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 convertedType = convertLegacyToastType(type) + toast.show({ + type: convertedType, + content: message, + a11yLabel: message, }) - 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> - ) } diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx deleted file mode 100644 index 6b99b30bf..000000000 --- a/src/view/com/util/Toast.web.tsx +++ /dev/null @@ -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, - }, -}) diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx index 4c17f1c33..8fc6f095f 100644 --- a/src/view/screens/Storybook/Toasts.tsx +++ b/src/view/screens/Storybook/Toasts.tsx @@ -1,65 +1,11 @@ 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> - ) -} +import {show as deprecatedShow} from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {toast} from '#/components/Toast' +import {Toast} from '#/components/Toast/Toast' +import {H1} from '#/components/Typography' export function Toasts() { return ( @@ -67,35 +13,103 @@ export function Toasts() { <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" + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'default', + content: 'Default toast', + a11yLabel: 'Default toast', + }) + }> + <Toast content="Default toast" type="default" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'default', + content: 'Default toast, 6 seconds', + a11yLabel: 'Default toast, 6 seconds', + duration: 6e3, + }) + }> + <Toast content="Default toast, 6 seconds" type="default" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'default', + content: + 'This is a longer message to test how the toast handles multiple lines of text content.', + a11yLabel: + 'This is a longer message to test how the toast handles multiple lines of text content.', + }) + }> + <Toast + content="This is a longer message to test how the toast handles multiple lines of text content." + type="default" /> - </View> - - <View style={[a.gap_xs]}> - <ToastPreview message="Something went wrong!" type="error" /> - </View> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'success', + content: 'Success toast', + a11yLabel: 'Success toast', + }) + }> + <Toast content="Success toast" type="success" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'info', + content: 'Info toast', + a11yLabel: 'Info toast', + }) + }> + <Toast content="Info" type="info" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'warning', + content: 'Warning toast', + a11yLabel: 'Warning toast', + }) + }> + <Toast content="Warning" type="warning" /> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => + toast.show({ + type: 'error', + content: 'Error toast', + a11yLabel: 'Error toast', + }) + }> + <Toast content="Error" type="error" /> + </Pressable> - <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> + <Button + label="Deprecated toast example" + onPress={() => + deprecatedShow( + 'This is a deprecated toast example', + 'exclamation-circle', + ) + } + size="large" + variant="solid" + color="secondary"> + <ButtonText>Deprecated toast example</ButtonText> + </Button> </View> </View> ) diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index afcc1c4e7..40ef79cca 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -91,6 +91,8 @@ function StorybookInner() { </Button> </View> + <Toasts /> + <Button variant="solid" color="primary" @@ -123,7 +125,6 @@ function StorybookInner() { <Breakpoints /> <Dialogs /> <Admonitions /> - <Toasts /> <Settings /> <Button |