Skip to content

Commit

Permalink
feat: Snackbar 컴포넌트 구현 (#162)
Browse files Browse the repository at this point in the history
* feat: SnackbarProps 타입 생성

* feat: Snackbar.style.ts 생성

* feat: 마우스 드래그 및 터치 드래그 기능 훅인 useTouchMouseDrag 작업

* feat: Snackbar, SnackbarProvider 작업

* feat: useSnackbar 작업

* feat: export 작업

* docs: Snackbar 문서 작성

* fix: SnackbarWithoutClosingProps 타입 생성

* fix: 스낵바 하나만 생성하도록 수정

* docs: Overflow 시 테스트 추가, height 타입 테이블에서 제거

* refactor: 불필요한 스타일 코드 제거

* fix: snackbar 배경 컬러 snackbar semantic color로 변경

* refactor: 관련 훅들 hooks로 이동

* fix: style type transient prop 처리, SnackbarProps 속 불필요한 $ 제외

* fix: import/order lint에러 -> 절대경로로 변경

* fix: full-width 관련 타입 변경 및 관련 스타일 수정

* docs: prop 관련 스토리북 문서 수정

* fix: 스낵바 스타일 관련 수정

* docs: type prop 예시 삭제

* docs: 불필요한 Story 삭제 및 대소문자 관련 수정
  • Loading branch information
seocylucky authored Nov 8, 2024
1 parent 6bba43b commit 767e68a
Show file tree
Hide file tree
Showing 10 changed files with 576 additions and 0 deletions.
106 changes: 106 additions & 0 deletions src/components/Snackbar/Snackbar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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`를 꼭 설정해주세요.

이외의 프로퍼티들은 하단의 예시에서 확인할 수 있습니다.

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

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

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

<Canvas of={SnackbarStories.Type} />

5. Snackbar는 `최대 두 줄까지` 입력되며, 두 줄을 넘어설 시, `ellipsis` 처리됩니다.

<Canvas of={SnackbarStories.OverflowTest} />

## 예시

### type

`type` prop으로 Snackbar의 종류를 설정합니다. (info 또는 error) <br />
기본 값은 `info`입니다.

### width

`width` prop으로 원하는 Snackbar의 가로 길이를 설정합니다. (full-width, px, rem, em, %, vh, calc()) <br />
기본 값으로 글자 길이에 맞게 가로 길이가 정해집니다.

`full-width`인 경우, 기본적으로 `양쪽 margin 16px`이 설정됩니다. 이 때, position 설정은 적용되지 않습니다.

### margin

`margin` prop으로 Snackbar의 왼쪽 오른쪽의 margin 값을 정해줍니다. <br />
기본 값은 `16px`입니다.

### message

Snackbar의 필수 프로퍼티로, Snackbar의 내용을 설정합니다. <br/ >

### duration

`duration` prop으로 Snackbar가 자동으로 닫히기까지의 시간을 설정합니다. (단위: `ms`) <br />
기본 값은 `5000`입니다.

### position

`position` prop으로 Snackbar의 위치를 설정합니다. (left, center, right) <br />
기본 값은 `center`입니다.
116 changes: 116 additions & 0 deletions src/components/Snackbar/Snackbar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Meta, StoryObj } from '@storybook/react';

import { BoxButton } from '@/components/BoxButton';

import { Snackbar } from './Snackbar';
import { SnackbarProps } from './Snackbar.type';
import { SnackbarProvider } from './SnackbarProvider';
import { useSnackbar } from './hooks/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, rem, em, %, vh, calc())`,
},
duration: {
description: 'Snackbar가 자동으로 닫히기 전까지의 시간 (ms)',
control: 'number',
},
position: {
description: 'Snackbar의 위치 (left, center, right)',
control: { type: 'radio', options: ['left', 'center', 'right'] },
},
message: {
control: 'text',
description: 'Snackbar의 내용 (메시지)',
},
onClose: { table: { disable: true } },
isClosing: { table: { disable: true } },
heightType: { table: { disable: true } },
},
};

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

const SnackbarComponent = (args: 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 Type: 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',
},
};

export const OverflowTest: Story = {
render: (args) => (
<SnackbarProvider>
<h3>두 줄 이상 입력 시</h3>
<br />
<div style={{ display: 'flex', gap: '20px' }}>
<SnackbarComponent {...args} type="info" />
<SnackbarComponent {...args} type="error" />
</div>
</SnackbarProvider>
),
args: {
message:
'최대 2줄 입력 가능합니다. 입력 값이 넘칠 시, ellipsis 처리됩니다. 최대 2줄 입력 가능합니다. 입력 값이 넘칠 시, ellipsis 처리됩니다.',
width: '350px',
position: 'center',
},
};
130 changes: 130 additions & 0 deletions src/components/Snackbar/Snackbar.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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 = (arg: { $type: SnackbarType; theme: DefaultTheme }) => {
return match(arg)
.with({ $type: 'error' }, ({ theme }) => theme.semantic.color.snackbarError)
.otherwise(({ theme }) => theme.semantic.color.snackbarInfo);
};

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;
align-items: start;
margin-left: ${margin || '16px'};
`
)
.with(
'right',
() => css`
right: 0;
align-items: end;
margin-right: ${margin || '16px'};
`
)
.otherwise(
() => css`
left: 0;
right: 0;
margin: 0 auto;
align-items: center;
`
);
};

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

export const StyledSnackbar = styled.div.withConfig({
shouldForwardProp: (prop) => prop !== 'isClosing',
})<StyledSnackbarProps>`
position: relative;
padding: 16px;
margin-bottom: 16px;
width: ${({ $width }) => ($width === 'full-width' ? 'calc(100% - 32px)' : $width)};
height: ${({ $heightType }) => ($heightType === 2 ? '72px' : '52px')};
border-radius: ${({ theme }) => theme.semantic.radius.m}px;
${({ $type, theme }) => ($type === 'info' ? theme.typo.B3_Rg_14 : theme.typo.B3_Sb_14)}
color: ${({ $type, theme }) => getFontColorStyle({ $type, theme })};
background-color: ${({ $type, theme }) => `${getBackgroundStyle({ $type, theme })}`};
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;
}
}
`;

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

0 comments on commit 767e68a

Please sign in to comment.