diff --git a/packages/frontend/src/components/shared/Toast/ToastProvider.tsx b/packages/frontend/src/components/shared/Toast/ToastProvider.tsx new file mode 100644 index 00000000..57ecf2b6 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/ToastProvider.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { + Provider, + Viewport, + type ToastProviderProps, +} from '@radix-ui/react-toast'; + +export const ToastProvider = ({ children, ...props }: ToastProviderProps) => { + return ( + + {children} + + + ); +}; diff --git a/packages/frontend/src/components/shared/Toast/Toaster.tsx b/packages/frontend/src/components/shared/Toast/Toaster.tsx new file mode 100644 index 00000000..85222167 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/Toaster.tsx @@ -0,0 +1,25 @@ +import React, { ComponentPropsWithoutRef, useMemo } from 'react'; +import { Provider, Viewport } from '@radix-ui/react-toast'; +import { SimpleToast, SimpleToastProps } from './SimpleToast'; +import { useToast } from './useToast'; + +interface ToasterProps extends ComponentPropsWithoutRef<'div'> {} + +export const Toaster = ({}: ToasterProps) => { + const { toasts } = useToast(); + + const renderToasts = useMemo( + () => + toasts.map(({ id, ...props }) => ( + + )), + [toasts], + ); + + return ( + + {renderToasts} + + + ); +}; diff --git a/packages/frontend/src/components/shared/Toast/useToast.tsx b/packages/frontend/src/components/shared/Toast/useToast.tsx new file mode 100644 index 00000000..ad088be4 --- /dev/null +++ b/packages/frontend/src/components/shared/Toast/useToast.tsx @@ -0,0 +1,190 @@ +// Inspired by react-hot-toast library +import React from 'react'; +import { type ToastProps } from '@radix-ui/react-toast'; + +const TOAST_LIMIT = 3; +const TOAST_REMOVE_DELAY_DEFAULT = 7000; + +type ToasterToast = ToastProps & { + id: string; +}; + +const actionTypes = { + ADD_TOAST: 'ADD_TOAST', + UPDATE_TOAST: 'UPDATE_TOAST', + DISMISS_TOAST: 'DISMISS_TOAST', + REMOVE_TOAST: 'REMOVE_TOAST', +} as const; + +let count = 0; + +const genId = () => { + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); +}; + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType['ADD_TOAST']; + toast: ToasterToast; + } + | { + type: ActionType['UPDATE_TOAST']; + toast: Partial; + } + | { + type: ActionType['DISMISS_TOAST']; + toastId?: ToasterToast['id']; + duration?: ToasterToast['duration']; + } + | { + type: ActionType['REMOVE_TOAST']; + toastId?: ToasterToast['id']; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string, duration: number) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: 'REMOVE_TOAST', + toastId: toastId, + }); + }, duration ?? TOAST_REMOVE_DELAY_DEFAULT); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'ADD_TOAST': + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case 'UPDATE_TOAST': + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id + ? ({ ...t, ...action.toast } as ToasterToast) + : t, + ), + }; + + case 'DISMISS_TOAST': { + const { toastId, duration = TOAST_REMOVE_DELAY_DEFAULT } = action; + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId, duration); + } else { + state.toasts.forEach((_toast) => { + addToRemoveQueue(_toast.id, duration); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case 'REMOVE_TOAST': + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(_state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +const dispatch = (action: Action) => { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +}; + +const toast = (props: ToasterToast) => { + if (!props.duration) { + props.duration = 20000; + } + const id = genId(); + + const update = (_props: ToasterToast) => + dispatch({ + type: 'UPDATE_TOAST', + toast: { ..._props, id }, + }); + const dismiss = () => + dispatch({ type: 'DISMISS_TOAST', toastId: id, duration: props.duration }); + + dispatch({ + type: 'ADD_TOAST', + toast: { + ...props, + id, + open: true, + onOpenChange: (open: boolean) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +}; + +const useToast = () => { + const [state, setState] = React.useState(memoryState); + + React.useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), + }; +}; + +export { toast, useToast };