Skip to content

Commit

Permalink
[NDD-312] Toast 개발 완료 (10h / 8h) (#171)
Browse files Browse the repository at this point in the history
* feat: 토스트 상태 관리를 위한 EventManger 추가

* feat: 토스트 틀 구현 완료

* design: 토스트 타입별 아이콘 추가

* fix: autoClose 옵션 꺼져있을 때 호버 이벤트시 토스트 바로 종료되는 문제 수정

* feat: 토스트 위치 지정 기능 추가

* fix: autoClose 타입 변경

autoClose 속성이 true이면 토스트가 바로 사라지는 문제 발생

* remove: 토스트 업데이트 관련 로직 제거

업데이트는 토스트를 사용해서 promise 등의 상태를 표시해줄 때 필요함.

추후 이 부분이 기획으로 추가된다면 그 때 개발하기

* feat: collapseToast을 적용해서 토스트가 자연스럽게 사라지도록 구현

* refactor: 애니메이션 끝나는 이벤트 훅으로 분리

* feat: 토스트 프로그래스바 추가

* feat: 마우스 호버시 토스트 타이머 멈춤

* refactor: useToastContainer 훅 분리

* rename: EventManger -> eventMangerˆ

* rename: EventManger -> eventManger 변경 후 import 수정

* rename: Toast -> ToastItem

* refactor: toast.type(text) 형식으로 출력할 수 있게 함수 변경

* design: 토스트 타입에 따라서 다른 프로그래스바 색상 추가

* chore: 토스트 옵션의 타입을 옵셔널로 변경

* refactor: useAnimationEnd 훅 제거하고 컴포넌트에 직접 리스너 달기

react는 바닐라가 아니야!!!

* rename: 핸들러 네이밍 수정

* design: 토스트 애니메이션 조정
  • Loading branch information
milk717 authored Dec 7, 2023
1 parent 601e089 commit 6814a34
Show file tree
Hide file tree
Showing 11 changed files with 467 additions and 0 deletions.
2 changes: 2 additions & 0 deletions FE/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -20,6 +21,7 @@ function App() {
<AppRouter queryClient={queryClient} />
<GlobalSVGProvider />
<ModalProvider />
<ToastContainer />
</ThemeProvider>
</RecoilRoot>
</QueryClientProvider>
Expand Down
24 changes: 24 additions & 0 deletions FE/src/GlobalSvgProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,30 @@ const spliteSvgCode = (
strokeLinejoin="round"
/>
</symbol>
<symbol id="check-round" viewBox="0 0 24 24">
<path
d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM16.78 9.7L11.11 15.37C10.97 15.51 10.78 15.59 10.58 15.59C10.38 15.59 10.19 15.51 10.05 15.37L7.22 12.54C6.93 12.25 6.93 11.77 7.22 11.48C7.51 11.19 7.99 11.19 8.28 11.48L10.58 13.78L15.72 8.64C16.01 8.35 16.49 8.35 16.78 8.64C17.07 8.93 17.07 9.4 16.78 9.7Z"
fill="#63B561"
/>
</symbol>
<symbol id="info-round" viewBox="0 0 24 24">
<path
d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM11.25 8C11.25 7.59 11.59 7.25 12 7.25C12.41 7.25 12.75 7.59 12.75 8V13C12.75 13.41 12.41 13.75 12 13.75C11.59 13.75 11.25 13.41 11.25 13V8ZM12.92 16.38C12.87 16.51 12.8 16.61 12.71 16.71C12.61 16.8 12.5 16.87 12.38 16.92C12.26 16.97 12.13 17 12 17C11.87 17 11.74 16.97 11.62 16.92C11.5 16.87 11.39 16.8 11.29 16.71C11.2 16.61 11.13 16.51 11.08 16.38C11.03 16.26 11 16.13 11 16C11 15.87 11.03 15.74 11.08 15.62C11.13 15.5 11.2 15.39 11.29 15.29C11.39 15.2 11.5 15.13 11.62 15.08C11.86 14.98 12.14 14.98 12.38 15.08C12.5 15.13 12.61 15.2 12.71 15.29C12.8 15.39 12.87 15.5 12.92 15.62C12.97 15.74 13 15.87 13 16C13 16.13 12.97 16.26 12.92 16.38Z"
fill="#477FEE"
/>
</symbol>
<symbol id="warning" viewBox="0 0 24 24">
<path
d="M21.76 15.92L15.36 4.4C14.5 2.85 13.31 2 12 2C10.69 2 9.49998 2.85 8.63998 4.4L2.23998 15.92C1.42998 17.39 1.33998 18.8 1.98998 19.91C2.63998 21.02 3.91998 21.63 5.59998 21.63H18.4C20.08 21.63 21.36 21.02 22.01 19.91C22.66 18.8 22.57 17.38 21.76 15.92ZM11.25 9C11.25 8.59 11.59 8.25 12 8.25C12.41 8.25 12.75 8.59 12.75 9V14C12.75 14.41 12.41 14.75 12 14.75C11.59 14.75 11.25 14.41 11.25 14V9ZM12.71 17.71C12.66 17.75 12.61 17.79 12.56 17.83C12.5 17.87 12.44 17.9 12.38 17.92C12.32 17.95 12.26 17.97 12.19 17.98C12.13 17.99 12.06 18 12 18C11.94 18 11.87 17.99 11.8 17.98C11.74 17.97 11.68 17.95 11.62 17.92C11.56 17.9 11.5 17.87 11.44 17.83C11.39 17.79 11.34 17.75 11.29 17.71C11.11 17.52 11 17.26 11 17C11 16.74 11.11 16.48 11.29 16.29C11.34 16.25 11.39 16.21 11.44 16.17C11.5 16.13 11.56 16.1 11.62 16.08C11.68 16.05 11.74 16.03 11.8 16.02C11.93 15.99 12.07 15.99 12.19 16.02C12.26 16.03 12.32 16.05 12.38 16.08C12.44 16.1 12.5 16.13 12.56 16.17C12.61 16.21 12.66 16.25 12.71 16.29C12.89 16.48 13 16.74 13 17C13 17.26 12.89 17.52 12.71 17.71Z"
fill="#EAC645"
/>
</symbol>
<symbol id="bomb" viewBox="0 0 512 512">
<path
fill="#E05241"
d="M459.1 52.4L442.6 6.5C440.7 2.6 436.5 0 432.1 0s-8.5 2.6-10.4 6.5L405.2 52.4l-46 16.8c-4.3 1.6-7.3 5.9-7.2 10.4c0 4.5 3 8.7 7.2 10.2l45.7 16.8 16.8 45.8c1.5 4.4 5.8 7.5 10.4 7.5s8.9-3.1 10.4-7.5l16.5-45.8 45.7-16.8c4.2-1.5 7.2-5.7 7.2-10.2c0-4.6-3-8.9-7.2-10.4L459.1 52.4zm-132.4 53c-12.5-12.5-32.8-12.5-45.3 0l-2.9 2.9C256.5 100.3 232.7 96 208 96C93.1 96 0 189.1 0 304S93.1 512 208 512s208-93.1 208-208c0-24.7-4.3-48.5-12.2-70.5l2.9-2.9c12.5-12.5 12.5-32.8 0-45.3l-80-80zM200 192c-57.4 0-104 46.6-104 104v8c0 8.8-7.2 16-16 16s-16-7.2-16-16v-8c0-75.1 60.9-136 136-136h8c8.8 0 16 7.2 16 16s-7.2 16-16 16h-8z"
/>
</symbol>
</svg>
);

Expand Down
80 changes: 80 additions & 0 deletions FE/src/components/foundation/Toast/Toast.styles.ts
Original file line number Diff line number Diff line change
@@ -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);
}
`;
29 changes: 29 additions & 0 deletions FE/src/components/foundation/Toast/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -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]) => (
<div
key={position}
css={[
css`
position: fixed;
display: flex;
flex-direction: column;
row-gap: 0.5rem;
z-index: 9999;
`,
ToastPositionStyle[position],
]}
>
{toasts.map((toastProps) => (
<ToastItem key={toastProps.toastId} {...toastProps} />
))}
</div>
));
};
117 changes: 117 additions & 0 deletions FE/src/components/foundation/Toast/ToastItem.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastProps> = ({
toastId,
text,
autoClose = 3000,
closeOnClick = true,
type = 'default',
pauseOnHover = true,
}) => {
const toastRef = useRef<HTMLDivElement>(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 : (
<Icon id={ToastTypeIconName[type]} width="20" height="20" />
);

return (
<div ref={toastRef}>
<Box
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onAnimationEnd={handleExitingAnimationEnd}
css={[
css`
position: relative;
display: flex;
flex-direction: column;
row-gap: 0.5rem;
min-width: 20rem;
border-radius: 0.5rem;
overflow: hidden;
background-color: ${theme.colors.surface.default};
animation: ${isExiting
? css`
${ToastFadeOutUpAnimation} 0.8s forwards
`
: 'none'};
`,
]}
>
<div
css={css`
display: flex;
align-items: center;
column-gap: 1rem;
padding: 1rem;
`}
>
<IconType />
{text}
</div>
<div
onAnimationEnd={handleProgressAnimationEnd}
css={[
css`
position: absolute;
bottom: 0;
height: 0.3125rem;
width: 100%;
transform-origin: left;
animation: ${autoClose
? css`
${ToastProgressBarAnimation} ${autoClose}ms linear
forwards
`
: 'none'};
animation-play-state: ${isPaused ? 'paused' : 'running'};
`,
ToastProgressBarStyle[type],
]}
/>
</Box>
</div>
);
};

export default ToastItem;
17 changes: 17 additions & 0 deletions FE/src/components/foundation/Toast/collapseToast.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
55 changes: 55 additions & 0 deletions FE/src/components/foundation/Toast/eventManger.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
},
};
30 changes: 30 additions & 0 deletions FE/src/components/foundation/Toast/toast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ToastEvent, ToastProps, ToastType } from '@foundation/Toast/type';
import { eventManager } from '@foundation/Toast/eventManger';

type ToastFunctionProps = Omit<ToastProps, 'toastId' | 'type'>;
type ToastOptions = Omit<ToastProps, 'toastId' | 'type' | 'text'>;
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 }),
};
Loading

0 comments on commit 6814a34

Please sign in to comment.