diff --git a/FE/src/App.tsx b/FE/src/App.tsx index ca07e6c..443aba9 100644 --- a/FE/src/App.tsx +++ b/FE/src/App.tsx @@ -7,6 +7,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import GlobalSVGProvider from '@/GlobalSvgProvider'; import AppRouter from '@/AppRouter'; import ModalProvider from './modalProvider'; +import { ToastContainer } from '@foundation/Toast/ToastContainer'; function App() { const queryClient = new QueryClient(); @@ -20,6 +21,7 @@ function App() { + diff --git a/FE/src/GlobalSvgProvider.tsx b/FE/src/GlobalSvgProvider.tsx index 6aa1f86..8e2902b 100644 --- a/FE/src/GlobalSvgProvider.tsx +++ b/FE/src/GlobalSvgProvider.tsx @@ -322,6 +322,30 @@ const spliteSvgCode = ( strokeLinejoin="round" /> + + + + + + + + + + + + ); diff --git a/FE/src/components/foundation/Toast/Toast.styles.ts b/FE/src/components/foundation/Toast/Toast.styles.ts new file mode 100644 index 0000000..6e2fcc9 --- /dev/null +++ b/FE/src/components/foundation/Toast/Toast.styles.ts @@ -0,0 +1,80 @@ +import { css, keyframes } from '@emotion/react'; +import { theme } from '@styles/theme'; + +const TOAST_GAP = '0.75rem'; +export const ToastProgressBarStyle = { + info: css` + background-color: ${theme.colors.toast.info}; + `, + success: css` + background-color: ${theme.colors.toast.success}; + `, + warning: css` + background-color: ${theme.colors.toast.warning}; + `, + error: css` + background-color: ${theme.colors.toast.error}; + `, + default: css` + background-color: ${theme.colors.toast.default}; + `, +}; + +export const ToastTypeIconName = { + info: 'info-round', + success: 'check-round', + warning: 'warning', + error: 'bomb', + default: null, +}; + +export const ToastPositionStyle = { + topLeft: css` + top: ${TOAST_GAP}; + left: ${TOAST_GAP}; + `, + topRight: css` + top: ${TOAST_GAP}; + right: ${TOAST_GAP}; + `, + topCenter: css` + top: ${TOAST_GAP}; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + `, + bottomLeft: css` + bottom: ${TOAST_GAP}; + left: ${TOAST_GAP}; + `, + bottomRight: css` + bottom: ${TOAST_GAP}; + right: ${TOAST_GAP}; + `, + bottomCenter: css` + bottom: ${TOAST_GAP}; + left: 50%; + transform: translateX(-50%); + z-index: 10000; + `, +}; + +export const ToastFadeOutUpAnimation = keyframes` + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-1.5rem); + } +`; + +export const ToastProgressBarAnimation = keyframes` + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +`; diff --git a/FE/src/components/foundation/Toast/ToastContainer.tsx b/FE/src/components/foundation/Toast/ToastContainer.tsx new file mode 100644 index 0000000..54714b8 --- /dev/null +++ b/FE/src/components/foundation/Toast/ToastContainer.tsx @@ -0,0 +1,29 @@ +import { css } from '@emotion/react'; +import { ToastPositionStyle } from '@foundation/Toast/Toast.styles'; +import useToastContainer from '@foundation/Toast/useToastContainer'; +import ToastItem from '@foundation/Toast/ToastItem'; + +export const ToastContainer = () => { + const { getToastPositionGroupToRender } = useToastContainer(); + const positionGroup = getToastPositionGroupToRender(); + + return Array.from(positionGroup).map(([position, toasts]) => ( +
+ {toasts.map((toastProps) => ( + + ))} +
+ )); +}; diff --git a/FE/src/components/foundation/Toast/ToastItem.tsx b/FE/src/components/foundation/Toast/ToastItem.tsx new file mode 100644 index 0000000..4e9f832 --- /dev/null +++ b/FE/src/components/foundation/Toast/ToastItem.tsx @@ -0,0 +1,117 @@ +import { useRef, useState } from 'react'; +import { ToastEvent, ToastProps } from '@foundation/Toast/type'; +import { css } from '@emotion/react'; +import { theme } from '@styles/theme'; +import { Box, Icon } from '@foundation/index'; +import { collapseToast } from '@foundation/Toast/collapseToast'; +import { eventManager } from '@foundation/Toast/eventManger'; +import { + ToastFadeOutUpAnimation, + ToastProgressBarAnimation, + ToastProgressBarStyle, + ToastTypeIconName, +} from '@foundation/Toast/Toast.styles'; + +const ToastItem: React.FC = ({ + toastId, + text, + autoClose = 3000, + closeOnClick = true, + type = 'default', + pauseOnHover = true, +}) => { + const toastRef = useRef(null); + const [isExiting, setIsExiting] = useState(false); + const [isPaused, setIsPaused] = useState(false); + + const handleExitingAnimationEnd = () => { + collapseToast(toastRef.current!, () => { + eventManager.emit(ToastEvent.Delete, toastId); + }); + }; + + const handleProgressAnimationEnd = () => { + autoClose && setIsExiting(true); + }; + + const handleClick = () => { + closeOnClick && handleProgressAnimationEnd(); + }; + + const handleMouseEnter = () => { + pauseOnHover && autoClose && setIsPaused(true); + }; + + const handleMouseLeave = () => { + if (pauseOnHover && autoClose) { + setIsPaused(false); + } + }; + + const IconType = () => + type === 'default' ? null : ( + + ); + + return ( +
+ +
+ + {text} +
+
+ +
+ ); +}; + +export default ToastItem; diff --git a/FE/src/components/foundation/Toast/collapseToast.ts b/FE/src/components/foundation/Toast/collapseToast.ts new file mode 100644 index 0000000..c4ca689 --- /dev/null +++ b/FE/src/components/foundation/Toast/collapseToast.ts @@ -0,0 +1,17 @@ +export function collapseToast( + node: HTMLDivElement, + done: () => void, + duration = 1000 +) { + const { scrollHeight, style } = node; + + requestAnimationFrame(() => { + style.height = scrollHeight + 'px'; + style.transition = `all ${duration}ms`; + + requestAnimationFrame(() => { + style.height = '0'; + setTimeout(done, duration); + }); + }); +} diff --git a/FE/src/components/foundation/Toast/eventManger.ts b/FE/src/components/foundation/Toast/eventManger.ts new file mode 100644 index 0000000..27e8fd2 --- /dev/null +++ b/FE/src/components/foundation/Toast/eventManger.ts @@ -0,0 +1,55 @@ +import { EventManager } from '@foundation/Toast/type'; + +export const eventManager: EventManager = { + list: new Map(), // 이벤트 리스너 저장 + emitQueue: new Map(), // 이벤트 지연을 위한 큐 + + // 새로운 이벤트 리스너 등록 + on(event, callback) { + this.list.has(event) + ? this.list.get(event)!.push(callback) + : this.list.set(event, [callback]); + + return this; + }, + + // 콜백이 있는경우 해당하는 리스너 제거, 없는 경우 이벤트에 대한 모든 리스너 제거 + off(event, callback) { + if (callback) { + const cb = this.list.get(event)?.filter((cb) => cb !== callback); + cb && this.list.set(event, cb); + return this; + } + this.list.delete(event); + + return this; + }, + + //대기중인 이벤트를 취소시킬 때 필요함 + cancelEmit(event) { + const timers = this.emitQueue.get(event); + if (timers) { + timers.forEach(clearTimeout); + this.emitQueue.delete(event); + } + + return this; + }, + + // 이벤트 발생시키기 + // 타입문제는 일단 보류중! (라이브러리 내부도 해결 안되어있음) + emit(event, ...args: never[]) { + this.list.has(event) && + this.list.get(event)!.forEach((callback) => { + const timer = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + return callback(...args); + }, 0); + + this.emitQueue.has(event) + ? this.emitQueue.get(event)!.push(timer) + : this.emitQueue.set(event, [timer]); + }); + }, +}; diff --git a/FE/src/components/foundation/Toast/toast.ts b/FE/src/components/foundation/Toast/toast.ts new file mode 100644 index 0000000..8672b62 --- /dev/null +++ b/FE/src/components/foundation/Toast/toast.ts @@ -0,0 +1,30 @@ +import { ToastEvent, ToastProps, ToastType } from '@foundation/Toast/type'; +import { eventManager } from '@foundation/Toast/eventManger'; + +type ToastFunctionProps = Omit; +type ToastOptions = Omit; +const generateUniqueId = () => { + return Date.now().toString(36) + Math.random().toString(36).substring(4); +}; + +const emitToast = (type: ToastType, toastProps: ToastFunctionProps) => { + const id = generateUniqueId(); + eventManager.emit(ToastEvent.Add, { + ...toastProps, + toastId: id, + type, + }); +}; + +export const toast = { + default: (text: string, toastOptions?: ToastOptions) => + emitToast('default', { text: text, ...toastOptions }), + info: (text: string, toastOptions?: ToastOptions) => + emitToast('info', { text: text, ...toastOptions }), + success: (text: string, toastOptions?: ToastOptions) => + emitToast('success', { text: text, ...toastOptions }), + warning: (text: string, toastOptions?: ToastOptions) => + emitToast('warning', { text: text, ...toastOptions }), + error: (text: string, toastOptions?: ToastOptions) => + emitToast('error', { text: text, ...toastOptions }), +}; diff --git a/FE/src/components/foundation/Toast/type.ts b/FE/src/components/foundation/Toast/type.ts new file mode 100644 index 0000000..85cfe80 --- /dev/null +++ b/FE/src/components/foundation/Toast/type.ts @@ -0,0 +1,43 @@ +import { + ToastPositionStyle, + ToastProgressBarStyle, +} from '@foundation/Toast/Toast.styles'; + +export type ToastPosition = keyof typeof ToastPositionStyle; +export type ToastType = keyof typeof ToastProgressBarStyle; +export type ToastProps = { + toastId: string; + text: string; + autoClose?: false | number; + closeOnClick?: boolean; + type?: ToastType; + pauseOnHover?: boolean; + position?: ToastPosition; +}; + +export const enum ToastEvent { + Add, + Delete, +} + +type OnAddCallback = (props: ToastProps) => void; +type OnDeleteCallback = (id: string) => void; + +export type Callback = OnAddCallback | OnDeleteCallback; + +type TimeoutId = ReturnType; + +export interface EventManager { + list: Map; + emitQueue: Map; + + on(event: ToastEvent.Add, callback: OnAddCallback): EventManager; + on(event: ToastEvent.Delete, callback: OnDeleteCallback): EventManager; + + off(event: ToastEvent.Add, callback: OnAddCallback): EventManager; + off(event: ToastEvent.Delete, callback: OnDeleteCallback): EventManager; + + cancelEmit(event: ToastEvent): EventManager; + emit(event: ToastEvent.Add, props: ToastProps): void; + emit(event: ToastEvent.Delete, id: string): void; +} diff --git a/FE/src/components/foundation/Toast/useToastContainer.ts b/FE/src/components/foundation/Toast/useToastContainer.ts new file mode 100644 index 0000000..9c93173 --- /dev/null +++ b/FE/src/components/foundation/Toast/useToastContainer.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { ToastEvent, ToastPosition, ToastProps } from '@foundation/Toast/type'; +import { eventManager } from '@foundation/Toast/eventManger'; + +const useToastContainer = () => { + const [toastList, setToastList] = useState(new Map()); + + // 토스트 추가 + const addToast = (props: ToastProps) => { + setToastList((prev) => new Map(prev).set(props.toastId, props)); + }; + + // 토스트 삭제 + const deleteToast = (id: string) => { + setToastList((prev) => { + const newMap = new Map(prev); + newMap.delete(id); + return newMap; + }); + }; + + useEffect(() => { + eventManager.on(ToastEvent.Add, addToast); + eventManager.on(ToastEvent.Delete, deleteToast); + + // 컴포넌트 언마운트 시 리스너 해제 + return () => { + eventManager.off(ToastEvent.Add, addToast); + eventManager.off(ToastEvent.Delete, deleteToast); + }; + }, []); + + const toastListToArray = () => { + return Array.from(toastList); + }; + + const getToastPositionGroupToRender = () => { + const list = toastListToArray(); + const positionGroup = new Map(); + list.forEach(([_, toastProps]) => { + const position = toastProps.position || 'topRight'; + positionGroup.has(position) + ? positionGroup.get(position)!.push(toastProps) + : positionGroup.set(position, [toastProps]); + }); + return positionGroup; + }; + + return { getToastPositionGroupToRender }; +}; + +export default useToastContainer; diff --git a/FE/src/styles/_colors.ts b/FE/src/styles/_colors.ts index d097515..6cbab46 100644 --- a/FE/src/styles/_colors.ts +++ b/FE/src/styles/_colors.ts @@ -56,6 +56,17 @@ const colorChips = { red700: '#98382C', red800: '#742B22', red900: '#511E17', + + yellow50: '#F9EFCB', + yellow100: '#F7EABC', + yellow200: '#F4E19E', + yellow300: '#F1D881', + yellow400: '#EDCF63', + yellow500: '#EAC645', + yellow600: '#C5A63A', + yellow700: '#9F872F', + yellow800: '#7A6724', + yellow900: '#544719', } as const; export const colors = { @@ -103,4 +114,11 @@ export const colors = { backdrop: { default: colorChips.shadow400, }, + toast: { + info: colorChips.blue500, + success: colorChips.green500, + warning: colorChips.yellow500, + error: colorChips.red500, + default: colorChips.blue500, + }, };