Skip to content

Commit

Permalink
[FE] feat: 토스트 구현 (#608)
Browse files Browse the repository at this point in the history
* feat: 토스트 구현

* feat: portal 구현

* chore: 파일 이름 변경

* chore: 변경된 파일 이름에 따라 import명 변경

* refactor: animation 분리

* feat: 에러일 때 스타일 추가

* refactor: 기본, 에러 버전 구현

* refactor: 리뷰 반영

* refactor: toast context로 변경

* refactor: context action과 value로 분리

* refactor: toast context에서 portal 실행되도록 변경

* refactor: portal 위치 변경

* refactor: context를 사용해 스토리북 변경

* chore: 필요없는 fragment 삭제

* refactor: 구조 분해 할당으로 변경

* refactor: style 이름 변경

* refactor: 사용하지 않는 action 삭제

* refactor: useToast로 분리

Co-authored-by: Leejin Yang <[email protected]>

* chore: 사용하지 않는 테스트 버튼 삭제

---------

Co-authored-by: Leejin Yang <[email protected]>
  • Loading branch information
hae-on and Leejin-Yang authored Oct 6, 2023
1 parent 062ca02 commit a1a27dc
Show file tree
Hide file tree
Showing 16 changed files with 1,593 additions and 1,299 deletions.
1 change: 1 addition & 0 deletions frontend/.storybook/preview-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,4 @@
</symbol>
</svg>
</div>
<div id="toast-container"></div>
1 change: 1 addition & 0 deletions frontend/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@
</head>
<body>
<div id="root"></div>
<div id="toast-container"></div>
</body>
</html>
2 changes: 1 addition & 1 deletion frontend/public/mockServiceWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/* tslint:disable */

/**
* Mock Service Worker (1.3.0).
* Mock Service Worker (1.3.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/Common/Toast/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/react';

import Toast from './Toast';

import ToastProvider from '@/contexts/ToastContext';
import { useToastActionContext } from '@/hooks/context';

const meta: Meta<typeof Toast> = {
title: 'common/Toast',
component: Toast,
decorators: [
(Story) => (
<ToastProvider>
<Story />
</ToastProvider>
),
],
};

export default meta;
type Story = StoryObj<typeof Toast>;

export const Default: Story = {
render: () => {
const { toast } = useToastActionContext();
const handleClick = () => {
toast.success('성공');
};
return (
<div style={{ width: '375px' }}>
<button onClick={handleClick}>토스트 성공</button>
</div>
);
},
};

export const Error: Story = {
render: () => {
const { toast } = useToastActionContext();
const handleClick = () => {
toast.error('실패');
};
return (
<div style={{ width: '375px' }}>
<button onClick={handleClick}>토스트 에러</button>
</div>
);
},
};
41 changes: 41 additions & 0 deletions frontend/src/components/Common/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Text, useTheme } from '@fun-eat/design-system';
import styled from 'styled-components';

import { useToast } from '@/hooks/common';
import { fadeOut, slideIn } from '@/styles/animations';

interface ToastProps {
id: number;
message: string;
isError?: boolean;
}

const Toast = ({ id, message, isError = false }: ToastProps) => {
const theme = useTheme();
const isShown = useToast(id);

return (
<ToastWrapper isError={isError} isAnimating={isShown}>
<Message color={theme.colors.white}>{message}</Message>
</ToastWrapper>
);
};

export default Toast;

type ToastStyleProps = Pick<ToastProps, 'isError'> & { isAnimating?: boolean };

const ToastWrapper = styled.div<ToastStyleProps>`
position: relative;
width: 100%;
height: 55px;
max-width: 560px;
border-radius: 10px;
background: ${({ isError, theme }) => (isError ? theme.colors.error : theme.colors.black)};
animation: ${({ isAnimating }) => (isAnimating ? slideIn : fadeOut)} 0.3s ease-in-out forwards;
`;

const Message = styled(Text)`
margin-left: 20px;
line-height: 55px;
`;
1 change: 1 addition & 0 deletions frontend/src/components/Common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { default as MarkedText } from './MarkedText/MarkedText';
export { default as NavigableSectionTitle } from './NavigableSectionTitle/NavigableSectionTitle';
export { default as Carousel } from './Carousel/Carousel';
export { default as RegisterButton } from './RegisterButton/RegisterButton';
export { default as Toast } from './Toast/Toast';
export { default as CategoryItem } from './CategoryItem/CategoryItem';
export { default as CategoryFoodList } from './CategoryFoodList/CategoryFoodList';
export { default as CategoryStoreList } from './CategoryStoreList/CategoryStoreList';
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { PropsWithChildren } from 'react';
import { createContext, useState } from 'react';
import { createPortal } from 'react-dom';
import styled from 'styled-components';

import { Toast } from '@/components/Common';

interface ToastState {
id: number;
message: string;
isError?: boolean;
}

interface ToastValue {
toasts: ToastState[];
}
interface ToastAction {
toast: {
success: (message: string) => void;
error: (message: string) => void;
};
deleteToast: (id: number) => void;
}

export const ToastValueContext = createContext<ToastValue | null>(null);
export const ToastActionContext = createContext<ToastAction | null>(null);

const ToastProvider = ({ children }: PropsWithChildren) => {
const [toasts, setToasts] = useState<ToastState[]>([]);

const showToast = (id: number, message: string, isError?: boolean) => {
setToasts([...toasts, { id, message, isError }]);
};

const deleteToast = (id: number) => {
setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id));
};

const toast = {
success: (message: string) => showToast(Number(Date.now()), message),
error: (message: string) => showToast(Number(Date.now()), message, true),
};

const toastValue = {
toasts,
};

const toastAction = {
toast,
deleteToast,
};

return (
<ToastActionContext.Provider value={toastAction}>
<ToastValueContext.Provider value={toastValue}>
{children}
{createPortal(
<ToastContainer>
{toasts.map(({ id, message, isError }) => (
<Toast key={id} id={id} message={message} isError={isError} />
))}
</ToastContainer>,
document.getElementById('toast-container') as HTMLElement
)}
</ToastValueContext.Provider>
</ToastActionContext.Provider>
);
};

export default ToastProvider;

const ToastContainer = styled.div`
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
width: calc(100% - 20px);
transform: translate(0, -10px);
`;
2 changes: 2 additions & 0 deletions frontend/src/hooks/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ export { default as useTimeout } from './useTimeout';
export { default as useRouteChangeTracker } from './useRouteChangeTracker';
export { default as useTabMenu } from './useTabMenu';
export { default as useScrollRestoration } from './useScrollRestoration';
export { default as useToast } from './useToast';
export { default as useGA } from './useGA';

37 changes: 37 additions & 0 deletions frontend/src/hooks/common/useToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from 'react';

import { useToastActionContext } from '../context';

const useToast = (id: number) => {
const { deleteToast } = useToastActionContext();
const [isShown, setIsShown] = useState(true);

const showTimeoutRef = useRef<number | null>(null);
const deleteTimeoutRef = useRef<number | null>(null);

useEffect(() => {
showTimeoutRef.current = window.setTimeout(() => setIsShown(false), 2000);

return () => {
if (showTimeoutRef.current) {
clearTimeout(showTimeoutRef.current);
}
};
}, []);

useEffect(() => {
if (!isShown) {
deleteTimeoutRef.current = window.setTimeout(() => deleteToast(id), 2000);
}

return () => {
if (deleteTimeoutRef.current) {
clearTimeout(deleteTimeoutRef.current);
}
};
}, [isShown]);

return isShown;
};

export default useToast;
2 changes: 2 additions & 0 deletions frontend/src/hooks/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export { default as useReviewFormActionContext } from './useReviewFormActionCont
export { default as useReviewFormValueContext } from './useReviewFormValueContext';
export { default as useRecipeFormActionContext } from './useRecipeFormActionContext';
export { default as useRecipeFormValueContext } from './useRecipeFormValueContext';
export { default as useToastActionContext } from './useToastActionContext';
export { default as useToastValueContext } from './useToastValueContext';
14 changes: 14 additions & 0 deletions frontend/src/hooks/context/useToastActionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useContext } from 'react';

import { ToastActionContext } from '@/contexts/ToastContext';

const useToastActionContext = () => {
const toastAction = useContext(ToastActionContext);
if (toastAction === null || toastAction === undefined) {
throw new Error('useToastActionContext는 Toast Provider 안에서 사용해야 합니다.');
}

return toastAction;
};

export default useToastActionContext;
14 changes: 14 additions & 0 deletions frontend/src/hooks/context/useToastValueContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useContext } from 'react';

import { ToastValueContext } from '@/contexts/ToastContext';

const useToastValueContext = () => {
const toastValue = useContext(ToastValueContext);
if (toastValue === null || toastValue === undefined) {
throw new Error('useToastValueContext는 Toast Provider 안에서 사용해야 합니다.');
}

return toastValue;
};

export default useToastValueContext;
7 changes: 5 additions & 2 deletions frontend/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { RouterProvider } from 'react-router-dom';

import { SvgSprite } from './components/Common';
import { ENVIRONMENT } from './constants';
import ToastProvider from './contexts/ToastContext';
import router from './router';
import GlobalStyle from './styles';
import GlobalStyle from './styles/globalStyle';

const initializeReactGA = () => {
if (process.env.NODE_ENV === 'development') return;
Expand Down Expand Up @@ -42,7 +43,9 @@ root.render(
<FunEatProvider>
<SvgSprite />
<GlobalStyle />
<RouterProvider router={router} fallbackElement={<p>...loading</p>} />
<ToastProvider>
<RouterProvider router={router} fallbackElement={<p>...loading</p>} />
</ToastProvider>
</FunEatProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/styles/animations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { keyframes } from 'styled-components';

export const slideIn = keyframes`
0% {
transform: translateY(-100px);
}
100% {
transform: translateY(70px);
}
`;

export const fadeOut = keyframes`
0% {
transform: translateY(70px);
opacity: 1;
}
100% {
transform: translateY(70px);
opacity:0;
}
`;
File renamed without changes.
Loading

0 comments on commit a1a27dc

Please sign in to comment.