diff --git a/__tests__/src/components/Toast.test.tsx b/__tests__/src/components/Toast.test.tsx index 4a67066..3d3c15b 100644 --- a/__tests__/src/components/Toast.test.tsx +++ b/__tests__/src/components/Toast.test.tsx @@ -7,6 +7,7 @@ import { Toast as ToastType, ToastType as ToastingType, resolveValue, + DismissReason, } from '../../../src/core/types'; import { toast as toasting } from '../../../src/headless'; import { View, Text } from 'react-native'; @@ -92,10 +93,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, + }, + DismissReason.PROGRAMMATIC + ); }); 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 7561f64..3c58b04 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; @@ -136,10 +136,18 @@ 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); + const dismiss = useCallback((id: string, reason: DismissReason) => { + toasting.dismiss(id, reason); }, []); const getSwipeDirection = useCallback(() => { @@ -240,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 @@ -260,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 || DismissReason.PROGRAMMATIC); }, - 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 5a62263..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; @@ -55,6 +62,10 @@ export interface Toast { height?: number; 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; @@ -86,6 +97,9 @@ export type ToastOptions = Partial< | 'animationConfig' | '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 b2b6398..f0c5222 100644 --- a/website/docs/api/toast.md +++ b/website/docs/api/toast.md @@ -103,6 +103,127 @@ 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!'); + }, +}); +``` + +### 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 ( + +