import * as React from "react" const TOAST_LIMIT = 5 // 최대 5개로 제한 export const TOAST_REMOVE_DELAY = 5000 // 5초 후 DOM에서 제거 export type ToasterToast = { id: string title?: React.ReactNode description?: React.ReactNode action?: React.ReactNode variant?: "default" | "destructive" duration?: number open?: boolean onOpenChange?: (open: boolean) => void } const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", DISMISS_TOAST: "DISMISS_TOAST", REMOVE_TOAST: "REMOVE_TOAST", } as const let count = 0 function genId() { count = (count + 1) % Number.MAX_SAFE_INTEGER return count.toString() } type ActionType = typeof actionTypes type Action = | { type: ActionType["ADD_TOAST"] toast: ToasterToast } | { type: ActionType["UPDATE_TOAST"] toast: Partial & { id: string } } | { type: ActionType["DISMISS_TOAST"] toastId?: string } | { type: ActionType["REMOVE_TOAST"] toastId?: string } interface State { toasts: ToasterToast[] } const toastTimeouts = new Map>() // 토스트 자동 제거 함수 const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { return } const timeout = setTimeout(() => { toastTimeouts.delete(toastId) dispatch({ type: actionTypes.REMOVE_TOAST, toastId: toastId, }) }, TOAST_REMOVE_DELAY) toastTimeouts.set(toastId, timeout) } export const reducer = (state: State, action: Action): State => { switch (action.type) { case actionTypes.ADD_TOAST: return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), } case actionTypes.UPDATE_TOAST: return { ...state, toasts: state.toasts.map((t) => t.id === action.toast.id ? { ...t, ...action.toast } : t ), } case actionTypes.DISMISS_TOAST: { const { toastId } = action if (toastId) { addToRemoveQueue(toastId) } else { state.toasts.forEach((toast) => { addToRemoveQueue(toast.id) }) } return { ...state, toasts: state.toasts.map((t) => t.id === toastId || toastId === undefined ? { ...t, open: false, } : t ), } } case actionTypes.REMOVE_TOAST: if (action.toastId === undefined) { return { ...state, toasts: [], } } return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId), } default: return state } } // 전역 상태 및 리스너 const listeners: Array<(state: State) => void> = [] let memoryState: State = { toasts: [] } // 마지막 액션 추적 (중복 방지용) let lastAction: { type: string; id?: string; time: number } | null = null function dispatch(action: Action) { // 마지막 액션 정보 추출 let actionId: string | undefined = undefined; if ('toast' in action && action.toast) { actionId = action.toast.id; } else if ('toastId' in action) { actionId = action.toastId; } // 동일한 토스트에 대한 중복 액션 방지 const now = Date.now(); const isSameAction = lastAction && lastAction.type === action.type && ((action.type === actionTypes.ADD_TOAST && lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지 (action.type !== actionTypes.ADD_TOAST && actionId === lastAction.id && lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지 if (isSameAction) { console.log('중복 토스트 액션 무시:', action.type); return; } // 액션 추적 업데이트 lastAction = { type: action.type, id: actionId, time: now }; // 실제 상태 업데이트 및 리스너 호출 memoryState = reducer(memoryState, action) listeners.forEach((listener) => { listener(memoryState) }) } type Toast = Omit function toast({ ...props }: Toast) { const id = genId() const update = (props: ToasterToast) => dispatch({ type: actionTypes.UPDATE_TOAST, toast: { ...props, id }, }) const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }) dispatch({ type: actionTypes.ADD_TOAST, toast: { ...props, id, open: true, onOpenChange: (open) => { if (!open) dismiss() }, duration: props.duration || 3000, // 기본 지속 시간 3초로 단축 }, }) return { id, dismiss, update, } } function 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: actionTypes.DISMISS_TOAST, toastId }), } } export { useToast, toast }