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 컴포넌트 구현 #162

Merged
merged 22 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7526c4d
feat: SnackbarProps 타입 생성
seocylucky Oct 30, 2024
a2e1a2c
feat: Snackbar.style.ts 생성
seocylucky Oct 30, 2024
bd7b682
feat: 마우스 드래그 및 터치 드래그 기능 훅인 useTouchMouseDrag 작업
seocylucky Oct 30, 2024
71263ed
feat: Snackbar, SnackbarProvider 작업
seocylucky Oct 30, 2024
7be5e65
feat: useSnackbar 작업
seocylucky Oct 30, 2024
a22180b
feat: export 작업
seocylucky Oct 30, 2024
586128f
docs: Snackbar 문서 작성
seocylucky Oct 30, 2024
62814f1
Merge remote-tracking branch 'origin/develop' into feat/#153-snackbar
seocylucky Oct 30, 2024
40f2c8f
fix: SnackbarWithoutClosingProps 타입 생성
seocylucky Oct 30, 2024
3a8416d
fix: 스낵바 하나만 생성하도록 수정
seocylucky Oct 30, 2024
3a2c4e5
docs: Overflow 시 테스트 추가, height 타입 테이블에서 제거
seocylucky Oct 30, 2024
ff4e890
refactor: 불필요한 스타일 코드 제거
seocylucky Oct 30, 2024
8cdfc01
Merge branch 'develop' into feat/#153-snackbar
seocylucky Oct 30, 2024
7139cec
fix: snackbar 배경 컬러 snackbar semantic color로 변경
seocylucky Oct 30, 2024
43d50c4
refactor: 관련 훅들 hooks로 이동
seocylucky Oct 31, 2024
bb5de21
fix: style type transient prop 처리, SnackbarProps 속 불필요한 $ 제외
seocylucky Oct 31, 2024
01a5ad2
fix: import/order lint에러 -> 절대경로로 변경
seocylucky Oct 31, 2024
08c7f4c
fix: full-width 관련 타입 변경 및 관련 스타일 수정
seocylucky Nov 1, 2024
3ea81f0
docs: prop 관련 스토리북 문서 수정
seocylucky Nov 1, 2024
27ba6ad
fix: 스낵바 스타일 관련 수정
seocylucky Nov 1, 2024
bb4859e
docs: type prop 예시 삭제
seocylucky Nov 2, 2024
6a732d2
docs: 불필요한 Story 삭제 및 대소문자 관련 수정
seocylucky Nov 8, 2024
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
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`입니다.
143 changes: 143 additions & 0 deletions src/components/Snackbar/Snackbar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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 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 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