Skip to content

Commit

Permalink
[FE] - 토스트 기본 설정 추가 (#53)
Browse files Browse the repository at this point in the history
* fix: add toastId props and handleAnimateEnd

* feat: 이벤트 매니저 구현

* feat: useToastContainer 구현

- EventManager를 구독하고 ToastList 상태를 관리한다.

* feat: ToastContainer 구현

- useToastContainer 커스텀 훅을 활용해서 Toastlist를 화면에 렌더링 시켜준다.

* feat: toastController 구현

toastId를 생성하고 type별로 쉽게 사용할 수 있게 도와준다.

* feat: App.tsx에 ToastContainer 컴포넌트 추가

* fix: ToastId props로 추가

* feat: info, warning 로고 추가

* style: 토스트 스타일 변경

- warning, info 아이콘 추가 및 색상 변경

* feat: 토스트 스토리북 수정

* refactor: 토스트 폴더 구조 변경 및 스타일 수정

- fixed에서 1rem만큼 떨어지도록 수정
- 사라질 때 애니메이션 추가

---------

Co-authored-by: byeong <[email protected]>
  • Loading branch information
dooohun and chan-byeong authored Nov 12, 2024
1 parent bc1b3fb commit e404b04
Show file tree
Hide file tree
Showing 14 changed files with 331 additions and 101 deletions.
9 changes: 7 additions & 2 deletions packages/FE/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import Router from './routes/Router';

import ToastContainer from '@/features/toast/ui/ToastContainer';
function App() {
return <Router />;
return (
<>
<ToastContainer position="top-right" />
<Router />
</>
);
}

export default App;
23 changes: 23 additions & 0 deletions packages/FE/src/features/toast/hooks/useToastContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
import { EventManager, ToastEvent } from '@/shared/libs/EventManager';
import { ToastProps } from '../types';

export const useToastContainer = () => {
const [toastList, setToastList] = useState<ToastProps[]>([]);

useEffect(() => {
EventManager.on(ToastEvent.ADD, (toast: ToastProps) => {
setToastList((prev) => [...prev, toast]);
});
EventManager.on(ToastEvent.DELETE, (toastId: number) => {
setToastList((prev) => prev.filter((toast) => toast.toastId !== toastId));
});

return () => {
EventManager.off(ToastEvent.ADD);
EventManager.off(ToastEvent.DELETE);
};
}, []);

return { toastList };
};
25 changes: 25 additions & 0 deletions packages/FE/src/features/toast/model/toastController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { EventManager } from '@/shared/libs/EventManager';

import { ToastEvent } from '@/shared/libs/EventManager';
import { ToastTime } from '../types';

export const toastController = () => {
let toastId = 0;

const toast = {
success: (label: string, time: ToastTime = 5) => {
EventManager.emit(ToastEvent.ADD, { toastId: toastId++, type: 'success', label, time });
},
warning: (label: string, time: ToastTime = 5) => {
EventManager.emit(ToastEvent.ADD, { toastId: toastId++, type: 'warning', label, time });
},
info: (label: string, time: ToastTime = 5) => {
EventManager.emit(ToastEvent.ADD, { toastId: toastId++, type: 'info', label, time });
},
error: (label: string, time: ToastTime = 5) => {
EventManager.emit(ToastEvent.ADD, { toastId: toastId++, type: 'error', label, time });
},
};

return toast;
};
13 changes: 13 additions & 0 deletions packages/FE/src/features/toast/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type ToastType = 'success' | 'warning' | 'error' | 'info';
export type ToastTime = 5 | 10 | 15 | 20 | 30;

export interface ToastProps {
/** Toast의 고유 id */
toastId: number;
/** Toast의 타입 (success | warning | error | info) */
type: ToastType;
/** Toast에 표시할 문구 */
label: string;
/** Toast가 표시될 시간 (5 | 10 | 15 | 20 | 30) */
time: ToastTime;
}
99 changes: 99 additions & 0 deletions packages/FE/src/features/toast/ui/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Meta, StoryObj } from '@storybook/react';

import Toast from './Toast.tsx';
import ToastContainer from './ToastContainer.tsx';
import { toastController } from '../model/toastController.ts';
import CustomButton from '../../../shared/ui/buttons/CustomButton.tsx';

const meta = {
title: 'Common/Toast',
component: Toast,
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
<div>
<ToastContainer position="top-right" />
<Story />
</div>
),
],
} satisfies Meta<typeof Toast>;

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

const toast = toastController();

export const Success: Story = {
render: () => (
<CustomButton
type="full"
label="성공 토스트"
onClick={() => {
toast.success('성공 문구입니다.');
}}
/>
),
args: {
toastId: 0,
type: 'success',
label: '성공 문구입니다.',
time: 5,
},
};

export const Warning: Story = {
render: () => (
<CustomButton
type="full"
label="경고 토스트"
onClick={() => {
toast.warning('경고 문구입니다.');
}}
/>
),
args: {
toastId: 1,
type: 'warning',
label: '경고 문구입니다.',
time: 5,
},
};

export const Error: Story = {
render: () => (
<CustomButton
type="full"
label="에러 토스트"
onClick={() => {
toast.error('에러 문구입니다.');
}}
/>
),
args: {
toastId: 2,
type: 'error',
label: '에러 문구입니다.',
time: 5,
},
};

export const Info: Story = {
render: () => (
<CustomButton
type="full"
label="정보 토스트"
onClick={() => {
toast.info('정보 문구입니다.');
}}
/>
),
args: {
toastId: 3,
type: 'info',
label: '정보 문구입니다.',
time: 5,
},
};
77 changes: 77 additions & 0 deletions packages/FE/src/features/toast/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import CloseIcon from '@/shared/assets/icons/close.svg?react';
import ProgressBar from '@/shared/ui/progress-bar/ProgressBar';
import { ToggleButton } from '@/shared/ui/buttons';
import { ToastEvent } from '@/shared/libs/EventManager';
import { EventManager } from '@/shared/libs/EventManager';
import { useRef } from 'react';
import { ToastProps } from '../types';

const getLogo = (type: ToastProps['type']) => {
switch (type) {
case 'success':
return <ToggleButton type="check" isClickable={false} isActive={true} size="small" />;
case 'warning':
return (
<ToggleButton
type="warning"
isClickable={false}
isActive={true}
size="large"
color="transparent"
/>
);
case 'error':
return (
<ToggleButton
type="question"
isClickable={false}
isActive={true}
size="small"
color="error"
/>
);
case 'info':
return (
<ToggleButton
type="info"
isClickable={false}
isActive={true}
size="medium"
color="transparent"
/>
);
}
};
export default function Toast({ toastId, type = 'success', label, time = 5 }: ToastProps) {
const toastRef = useRef<HTMLDivElement>(null);
const handleToastClose = () => {
toastRef.current?.classList.add('animate-slide-out');
setTimeout(() => {
EventManager.emit(ToastEvent.DELETE, toastId);
}, time * 100);
};

const logo = getLogo(type);

return (
<div
className="relative flex flex-col justify-center w-[296px] h-16 rounded-base bg-white border overflow-hidden group"
ref={toastRef}
>
<div className="flex gap-4 px-4 item-center">
{logo}
<p className="flex justify-center items-center text-weak-md">{label}</p>
</div>
<div className="absolute bottom-0 left-0 w-[296px]">
<ProgressBar
time={time}
type={type}
barShape="rounded"
pauseOnHover={true}
handleAnimationEnd={handleToastClose}
/>
</div>
<CloseIcon className="absolute top-3 right-3 cursor-pointer" onClick={handleToastClose} />
</div>
);
}
25 changes: 25 additions & 0 deletions packages/FE/src/features/toast/ui/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useToastContainer } from '@/features/toast/hooks/useToastContainer';
import Toast from '@/features/toast/ui/Toast';

const toastPositions = {
'top-left': 'top-4 left-4',
'top-center': 'top-4 left-1/2 -translate-x-1/2',
'top-right': 'top-4 right-4',
'bottom-left': 'bottom-4 left-4',
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
'bottom-right': 'bottom-4 right-4',
};

export default function ToastContainer({ position }: { position: keyof typeof toastPositions }) {
const { toastList } = useToastContainer();

return (
<div
className={`fixed flex flex-col items-center justify-center gap-4 ${toastPositions[position]} z-50`}
>
{toastList.map((toast) => (
<Toast key={toast.toastId} {...toast} />
))}
</div>
);
}
5 changes: 5 additions & 0 deletions packages/FE/src/shared/assets/icons/info.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions packages/FE/src/shared/assets/icons/warning.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions packages/FE/src/shared/libs/EventManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const enum ToastEvent {
ADD = 'ADD',
DELETE = 'DELETE',
}

export type EventType = ToastEvent;

export const EventManager = {
list: new Map<EventType, (...args: any[]) => void>(),

on: (eventType: EventType, callback: (...args: any[]) => void) => {
EventManager.list.set(eventType, callback);
},

off: (eventType: EventType) => {
EventManager.list.delete(eventType);
},

// emit 수정
emit: (eventType: EventType, ...args: any[]) => {
const callback = EventManager.list.get(eventType);
if (callback) {
callback(...args);
}
},
};
17 changes: 12 additions & 5 deletions packages/FE/src/shared/ui/buttons/ToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import CheckIcon from '@/shared/assets/icons/check.svg?react';
import CheckBoxIcon from '@/shared/assets/icons/check-box.svg?react';
import QuestionIcon from '@/shared/assets/icons/question.svg?react';
import WarningIcon from '@/shared/assets/icons/warning.svg?react';
import InfoIcon from '@/shared/assets/icons/info.svg?react';

type ToggleButtonType = 'check' | 'question' | 'checkBox';
type ToggleButtonType = 'check' | 'question' | 'checkBox' | 'warning' | 'info';
type ToggleButtonSize = 'small' | 'medium' | 'large';
type ToggleButtonColor = 'success' | 'warning' | 'error' | 'info';
type ToggleButtonColor = 'success' | 'warning' | 'error' | 'info' | 'transparent';

interface ToggleButtonProps {
/** 토글 버튼 타입 (check, question, checkbox) */
/** 토글 버튼 타입 (check, question, checkbox, warning, info) */
type: ToggleButtonType;
/**클릭이 가능한 상태 여부 */
isClickable: boolean;
/** 버튼 활성화 여부 */
isActive: boolean;
/** 클릭 이벤트 핸들러 */
onClick: () => void;
onClick?: () => void;
/** 버튼 사이즈 (small, medium, large) */
size?: ToggleButtonSize;
/** 색상 */
Expand All @@ -29,6 +31,10 @@ const getIcon = (type: ToggleButtonType, isActive: boolean, iconSize: string) =>
return <QuestionIcon stroke={`${isActive ? '#ffffff' : '#525252'}`} className={iconSize} />;
case 'checkBox':
return <CheckBoxIcon stroke={`${isActive ? '#ffffff' : '#525252'}`} className={iconSize} />;
case 'warning':
return <WarningIcon stroke={`${isActive ? '#ffffff' : '#525252'}`} className={iconSize} />;
case 'info':
return <InfoIcon stroke={`${isActive ? '#ffffff' : '#525252'}`} className={iconSize} />;
}
};

Expand All @@ -49,6 +55,7 @@ const colors = {
warning: 'bg-yellow-500',
error: 'bg-red-500',
info: 'bg-blue-500',
transparent: 'transparent',
};

export default function ToggleButton({
Expand All @@ -75,7 +82,7 @@ export default function ToggleButton({
</button>
) : (
<div
className={`flex items-center justify-center ${buttonSize} ${backgroundColor} border rounded-full `}
className={`flex items-center justify-center ${buttonSize} ${backgroundColor} rounded-full `}
>
{icon}
</div>
Expand Down
Loading

0 comments on commit e404b04

Please sign in to comment.