From 489a925b5cf3f1102d17536f6feae73caca8fe89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=A8=EC=A0=95=EC=9A=B1?= <113816822+HelloWook@users.noreply.github.com> Date: Thu, 31 Oct 2024 16:04:01 +0900 Subject: [PATCH] =?UTF-8?q?64=20feat=20toast=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EA=B5=AC=ED=98=84=20(#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : Toast 컴포넌트 구현 * feat : Toast 컨테이너 구현 * feat : 전역적으로 토스트 관리 --- src/app/App.tsx | 2 + src/features/Toast/hooks/useToastStore.ts | 39 +++++++++++ src/features/Toast/model/type.ts | 5 ++ src/features/Toast/ui/Toast.stories.ts | 25 ++++++++ src/features/Toast/ui/Toast.styeld.ts | 64 +++++++++++++++++++ src/features/Toast/ui/Toast.tsx | 48 ++++++++++++++ src/features/Toast/ui/ToastContainer.tsx | 27 ++++++++ .../Toast/ui/toastContainer.styled.tsx | 8 +++ src/shared/ui/Modal/Modal.styled.ts | 19 +++++- 9 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/features/Toast/hooks/useToastStore.ts create mode 100644 src/features/Toast/model/type.ts create mode 100644 src/features/Toast/ui/Toast.stories.ts create mode 100644 src/features/Toast/ui/Toast.styeld.ts create mode 100644 src/features/Toast/ui/Toast.tsx create mode 100644 src/features/Toast/ui/ToastContainer.tsx create mode 100644 src/features/Toast/ui/toastContainer.styled.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 865deab..10bead7 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import GlobalStyles from './styles/globalStyles'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import ToastContainer from '@/features/Toast/ui/ToastContainer'; const queryClient = new QueryClient(); @@ -8,6 +9,7 @@ const App: React.FC = () => { return ( + ); }; diff --git a/src/features/Toast/hooks/useToastStore.ts b/src/features/Toast/hooks/useToastStore.ts new file mode 100644 index 0000000..186e85c --- /dev/null +++ b/src/features/Toast/hooks/useToastStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; +import { Toast } from '../model/type'; + +interface ToastStore { + toasts: Toast[]; + addToast: ( + message: string, + variant: 'success' | 'warning' | 'error' + ) => void; + removeToast: (id: number) => void; +} + +/** + * addToasdt새로운 Toast 메시지를 추가하고 2초 후 자동 제거 + * 호출 시 + * const { addToast } = useToastStore(); 불러와서 + * () => addToast('Success message!', 'success') 형식으로 사용해주세요 + * @param message - 표시할 메시지 내용 + * @param variant - 메시지 타입 ('success' | 'warning' | 'error') + */ +export const useToastStore = create((set) => ({ + toasts: [], + addToast: (message, variant) => { + const id = Date.now(); + set((state) => ({ + toasts: [...state.toasts, { id, message, variant }] + })); + + setTimeout(() => { + set((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id) + })); + }, 2000); + }, + removeToast: (id) => + set((state) => ({ + toasts: state.toasts.filter((toast) => toast.id !== id) + })) +})); diff --git a/src/features/Toast/model/type.ts b/src/features/Toast/model/type.ts new file mode 100644 index 0000000..56db3b4 --- /dev/null +++ b/src/features/Toast/model/type.ts @@ -0,0 +1,5 @@ +export interface Toast { + id: number; + variant: 'success' | 'warning' | 'error'; + message: string; +} diff --git a/src/features/Toast/ui/Toast.stories.ts b/src/features/Toast/ui/Toast.stories.ts new file mode 100644 index 0000000..0b510f0 --- /dev/null +++ b/src/features/Toast/ui/Toast.stories.ts @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Toast from './Toast'; + +const meta: Meta = { + component: Toast, + title: 'features/UI/Toast', + tags: ['autodocs'], + argTypes: {}, +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {children :"화이팅"}, +}; + +export const Waring: Story = { + args: {children :"화이팅",variant: "warning"}, + }; + + export const Error: Story = { + args: {children :"화이팅", variant : 'error'}, + }; \ No newline at end of file diff --git a/src/features/Toast/ui/Toast.styeld.ts b/src/features/Toast/ui/Toast.styeld.ts new file mode 100644 index 0000000..23a0e92 --- /dev/null +++ b/src/features/Toast/ui/Toast.styeld.ts @@ -0,0 +1,64 @@ +import React from 'react'; +import styled, { keyframes } from 'styled-components'; + +interface ToastStyledProps { + variant: 'success' | 'warning' | 'error'; +} + +const slideIn = keyframes` + 0% { + opacity: 0; + transform: translateY(-20px); + } + 10% { + opacity: 1; + transform: translateY(0); + } + 90% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-20px); + } +`; + +export const ToastStyled = styled.div` + display: flex; + align-items: center; + justify-content: center; + color: #ffffff; + width: 514.05px; + height: 77px; + padding: 0 20px; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); + border-radius: 6px; + font-family: 'Lato', sans-serif; + font-weight: 400; + font-size: 22px; + letter-spacing: 0.7125px; + position: relative; + margin-bottom: 10px; + background-color: ${({ variant }) => { + switch (variant) { + case 'success': + return '#3BDE86'; + case 'warning': + return '#FB4242'; + case 'error': + return '#FED046'; + default: + return '#ffffff'; + } + }}; + animation: ${slideIn} 2s ease forwards; +`; +export const ToastMessageStlyed = styled.span` + text-align: center; + text-overflow: ellipsis; + display: inline-block; + white-space: nowrap; + overflow: hidden; + width: 350px; +`; diff --git a/src/features/Toast/ui/Toast.tsx b/src/features/Toast/ui/Toast.tsx new file mode 100644 index 0000000..d245b4b --- /dev/null +++ b/src/features/Toast/ui/Toast.tsx @@ -0,0 +1,48 @@ +// components/Toast.tsx +import React from 'react'; +import { ToastStyled, ToastMessageStlyed } from './Toast.styeld'; +import { IoIosWarning, IoMdClose } from 'react-icons/io'; +import { FaCheck } from 'react-icons/fa6'; +import { MdOutlineError } from 'react-icons/md'; + +interface ToastProps { + children: React.ReactNode; + variant: 'success' | 'warning' | 'error'; + onClose: () => void; +} + +const IconStyle = { + width: 35, + height: 35, + position: 'absolute' as const, + left: 30, + top: 20 +}; + +const CloseStlye = { + width: 35, + height: 35, + position: 'absolute' as const, + right: 30, + top: 20 +}; + +const iconComponents = { + success: , + warning: , + error: +}; + +const Toast = ({ children, variant = 'success', onClose }: ToastProps) => { + const Icon = iconComponents[variant]; + + return ( + + {Icon} + {children} + + + ); +}; + +export default Toast; diff --git a/src/features/Toast/ui/ToastContainer.tsx b/src/features/Toast/ui/ToastContainer.tsx new file mode 100644 index 0000000..6f98d08 --- /dev/null +++ b/src/features/Toast/ui/ToastContainer.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { useToastStore } from '../hooks/useToastStore'; +import Toast from './Toast'; +import { ToastContainerStyled } from './toastContainer.styled'; + +const ToastContainer = () => { + const { toasts, removeToast } = useToastStore(); + + return ( + + {toasts + .slice() + .reverse() + .map((toast) => ( + removeToast(toast.id)} + > + {toast.message} + + ))} + + ); +}; + +export default ToastContainer; diff --git a/src/features/Toast/ui/toastContainer.styled.tsx b/src/features/Toast/ui/toastContainer.styled.tsx new file mode 100644 index 0000000..2a9ffd9 --- /dev/null +++ b/src/features/Toast/ui/toastContainer.styled.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const ToastContainerStyled = styled.div` + position: fixed; + margin-bottom: 20px; + top: 20px; + right: 20px; +`; diff --git a/src/shared/ui/Modal/Modal.styled.ts b/src/shared/ui/Modal/Modal.styled.ts index 5c6baa5..77a5662 100644 --- a/src/shared/ui/Modal/Modal.styled.ts +++ b/src/shared/ui/Modal/Modal.styled.ts @@ -1,22 +1,37 @@ -import styled from 'styled-components'; +import styled, { keyframes } from 'styled-components'; + +const fadeIn = keyframes` + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +`; export const ModalStyled = styled.div` background: white; width: 370px; height: 200px; border-radius: 20px; - box-shadow: inset; padding: 8px; position: relative; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.1); + animation: ${fadeIn} 2 ease forwards; + h1 { margin-bottom: 30px; } + svg { font-size: 30px; position: absolute; top: 18px; right: 20px; color: #838383; + cursor: pointer; } `;