Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Snackbar 컴포넌트 구현 #161

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/Snackbar/Snackbar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Canvas, Meta, Controls } from '@storybook/blocks';
import * as SnackbarStories from './Snackbar.stories.tsx';
import { Snackbar } from './Snackbar';

<Meta of={SnackbarStories} />

# Snackbar

UI의 최하단에서 유저의 이용을 방해하지 않으면서 유저가 수행했거나 수행해야 할 작업에 대해 일시적으로 피드백을 제공합니다.

<Canvas of={SnackbarStories.Test} />
<Controls />

<br />
<br />

## 사용법

Snackbar 컴포넌트의 기본 사용법입니다.

1. Snackbar를 노출할 영역을 `SnackbarProvider`로 감싸줍니다.

```tsx
<YDSWrapper>
<SnackbarProvider>
<App />
</SnackbarProvider>
</YDSWrapper>
```

2. `useSnackbar` 훅을 사용하여 snackbar를 가져옵니다.

```tsx
const { snackbar } = useSnackbar();
```

3. Snackbar를 호출하는 함수를 만들어서 Snackbar를 노출합니다.

필수 프로퍼티인 `message`를 꼭 설정해주세요.

`type` - Snackbar의 종류 (info 또는 error). 기본 값은 `info`입니다. <br />
`width` - Snackbar의 가로 길이 (px, %, 등). 기본적으로 글자 길이에 맞게 가로 길이가 정해집니다. <br />
`margin` - 왼쪽 오른쪽의 margin 값. 기본 값은 `16px`입니다. <br />
`message` - Snackbar의 내용 (메시지). 필수 프로퍼티입니다. <br />
`duration` - Snackbar가 자동으로 닫히기 전까지의 시간(ms). 기본 값은 `5000`입니다. <br />
`position` - Snackbar의 위치 (left, center, right, full-width). 기본 값은 `center`입니다.

```tsx
function App() {
const { snackbar } = useSnackbar();

const handleShowSnackbar = () => {
snackbar({
type: 'info', // Snackbar의 종류 (info 또는 error)
width: '350px', // Snackbar의 가로 길이 (px, %, 등)
margin: '16px', // 왼쪽 오른쪽의 margin 값
message: '테스트용 스낵바입니다.', // Snackbar의 내용 (메시지)
duration: 3000, // Snackbar가 자동으로 닫히기 전까지의 시간(ms)
position: 'center', // Snackbar의 위치 (left, center, right, full-width)
});
};
return (
<>
<BoxButton size="small" hierarchy="primary" onClick={handleShowSnackbar}>
Show Snackbar
</BoxButton>
</>
);
}
```

4. Snackbar를 닫을 시, info 타입일 때는 `드래그`, error 타입일 때는 `X 버튼`을 클릭하여 Snackbar를 닫을 수 있습니다.

<Canvas of={SnackbarStories.CloseTest} />
125 changes: 125 additions & 0 deletions src/components/Snackbar/Snackbar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Meta, StoryObj } from '@storybook/react';

import { BoxButton } from '../BoxButton';

import { Snackbar } from './Snackbar';
import { SnackbarProps } from './Snackbar.type';
import { SnackbarProvider } from './SnackbarProvider';
import { useSnackbar } from './useSnackbar';

const meta: Meta<SnackbarProps> = {
title: 'Components/Snackbar',
component: Snackbar,
args: {
type: 'info',
duration: 5000,
margin: '16px',
position: 'center',
},
argTypes: {
type: {
description: 'Snackbar의 종류 (info 또는 error)',
control: { type: 'radio', options: ['info', 'error'] },
},
width: {
control: 'text',
description: 'Snackbar의 가로 길이 (px, %, 등)',
},
duration: {
description: 'Snackbar가 자동으로 닫히기 전까지의 시간 (ms)',
control: 'number',
},
position: {
description: 'Snackbar의 위치 (left, center, right, full-width)',
control: { type: 'radio', options: ['left', 'center', 'right', 'full-width'] },
},
message: {
control: 'text',
description: 'Snackbar의 내용 (메시지)',
},
id: { table: { disable: true } },
onClose: { table: { disable: true } },
isClosing: { table: { disable: true } },
},
};

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

const SnackbarComponent = (args: Partial<SnackbarProps>) => {
const { snackbar } = useSnackbar();

const addSnackbar = () => {
snackbar({
...args,
message: args.message || '기본 메시지입니다.',
});
};

const buttonLabel = args.type === 'error' ? 'Error Snackbar' : 'Info Snackbar';

return (
<div>
<BoxButton size="small" hierarchy="primary" onClick={addSnackbar}>
{buttonLabel}
</BoxButton>
</div>
);
};

export const Test: Story = {
render: (args) => (
<SnackbarProvider>
<SnackbarComponent {...args} />
</SnackbarProvider>
),
args: {
type: 'info',
position: 'center',
width: '350px',
message: '테스트용 스낵바입니다.',
},
};

export const Info: Story = {
render: (args) => (
<SnackbarProvider>
<SnackbarComponent {...args} />
</SnackbarProvider>
),
args: {
type: 'info',
position: 'center',
width: '350px',
message: '정보성 메시지가 들어갑니다. 최대 2줄 입력 가능합니다.',
},
};

export const Error: Story = {
render: (args) => (
<SnackbarProvider>
<SnackbarComponent {...args} />
</SnackbarProvider>
),
args: {
type: 'error',
message: '에러 메시지가 들어갑니다. 최대 2줄 입력 가능합니다.',
width: '350px',
position: 'center',
},
};

export const CloseTest: Story = {
render: (args) => (
<SnackbarProvider>
<div style={{ display: 'flex', gap: '20px' }}>
<SnackbarComponent {...args} type="info" message="정보성 메시지가 들어갑니다." />
<SnackbarComponent {...args} type="error" message="에러 메시지가 들어갑니다." />
</div>
</SnackbarProvider>
),
args: {
width: '350px',
position: 'center',
},
};
135 changes: 135 additions & 0 deletions src/components/Snackbar/Snackbar.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import styled, { css } from 'styled-components';
import { DefaultTheme } from 'styled-components/dist/types';
import { match } from 'ts-pattern';

import { SnackbarHeightType, SnackbarPosition, SnackbarProps, SnackbarType } from './Snackbar.type';

interface StyledSnackbarProps {
$type: 'info' | 'error';
$width: SnackbarProps['width'];
$margin?: SnackbarProps['margin'];
isClosing?: boolean;
position: SnackbarPosition;
$heightType?: SnackbarHeightType;
}

const getBackgroundStyle = ($type: SnackbarType) => {
return match($type)
.with('error', () => '#FFEBEB')
.otherwise(() => '#3F434D');
};

const getFontColorStyle = (arg: { $type: SnackbarType; theme: DefaultTheme }) => {
return match(arg)
.with({ $type: 'error' }, ({ theme }) => theme.semantic.color.textStatusNegative)
.otherwise(() => arg.theme.semantic.color.textBasicWhite);
};

const getPositionStyle = (position: SnackbarPosition, margin?: string) => {
return match(position)
.with(
'left',
() => css`
left: 0;
margin-left: ${margin || '16px'};
`
)
.with(
'right',
() => css`
right: 0px;
margin-right: ${margin || '16px'};
`
)
.otherwise(
() => css`
left: 0;
right: 0;
margin: 0 auto;
align-items: center;
`
);
};

export const StyledSnackbarContainer = styled.div<
Omit<StyledSnackbarProps, '$type' | '$width' | 'visible'>
>`
position: fixed;
bottom: 0;
width: ${({ position }) => (position === 'full-width' ? '100%' : 'fit-content')};
height: fit-content;
display: flex;
flex-direction: column-reverse;
margin: 0 auto;
${({ position, $margin }) => getPositionStyle(position, $margin)}
`;

export const StyledSnackbar = styled.div.withConfig({
shouldForwardProp: (prop) => prop !== 'isClosing',
})<StyledSnackbarProps>`
position: relative;
padding: 24px;
width: ${({ position, $width }) => (position === 'full-width' ? 'calc(100% - 32px)' : $width)};
height: ${({ $heightType }) => ($heightType === 2 ? '72px' : '52px')};
border-radius: ${({ theme }) => theme.semantic.radius.m}px;

${({ theme }) => theme.typo.B3_Rg_14}
color: ${({ $type, theme }) => getFontColorStyle({ $type, theme })};
background-color: ${({ $type }) => `${getBackgroundStyle($type)}`};

display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;

${({ isClosing }) => css`
opacity: ${isClosing ? 0 : 1};
transform: ${isClosing ? 'translateY(100%)' : 'translateY(0)'};
transition:
opacity 300ms ease-out,
transform 300ms ease-out;
animation: ${isClosing ? 'none' : 'slideIn 500ms ease-out'};
`}

@keyframes slideIn {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

&:not(:first-child) {
margin-bottom: 8px;
}

&:first-child {
margin-bottom: 16px;
}
`;

export const StyledIcMessage = styled.div`
width: 100%;
justify-content: space-between;
align-items: flex-start;
display: flex;
gap: 8px;
`;

export const StyledMessage = styled.span`
width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
`;

export const StyledErrorIc = styled.div`
height: 20px;
cursor: pointer;
color: ${({ theme }) => theme.semantic.color.iconBasicTertiary};
`;
Loading
Loading