diff --git a/frontend/src/assets/images/attendeeCheck.svg b/frontend/src/assets/images/attendeeCheck.svg index da7adaf8..1c6c1615 100644 --- a/frontend/src/assets/images/attendeeCheck.svg +++ b/frontend/src/assets/images/attendeeCheck.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/src/assets/images/check.svg b/frontend/src/assets/images/check.svg index 6551c56b..eea4cb0e 100644 --- a/frontend/src/assets/images/check.svg +++ b/frontend/src/assets/images/check.svg @@ -1,3 +1,3 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/assets/images/exclamation.svg b/frontend/src/assets/images/exclamation.svg new file mode 100644 index 00000000..e2c8b5e9 --- /dev/null +++ b/frontend/src/assets/images/exclamation.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/_common/Toast/Toast.stories.tsx b/frontend/src/components/_common/Toast/Toast.stories.tsx new file mode 100644 index 00000000..cd59aab0 --- /dev/null +++ b/frontend/src/components/_common/Toast/Toast.stories.tsx @@ -0,0 +1,158 @@ +import { css } from '@emotion/react'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ToastProvider from '@contexts/ToastProvider'; + +import useToast from '@hooks/useToast/useToast'; + +import Toast from '.'; +import { Button } from '../Buttons/Button'; + +const meta = { + title: 'Components/Toast', + component: Toast, + parameters: { + layout: 'centered', + }, + argTypes: { + message: { + control: { + type: 'text', + }, + description: '토스트 UI를 통해 사용자에게 전달할 메시지입니다.', + }, + type: { + control: { + type: 'select', + }, + options: ['default', 'warning', 'success'], + }, + isOpen: { + control: { + type: 'boolean', + }, + description: '토스트 UI에 애니메이션을 적용하기 위한 추가 상태입니다.', + }, + }, + + decorators: [ + (Story) => { + return ( + +
+ +
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isOpen: true, + type: 'default', + message: '안녕하세요, 내 이름은 기본 토스트입니다.', + }, + + render: (args) => { + return ; + }, +}; + +export const Warning: Story = { + args: { + isOpen: true, + type: 'warning', + message: '안녕하세요, 내 이름은 경고 토스트입니다.', + }, + + render: (args) => { + return ; + }, +}; + +export const Success: Story = { + args: { + isOpen: true, + type: 'success', + message: '안녕하세요, 내 이름은 성공 토스트입니다.', + }, + + render: (args) => { + return ; + }, +}; + +export const ToastPlayground: Story = { + render: () => { + const { addToast } = useToast(); + + const renderDefaultToast = () => { + addToast({ + type: 'default', + message: '안녕하세요, 내 이름은 기본 토스트입니다', + duration: 3000, + }); + }; + + const renderSuccessToast = () => { + addToast({ + type: 'success', + message: '안녕하세요, 내 이름은 성공 토스트입니다', + duration: 3000, + }); + }; + + const renderWarningToast = () => { + addToast({ + type: 'warning', + message: '안녕하세요, 내 이름은 경고 토스트입니다', + duration: 3000, + }); + }; + + return ( +
+
+ + + +
+
+ ); + }, +}; diff --git a/frontend/src/components/_common/Toast/Toast.styles.ts b/frontend/src/components/_common/Toast/Toast.styles.ts new file mode 100644 index 00000000..c81ea2b6 --- /dev/null +++ b/frontend/src/components/_common/Toast/Toast.styles.ts @@ -0,0 +1,67 @@ +import type { SerializedStyles } from '@emotion/react'; +import { css, keyframes } from '@emotion/react'; + +import theme from '@styles/theme'; + +import type { ToastType } from './Toast.type'; + +const toastSlideIn = keyframes` + from{ + opacity: 0; + }to{ + opacity: 1; + } +`; + +const toastSlideOut = keyframes` + from{ + opacity: 1; + }to{ + opacity: 0; + } +`; + +export const s_toastContainer = (isOpen: boolean) => css` + display: flex; + gap: 1.2rem; + align-items: center; + + width: 100%; + height: 4.8rem; + padding: 1.2rem; + + background-color: #a1a1aa; + border-radius: 1.6rem; + box-shadow: 0 0.4rem 0.4rem rgb(0 0 0 / 20%); + + animation: ${isOpen ? toastSlideIn : toastSlideOut} 0.5s ease-in-out forwards; +`; + +export const s_toastText = css` + ${theme.typography.captionBold} + color: ${theme.colors.white}; +`; + +const ICON_BACKGROUND_COLORS: Record, SerializedStyles> = { + warning: css` + background-color: #ef4545; + `, + success: css` + background-color: ${theme.colors.green.mediumDark}; + `, +}; + +export const s_iconBackgroundColor = (type: Exclude) => { + return ICON_BACKGROUND_COLORS[type]; +}; + +export const s_iconContainer = css` + display: flex; + align-items: center; + justify-content: center; + + width: 2.4rem; + height: 2.4rem; + + border-radius: 50%; +`; diff --git a/frontend/src/components/_common/Toast/Toast.type.ts b/frontend/src/components/_common/Toast/Toast.type.ts new file mode 100644 index 00000000..c82cec64 --- /dev/null +++ b/frontend/src/components/_common/Toast/Toast.type.ts @@ -0,0 +1 @@ +export type ToastType = 'default' | 'warning' | 'success'; diff --git a/frontend/src/components/_common/Toast/ToastContainer.tsx b/frontend/src/components/_common/Toast/ToastContainer.tsx new file mode 100644 index 00000000..ab7b8140 --- /dev/null +++ b/frontend/src/components/_common/Toast/ToastContainer.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; + +import Toast from '.'; +import type { ToastType } from './Toast.type'; + +interface ToastContainerProps { + duration?: number; + type: ToastType; + message: string; +} + +const TOAST_ANIMATION_DURATION_TIME = 500; + +export default function ToastContainer({ type, message, duration = 3000 }: ToastContainerProps) { + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + const animationTimer = setTimeout(() => { + setIsOpen(false); + }, duration - TOAST_ANIMATION_DURATION_TIME); + + return () => { + clearTimeout(animationTimer); + }; + }, [duration]); + + return ; +} diff --git a/frontend/src/components/_common/Toast/ToastList/ToastList.styles.ts b/frontend/src/components/_common/Toast/ToastList/ToastList.styles.ts new file mode 100644 index 00000000..a40991f2 --- /dev/null +++ b/frontend/src/components/_common/Toast/ToastList/ToastList.styles.ts @@ -0,0 +1,19 @@ +import { css } from '@emotion/react'; + +export const s_toastListContainer = css` + position: fixed; + z-index: 3; + top: 9rem; + left: 50%; + transform: translateX(-50%); + + display: flex; + flex-direction: column; + gap: 1.2rem; + align-items: center; + justify-content: center; + + width: 100%; + max-width: 43rem; + padding: 1.6rem; +`; diff --git a/frontend/src/components/_common/Toast/ToastList/ToastList.tsx b/frontend/src/components/_common/Toast/ToastList/ToastList.tsx new file mode 100644 index 00000000..ec0fa243 --- /dev/null +++ b/frontend/src/components/_common/Toast/ToastList/ToastList.tsx @@ -0,0 +1,20 @@ +import { createPortal } from 'react-dom'; + +import useToast from '@hooks/useToast/useToast'; + +import ToastContainer from '../ToastContainer'; +import { s_toastListContainer } from './ToastList.styles'; + +export default function ToastList() { + const { toasts } = useToast(); + + return createPortal( +
+ {toasts && + toasts.map(({ id, type, message, duration }) => ( + + ))} +
, + document.body, + ); +} diff --git a/frontend/src/components/_common/Toast/index.tsx b/frontend/src/components/_common/Toast/index.tsx new file mode 100644 index 00000000..15229c55 --- /dev/null +++ b/frontend/src/components/_common/Toast/index.tsx @@ -0,0 +1,40 @@ +import Check from '@assets/images/attendeeCheck.svg'; +import Exclamation from '@assets/images/exclamation.svg'; + +import theme from '@styles/theme'; + +import { + s_iconBackgroundColor, + s_iconContainer, + s_toastContainer, + s_toastText, +} from './Toast.styles'; +import type { ToastType } from './Toast.type'; + +interface ToastProps { + isOpen: boolean; + type: ToastType; + message: string; +} + +const iconMap: Record> | null> = { + default: null, + success: Check, + warning: Exclamation, +}; + +// 토스트 컴포넌트는 UI를 보여주는 책임만 가질 수 있도록 최대한 책임을 분리하고 스토리북을 활용한 UI 테스트를 쉽게할 수 있도록 한다.(@해리) +export default function Toast({ isOpen, type = 'default', message }: ToastProps) { + const ToastIcon = iconMap[type]; + + return ( +
+ {type !== 'default' && ( +
+ {ToastIcon && } +
+ )} +

{message}

+
+ ); +} diff --git a/frontend/src/contexts/ToastProvider.tsx b/frontend/src/contexts/ToastProvider.tsx new file mode 100644 index 00000000..76c03ab9 --- /dev/null +++ b/frontend/src/contexts/ToastProvider.tsx @@ -0,0 +1,56 @@ +import type { PropsWithChildren } from 'react'; +import { useState } from 'react'; +import { createContext } from 'react'; + +import type { ToastType } from '@components/_common/Toast/Toast.type'; +import ToastList from '@components/_common/Toast/ToastList/ToastList'; + +interface ToastState { + id: number; + type: ToastType; + message: string; + duration?: number; +} + +interface ToastContextType { + toasts: ToastState[]; + addToast: ({ type, message, duration }: Omit) => void; +} + +export const ToastContext = createContext(null); + +export default function ToastProvider({ children }: PropsWithChildren) { + const [toasts, setToasts] = useState([]); + + const checkAlreadyRenderedToast = (toastMessage: string) => { + return toasts.find(({ message }) => message === toastMessage); + }; + + const removeToast = (toastId: number) => { + setToasts((prevToasts) => prevToasts.filter(({ id }) => id !== toastId)); + }; + + const addToast = ({ type, message, duration }: Omit) => { + if (checkAlreadyRenderedToast(message)) return; + + const toastId = Date.now(); + const newToastState = { + id: toastId, + type, + message, + duration, + }; + + setToasts((prevToasts) => [...prevToasts, newToastState]); + setTimeout(() => { + removeToast(toastId); + }, duration); + }; + + return ( + + + {children} + + ); +} diff --git a/frontend/src/hooks/useToast/useToast.ts b/frontend/src/hooks/useToast/useToast.ts new file mode 100644 index 00000000..78e05176 --- /dev/null +++ b/frontend/src/hooks/useToast/useToast.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react'; + +import { ToastContext } from '@contexts/ToastProvider'; + +const useToast = () => { + const toastContext = useContext(ToastContext); + + if (!toastContext) { + throw new Error('ToastContext를 사용할 수 없는 컴포넌트입니다.'); + } + + const { toasts, addToast } = toastContext; + + return { toasts, addToast }; +}; + +export default useToast; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 10d6dd62..0bfb0a33 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -5,6 +5,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import ReactDOM from 'react-dom/client'; +import ToastProvider from '@contexts/ToastProvider'; + import globalStyles from '@styles/global'; import theme from '@styles/theme'; @@ -51,7 +53,9 @@ enableMocking().then(() => { - + + + ,