Skip to content

Commit

Permalink
[FE] 토스트 UI 구현 (#391)
Browse files Browse the repository at this point in the history
* chore: 체크 svg를 재사용할 수 있도록 current 속성으로 변경

* chore: 경고 토스트를 보여줄 때, 사용할 svg 추가

* chore: 필요없는 공백 제거

* feat: 토스트 컴포넌트 구현

- 토스트의 타입에 따라서 아이콘, 아이콘의 배경색을 동적으로 결정할 수 있도록 구현

* design: 토스트 컴포넌트를 스타일링하기 위한 css 추가

* test: 토스트 컴포넌트 UI 테스트

* feat: 토스트를 감싸는 컴포넌트 구현

- 토스트 컴포넌트 UI 테스트를 쉽게할 수 있도록, 토스트가 사라질 때 애니메이션을 적용하기 위한 상태를 관리하는 Wrapper 컴포넌트 구현

* feat: 여러개의 토스트를 보여주기 위한 컴포넌트 구현

* design: 토스트 목록 컴포넌트 스타일링을 위한 css 추가

- 사용자의 디바이스 너비에 따라서 동적으로 토스트 UI의 너비를 조절하기 위해 padding, width 속성 사용

* feat: 전역으로 토스트의 렌더링 상태를 관리하기 위한 컨텍스트, 프로바이더 컴포넌트 구현

- 동일한 피드백을 전달하는 토스트가 이미 렌더링중이라면 추가적으로 렌더링되지 않도록 처리

* test: 토스트 컴포넌트 폴더 구조 이동, 공통 스타일 추출, 버튼을 클릭했을 때 토스트 UI가 렌더링되는 스토리 추가

* chore: 토스트 UI 기본 시간 추가
  • Loading branch information
hwinkr authored and ehBeak committed Oct 11, 2024
1 parent a2ce31f commit 2dbe836
Show file tree
Hide file tree
Showing 13 changed files with 416 additions and 3 deletions.
2 changes: 1 addition & 1 deletion frontend/src/assets/images/attendeeCheck.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion frontend/src/assets/images/check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/images/exclamation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
158 changes: 158 additions & 0 deletions frontend/src/components/_common/Toast/Toast.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ToastProvider>
<div
css={css`
width: 43rem;
`}
>
<Story />
</div>
</ToastProvider>
);
},
],
} satisfies Meta<typeof Toast>;

export default meta;

type Story = StoryObj<typeof Toast>;

export const Default: Story = {
args: {
isOpen: true,
type: 'default',
message: '안녕하세요, 내 이름은 기본 토스트입니다.',
},

render: (args) => {
return <Toast {...args} />;
},
};

export const Warning: Story = {
args: {
isOpen: true,
type: 'warning',
message: '안녕하세요, 내 이름은 경고 토스트입니다.',
},

render: (args) => {
return <Toast {...args} />;
},
};

export const Success: Story = {
args: {
isOpen: true,
type: 'success',
message: '안녕하세요, 내 이름은 성공 토스트입니다.',
},

render: (args) => {
return <Toast {...args} />;
},
};

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 (
<div
css={css`
position: relative;
display: flex;
justify-content: center;
width: 100%;
height: 100vh;
`}
>
<div
css={css`
position: absolute;
bottom: 2.4rem;
display: flex;
flex-direction: column;
row-gap: 1.2rem;
`}
>
<Button variant="primary" size="s" onClick={renderDefaultToast}>
기본 토스트 렌더링하기
</Button>
<Button variant="primary" size="s" onClick={renderSuccessToast}>
성공 토스트 렌더링하기
</Button>
<Button variant="primary" size="s" onClick={renderWarningToast}>
경고 토스트 렌더링하기
</Button>
</div>
</div>
);
},
};
67 changes: 67 additions & 0 deletions frontend/src/components/_common/Toast/Toast.styles.ts
Original file line number Diff line number Diff line change
@@ -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<Exclude<ToastType, 'default'>, SerializedStyles> = {
warning: css`
background-color: #ef4545;
`,
success: css`
background-color: ${theme.colors.green.mediumDark};
`,
};

export const s_iconBackgroundColor = (type: Exclude<ToastType, 'default'>) => {
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%;
`;
1 change: 1 addition & 0 deletions frontend/src/components/_common/Toast/Toast.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ToastType = 'default' | 'warning' | 'success';
28 changes: 28 additions & 0 deletions frontend/src/components/_common/Toast/ToastContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 <Toast isOpen={isOpen} type={type} message={message} />;
}
Original file line number Diff line number Diff line change
@@ -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;
`;
20 changes: 20 additions & 0 deletions frontend/src/components/_common/Toast/ToastList/ToastList.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div css={s_toastListContainer}>
{toasts &&
toasts.map(({ id, type, message, duration }) => (
<ToastContainer key={id} type={type} message={message} duration={duration} />
))}
</div>,
document.body,
);
}
40 changes: 40 additions & 0 deletions frontend/src/components/_common/Toast/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastType, React.FC<React.SVGProps<SVGSVGElement>> | null> = {
default: null,
success: Check,
warning: Exclamation,
};

// 토스트 컴포넌트는 UI를 보여주는 책임만 가질 수 있도록 최대한 책임을 분리하고 스토리북을 활용한 UI 테스트를 쉽게할 수 있도록 한다.(@해리)
export default function Toast({ isOpen, type = 'default', message }: ToastProps) {
const ToastIcon = iconMap[type];

return (
<div css={s_toastContainer(isOpen)}>
{type !== 'default' && (
<div css={[s_iconContainer, s_iconBackgroundColor(type)]}>
{ToastIcon && <ToastIcon width={16} height={16} stroke={theme.colors.white} />}
</div>
)}
<p css={s_toastText}>{message}</p>
</div>
);
}
Loading

0 comments on commit 2dbe836

Please sign in to comment.