From 01e9f54904a6d57757e00cb78b1a951af07c8e9a Mon Sep 17 00:00:00 2001 From: Nick DeBaise Date: Mon, 10 Mar 2025 11:12:59 -0400 Subject: [PATCH 1/4] fix: fix up code, add type export to fix strict JS compilers --- src/components/Toast.tsx | 27 ++++++++++++++------------- src/components/Toasts.tsx | 2 +- src/index.tsx | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 8db6291..5cc36e2 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -74,20 +74,7 @@ export const Toast: FC = ({ }) => { const insets = useSafeAreaInsets(); const { width, height } = useWindowDimensions(); - - useVisibilityChange( - () => { - onToastShow?.(toast); - }, - () => { - onToastHide?.(toast); - }, - toast.visible - ); - const isSystemDarkMode = useColorScheme() === 'dark'; - const isDarkMode = - overrideDarkMode !== undefined ? overrideDarkMode : isSystemDarkMode; const [toastHeight, setToastHeight] = useState( toast?.height ? toast.height : DEFAULT_TOAST_HEIGHT @@ -96,6 +83,9 @@ export const Toast: FC = ({ toast?.width ? toast.width : width - 32 > 360 ? 360 : width - 32 ); + const isDarkMode = + overrideDarkMode !== undefined ? overrideDarkMode : isSystemDarkMode; + const startingY = useMemo( () => toast.position === ToastPosition.TOP @@ -109,6 +99,7 @@ export const Toast: FC = ({ const offsetY = useSharedValue(startingY); const onPress = () => onToastPress?.(toast); + const dismiss = useCallback((id: string) => { toasting.dismiss(id); }, []); @@ -213,6 +204,16 @@ export const Toast: FC = ({ toast.animationConfig, ]); + useVisibilityChange( + () => { + onToastShow?.(toast); + }, + () => { + onToastHide?.(toast); + }, + toast.visible + ); + useEffect(() => { setToastHeight(toast?.height ? toast.height : DEFAULT_TOAST_HEIGHT); }, [toast.height]); diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index 09e1bda..44a68cf 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -60,8 +60,8 @@ export const Toasts: FunctionComponent = ({ left: insets.left + (extraInsets?.left ?? 0), right: insets.right + (extraInsets?.right ?? 0), bottom: insets.bottom + (extraInsets?.bottom ?? 0) + 16, + pointerEvents: 'box-none', }} - pointerEvents={'box-none'} > {toasts.map((t) => ( Date: Wed, 23 Apr 2025 11:51:58 -0400 Subject: [PATCH 2/4] feat: add onPress to toastwebs --- src/components/Toast.tsx | 10 ++++++++- src/core/types.ts | 2 ++ website/docs/api/toast.md | 46 +++++++++++++++++++++++++-------------- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 7561f64..89c4ba6 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -136,7 +136,15 @@ export const Toast: FC = ({ const position = useSharedValue(startY); const offsetY = useSharedValue(startY); - const onPress = () => onToastPress?.(toast); + const onPress = useCallback(() => { + if (toast.onPress) { + toast.onPress(toast); + } + + if (onToastPress) { + onToastPress(toast); + } + }, [toast, onToastPress]); const dismiss = useCallback((id: string) => { toasting.dismiss(id); diff --git a/src/core/types.ts b/src/core/types.ts index 5a62263..2ade7ab 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -55,6 +55,7 @@ export interface Toast { height?: number; width?: number; maxWidth?: number; + onPress?: (toast: Toast) => void; styles?: { pressable?: ViewStyle; view?: ViewStyle; @@ -86,6 +87,7 @@ export type ToastOptions = Partial< | 'animationConfig' | 'animationType' | 'maxWidth' + | 'onPress' > >; diff --git a/website/docs/api/toast.md b/website/docs/api/toast.md index b2b6398..290f8f4 100644 --- a/website/docs/api/toast.md +++ b/website/docs/api/toast.md @@ -103,6 +103,19 @@ toast(Math.floor(Math.random() * 1000).toString(), { ``` +### onPress +You can add a function to be called when the toast is pressed. This is useful for performing an action. + +```js +toast('Hello World', { + onPress: () => { + console.log('Toast pressed!'); + }, +}); +``` + + + ### Loading ```js @@ -313,21 +326,22 @@ where `AutoWidthStyles` holds the actual styles for auto width. ## All toast() Options -| Name | Type | Default | Description | -|-----------------|----------|-----------|-------------------------------------------------------------------------------------------------------------| -| `duration` | number | 3000 | Duration in milliseconds. Set to `Infinity` to keep the toast open until dismissed manually. | -| `position` | enum | 1 | Position of the toast. Can be `ToastPosition.{TOP, BOTTOM, TOP_LEFT, BOTTOM_LEFT, TOP_RIGHT, BOTTOM_RIGHT}` | -| `id` | string | | Unique id for the toast. | -| `icon` | Element | | Icon to display on the left of the toast. | -| `animationType` | string | 'timing' | Animation type. Can be 'timing' or 'spring'. | -| `animationConfig`| object | | Animation configuration. | -| `customToast` | function | | Custom toast component. | -| `width` | number | | Width of the toast. | -| `height` | number | | Height of the toast. | -| `disableShadow` | boolean | false | Disable shadow on the toast. | -| `isSwipeable` | boolean | true | Disable/Enable swipe to dismiss the toast. | -| `providerKey` | string | 'DEFAULT' | Provider key for the toast. | -| `accessibilityMessage`| string | | Accessibility message for screen readers. | -| `styles` | object | | Styles for the toast. | +| Name | Type | Default | Description | +|------------------------|----------|-----------|-------------------------------------------------------------------------------------------------------------| +| `duration` | number | 3000 | Duration in milliseconds. Set to `Infinity` to keep the toast open until dismissed manually. | +| `position` | enum | 1 | Position of the toast. Can be `ToastPosition.{TOP, BOTTOM, TOP_LEFT, BOTTOM_LEFT, TOP_RIGHT, BOTTOM_RIGHT}` | +| `id` | string | | Unique id for the toast. | +| `icon` | Element | | Icon to display on the left of the toast. | +| `animationType` | string | 'timing' | Animation type. Can be 'timing' or 'spring'. | +| `animationConfig` | object | | Animation configuration. | +| `customToast` | function | | Custom toast component. | +| `width` | number | | Width of the toast. | +| `onPress` | function | | Function called when individual toasts are pressed | +| `height` | number | | Height of the toast. | +| `disableShadow` | boolean | false | Disable shadow on the toast. | +| `isSwipeable` | boolean | true | Disable/Enable swipe to dismiss the toast. | +| `providerKey` | string | 'DEFAULT' | Provider key for the toast. | +| `accessibilityMessage` | string | | Accessibility message for screen readers. | +| `styles` | object | | Styles for the toast. | From 96333877fa22652399552609ef056346a8b61a4c Mon Sep 17 00:00:00 2001 From: Nick DeBaise Date: Wed, 23 Apr 2025 13:06:37 -0400 Subject: [PATCH 3/4] feat: add dismiss reasoning --- __tests__/src/components/Toast.test.tsx | 12 +- __tests__/src/core/store.test.ts | 9 +- example/src/App.tsx | 36 ++++- src/components/Toast.tsx | 23 ++- src/components/Toasts.tsx | 3 +- src/core/store.ts | 14 +- src/core/toast.ts | 7 +- src/core/types.ts | 12 ++ src/core/use-toaster.ts | 14 +- src/utils/useVisibilityChange.ts | 10 +- website/docs/api/toast.md | 144 +++++++++++++++--- website/docs/features/toast-handlers.md | 190 ++++++++++++++++++++++++ website/sidebars.ts | 1 + 13 files changed, 428 insertions(+), 47 deletions(-) create mode 100644 website/docs/features/toast-handlers.md diff --git a/__tests__/src/components/Toast.test.tsx b/__tests__/src/components/Toast.test.tsx index 4a67066..ba6c552 100644 --- a/__tests__/src/components/Toast.test.tsx +++ b/__tests__/src/components/Toast.test.tsx @@ -92,10 +92,14 @@ describe('', () => { toast={{ ...defaultProps.toast, visible: false }} /> ); - expect(onToastHide).toHaveBeenCalledWith({ - ...defaultProps.toast, - visible: false, - }); + expect(onToastHide).toHaveBeenCalledTimes(1); + expect(onToastHide).toHaveBeenCalledWith( + { + ...defaultProps.toast, + visible: false, + }, + undefined + ); }); it('handles press events', () => { diff --git a/__tests__/src/core/store.test.ts b/__tests__/src/core/store.test.ts index 5c8ee8c..53c9463 100644 --- a/__tests__/src/core/store.test.ts +++ b/__tests__/src/core/store.test.ts @@ -4,8 +4,12 @@ import { reducer, useStore, } from '../../../src/core/store'; -import { renderHook, act } from '@testing-library/react-native'; -import { ToastPosition, ToastType } from '../../../src/core/types'; +import { act, renderHook } from '@testing-library/react-native'; +import { + DismissReason, + ToastPosition, + ToastType, +} from '../../../src/core/types'; describe('Toast Store', () => { const initialState = { toasts: [], pausedAt: undefined }; @@ -76,6 +80,7 @@ describe('Toast Store', () => { const updatedState = reducer(stateWithToast, { type: ActionType.DISMISS_TOAST, toastId: '1', + reason: DismissReason.TIMEOUT, }); expect(updatedState.toasts[0]?.visible).toBe(false); diff --git a/example/src/App.tsx b/example/src/App.tsx index 5ebee94..751c955 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -131,6 +131,38 @@ export default function App() { + { + toast(Math.floor(Math.random() * 1000).toString(), { + position, + duration, + height, + width, + providerKey: 'PERSISTS', + onPress: (toast) => { + console.log('Toast pressed: ', toast); + }, + onShow: (toast) => { + console.log('Toast shown: ', toast); + }, + onHide: (toast, reason) => { + console.log('Toast hidden: ', toast, reason); + }, + }); + }} + > + + Toast With Handlers + + + { @@ -294,8 +326,8 @@ export default function App() { onToastShow={(t) => { console.log('SHOW: ', t); }} - onToastHide={(t) => { - console.log('HIDE: ', t); + onToastHide={(t, reason) => { + console.log('HIDE: ', t, reason); }} onToastPress={(t) => { console.log('PRESS: ', t); diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 89c4ba6..52fd685 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -27,7 +27,7 @@ import { GestureDetector, } from 'react-native-gesture-handler'; -import type { ExtraInsets, Toast as ToastType } from '../core/types'; +import { DismissReason, ExtraInsets, Toast as ToastType } from '../core/types'; import { resolveValue, Toast as T, ToastPosition } from '../core/types'; import { colors, ConstructShadow, useVisibilityChange } from '../utils'; import { toast as toasting } from '../headless'; @@ -45,7 +45,7 @@ type Props = { customRenderer?: (toast: ToastType) => React.ReactNode; overrideDarkMode?: boolean; onToastShow?: (toast: T) => void; - onToastHide?: (toast: T) => void; + onToastHide?: (toast: T, reason?: DismissReason) => void; onToastPress?: (toast: T) => void; extraInsets?: ExtraInsets; keyboardVisible?: boolean; @@ -146,8 +146,8 @@ export const Toast: FC = ({ } }, [toast, onToastPress]); - const dismiss = useCallback((id: string) => { - toasting.dismiss(id); + const dismiss = useCallback((id: string, reason: DismissReason) => { + toasting.dismiss(id, reason); }, []); const getSwipeDirection = useCallback(() => { @@ -248,7 +248,7 @@ export const Toast: FC = ({ offsetY.value = withTiming(startY, { duration: toast?.animationConfig?.flingPositionReturnDuration ?? 40, }); - runOnJS(dismiss)(toast.id); + runOnJS(dismiss)(toast.id, DismissReason.SWIPE); }); return toast.isSwipeable @@ -268,12 +268,19 @@ export const Toast: FC = ({ useVisibilityChange( () => { + if (toast.onShow) { + toast.onShow(toast); + } onToastShow?.(toast); }, - () => { - onToastHide?.(toast); + (reason) => { + if (toast.onHide) { + toast.onHide(toast, reason || DismissReason.PROGRAMMATIC); + } + onToastHide?.(toast, reason); }, - toast.visible + toast.visible, + toast.dismissReason ); useEffect(() => { diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx index 8e64440..d9f4fd2 100644 --- a/src/components/Toasts.tsx +++ b/src/components/Toasts.tsx @@ -14,6 +14,7 @@ import { useSafeAreaFrame, } from 'react-native-safe-area-context'; import { + DismissReason, ExtraInsets, ToastAnimationConfig, ToastAnimationType, @@ -25,7 +26,7 @@ type Props = { overrideDarkMode?: boolean; extraInsets?: ExtraInsets; onToastShow?: (toast: T) => void; - onToastHide?: (toast: T) => void; + onToastHide?: (toast: T, reason?: DismissReason) => void; onToastPress?: (toast: T) => void; providerKey?: string; preventScreenReaderFromHiding?: boolean; diff --git a/src/core/store.ts b/src/core/store.ts index c93855d..d31022a 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { DefaultToastOptions, Toast, ToastType } from './types'; +import { DefaultToastOptions, DismissReason, Toast, ToastType } from './types'; const TOAST_LIMIT = 20; @@ -29,10 +29,12 @@ export type Action = | { type: ActionType.DISMISS_TOAST; toastId?: string; + reason: DismissReason; } | { type: ActionType.REMOVE_TOAST; toastId?: string; + reason?: DismissReason; } | { type: ActionType.START_PAUSE; @@ -50,7 +52,7 @@ interface State { const toastTimeouts = new Map>(); -const addToRemoveQueue = (toastId: string) => { +const addToRemoveQueue = (toastId: string, reason: DismissReason) => { if (toastTimeouts.has(toastId)) { return; } @@ -60,6 +62,7 @@ const addToRemoveQueue = (toastId: string) => { dispatch({ type: ActionType.REMOVE_TOAST, toastId: toastId, + reason, }); }, 1000); @@ -101,14 +104,14 @@ export const reducer = (state: State, action: Action): State => { : reducer(state, { type: ActionType.ADD_TOAST, toast }); case ActionType.DISMISS_TOAST: - const { toastId } = action; + const { toastId, reason } = action; // ! Side effects ! - This could be execrated into a dismissToast() action, but I'll keep it here for simplicity if (toastId) { - addToRemoveQueue(toastId); + addToRemoveQueue(toastId, reason); } else { state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); + addToRemoveQueue(toast.id, reason); }); } @@ -119,6 +122,7 @@ export const reducer = (state: State, action: Action): State => { ? { ...t, visible: false, + dismissReason: reason, } : t ), diff --git a/src/core/toast.ts b/src/core/toast.ts index ebe4ea7..2ec70f2 100644 --- a/src/core/toast.ts +++ b/src/core/toast.ts @@ -1,5 +1,6 @@ import { DefaultToastOptions, + DismissReason, Element, resolveValue, Toast, @@ -55,10 +56,14 @@ toast.error = createHandler('error'); toast.success = createHandler('success'); toast.loading = createHandler('loading'); -toast.dismiss = (toastId?: string) => { +toast.dismiss = ( + toastId?: string, + reason: DismissReason = DismissReason.PROGRAMMATIC +) => { dispatch({ type: ActionType.DISMISS_TOAST, toastId, + reason, }); }; diff --git a/src/core/types.ts b/src/core/types.ts index 2ade7ab..2f05832 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,6 +13,13 @@ export enum ToastPosition { export type Element = JSX.Element | string | null; +export enum DismissReason { + TIMEOUT = 'timeout', + SWIPE = 'swipe', + PROGRAMMATIC = 'programmatic', + TAP = 'tap', +} + export interface IconTheme { primary: string; secondary: string; @@ -56,6 +63,9 @@ export interface Toast { width?: number; maxWidth?: number; onPress?: (toast: Toast) => void; + onHide?: (toast: Toast, reason: DismissReason) => void; + onShow?: (toast: Toast) => void; + dismissReason?: DismissReason; styles?: { pressable?: ViewStyle; view?: ViewStyle; @@ -88,6 +98,8 @@ export type ToastOptions = Partial< | 'animationType' | 'maxWidth' | 'onPress' + | 'onHide' + | 'onShow' > >; diff --git a/src/core/use-toaster.ts b/src/core/use-toaster.ts index 2795eff..d210280 100644 --- a/src/core/use-toaster.ts +++ b/src/core/use-toaster.ts @@ -1,7 +1,12 @@ import { useEffect, useMemo } from 'react'; import { ActionType, dispatch, useStore } from './store'; import { toast } from './toast'; -import type { DefaultToastOptions, Toast, ToastPosition } from './types'; +import { + DefaultToastOptions, + DismissReason, + Toast, + ToastPosition, +} from './types'; export const useToaster = (toastOptions?: DefaultToastOptions) => { const { toasts, pausedAt } = useStore(toastOptions); @@ -22,11 +27,14 @@ export const useToaster = (toastOptions?: DefaultToastOptions) => { if (durationLeft < 0) { if (t.visible) { - toast.dismiss(t.id); + toast.dismiss(t.id, DismissReason.TIMEOUT); } return; } - return setTimeout(() => toast.dismiss(t.id), durationLeft); + return setTimeout( + () => toast.dismiss(t.id, DismissReason.TIMEOUT), + durationLeft + ); }); return () => { diff --git a/src/utils/useVisibilityChange.ts b/src/utils/useVisibilityChange.ts index cb6c985..09f297c 100644 --- a/src/utils/useVisibilityChange.ts +++ b/src/utils/useVisibilityChange.ts @@ -1,9 +1,11 @@ import { useEffect, useState } from 'react'; +import { DismissReason } from '../core/types'; export const useVisibilityChange = ( onShow: () => void, - onHide: () => void, - visible: boolean + onHide: (reason?: DismissReason) => void, + visible: boolean, + dismissReason?: DismissReason ) => { const [mounted, setMounted] = useState(false); @@ -15,9 +17,9 @@ export const useVisibilityChange = ( if (mounted && !visible) { setMounted(false); - onHide(); + onHide(dismissReason); } - }, [visible, mounted, onShow, onHide]); + }, [visible, mounted, dismissReason, onShow, onHide]); return undefined; }; diff --git a/website/docs/api/toast.md b/website/docs/api/toast.md index 290f8f4..f0c5222 100644 --- a/website/docs/api/toast.md +++ b/website/docs/api/toast.md @@ -114,6 +114,114 @@ toast('Hello World', { }); ``` +### Toast Handlers and Dismiss Reasons + +You can add individual handlers to each toast for greater control over behavior and responses. + +```js +import { DismissReason } from "@backpackapp-io/react-native-toast"; + +const id = toast('Hello World', { + // Handler for when toast is pressed + onPress: (toast) => { + console.log('Toast pressed!', toast.id); + // Access component-specific methods or state here + navigation.navigate('Details', { id: toast.id }); + }, + + // Handler for when toast appears + onShow: (toast) => { + console.log('Toast shown!', toast.id); + analytics.logEvent('toast_shown', { id: toast.id }); + }, + + // Handler for when toast is dismissed with reason + onHide: (toast, reason) => { + console.log(`Toast ${toast.id} dismissed because: ${reason}`); + + // Handle different dismiss reasons + switch(reason) { + case DismissReason.TIMEOUT: + console.log('Toast timed out'); + break; + case DismissReason.SWIPE: + console.log('User swiped toast away'); + break; + case DismissReason.PROGRAMMATIC: + console.log('Toast was programmatically dismissed'); + break; + case DismissReason.TAP: + console.log('User tapped to dismiss toast'); + break; + } + } +}); +``` + +#### Dismiss Reasons + +When a toast is dismissed, you can now determine why it was dismissed: + +| Reason | Description | +|--------|-------------| +| `DismissReason.TIMEOUT` | The toast was dismissed because its duration elapsed | +| `DismissReason.SWIPE` | The toast was dismissed because the user swiped it away | +| `DismissReason.PROGRAMMATIC` | The toast was dismissed programmatically via `toast.dismiss()` | +| `DismissReason.TAP` | The toast was dismissed because the user tapped it | + +#### Individual vs Global Handlers + +You can use both individual handlers (attached to each toast) and global handlers (provided to the `` component): + +```js +// Individual handler (specific to this toast) +toast('Hello', { + onPress: (toast) => { + // This runs only for this specific toast + console.log('This specific toast was pressed'); + } +}); + +// In your app component + { + // This runs for ALL toasts + console.log('A toast was pressed:', toast.id); + }} +/> +``` + +When both are provided, both handlers will be executed when the event occurs. + +#### Usage with Component-Specific Logic + +Individual handlers are especially useful when you need to access component-specific state, hooks, or navigation: + +```js +const OrderConfirmationScreen = () => { + const navigation = useNavigation(); + const { orderId } = useRoute().params; + const { mutate: cancelOrder } = useCancelOrderMutation(); + + const showUndoToast = () => { + toast('Order placed!', { + onPress: () => { + // Access component-specific state and functions + cancelOrder(orderId); + navigation.navigate('OrderCanceled'); + }, + duration: 5000, + }); + }; + + return ( + +