diff --git a/src/components/Snackbar/Snackbar.mdx b/src/components/Snackbar/Snackbar.mdx new file mode 100644 index 0000000..0d9ddfd --- /dev/null +++ b/src/components/Snackbar/Snackbar.mdx @@ -0,0 +1,106 @@ +import { Canvas, Meta, Controls } from '@storybook/blocks'; +import * as SnackbarStories from './Snackbar.stories.tsx'; +import { Snackbar } from './Snackbar'; + + + +# Snackbar + +UI의 최하단에서 유저의 이용을 방해하지 않으면서 유저가 수행했거나 수행해야 할 작업에 대해 일시적으로 피드백을 제공합니다. + + + + +
+
+ +## 사용법 + +Snackbar 컴포넌트의 기본 사용법입니다. + +1. Snackbar를 노출할 영역을 `SnackbarProvider`로 감싸줍니다. + +```tsx + + + + + +``` + +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 ( + <> + + Show Snackbar + + + ); +} +``` + +4. Snackbar를 닫을 시, info 타입일 때는 `드래그`, error 타입일 때는 `X 버튼`을 클릭하여 Snackbar를 닫을 수 있습니다. + + + +5. Snackbar는 `최대 두 줄까지` 입력되며, 두 줄을 넘어설 시, `ellipsis` 처리됩니다. + + + +## 예시 + +### type + +`type` prop으로 Snackbar의 종류를 설정합니다. (info 또는 error)
+기본 값은 `info`입니다. + +### width + +`width` prop으로 원하는 Snackbar의 가로 길이를 설정합니다. (full-width, px, rem, em, %, vh, calc())
+기본 값으로 글자 길이에 맞게 가로 길이가 정해집니다. + +`full-width`인 경우, 기본적으로 `양쪽 margin 16px`이 설정됩니다. 이 때, position 설정은 적용되지 않습니다. + +### margin + +`margin` prop으로 Snackbar의 왼쪽 오른쪽의 margin 값을 정해줍니다.
+기본 값은 `16px`입니다. + +### message + +Snackbar의 필수 프로퍼티로, Snackbar의 내용을 설정합니다.
+ +### duration + +`duration` prop으로 Snackbar가 자동으로 닫히기까지의 시간을 설정합니다. (단위: `ms`)
+기본 값은 `5000`입니다. + +### position + +`position` prop으로 Snackbar의 위치를 설정합니다. (left, center, right)
+기본 값은 `center`입니다. diff --git a/src/components/Snackbar/Snackbar.stories.tsx b/src/components/Snackbar/Snackbar.stories.tsx new file mode 100644 index 0000000..aa79c62 --- /dev/null +++ b/src/components/Snackbar/Snackbar.stories.tsx @@ -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 = { + 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; + +const SnackbarComponent = (args: SnackbarProps) => { + const { snackbar } = useSnackbar(); + + const addSnackbar = () => { + snackbar({ + ...args, + message: args.message || '기본 메시지입니다.', + }); + }; + + const buttonLabel = args.type === 'error' ? 'Error Snackbar' : 'Info Snackbar'; + + return ( +
+ + {buttonLabel} + +
+ ); +}; + +export const Test: Story = { + render: (args) => ( + + + + ), + args: { + type: 'info', + position: 'center', + width: '350px', + message: '테스트용 스낵바입니다.', + }, +}; + +export const Type: Story = { + render: (args) => ( + +
+ + +
+
+ ), + args: { + width: '350px', + position: 'center', + }, +}; + +export const OverflowTest: Story = { + render: (args) => ( + +

두 줄 이상 입력 시

+
+
+ + +
+
+ ), + args: { + message: + '최대 2줄 입력 가능합니다. 입력 값이 넘칠 시, ellipsis 처리됩니다. 최대 2줄 입력 가능합니다. 입력 값이 넘칠 시, ellipsis 처리됩니다.', + width: '350px', + position: 'center', + }, +}; diff --git a/src/components/Snackbar/Snackbar.style.ts b/src/components/Snackbar/Snackbar.style.ts new file mode 100644 index 0000000..6fc17ea --- /dev/null +++ b/src/components/Snackbar/Snackbar.style.ts @@ -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>` + 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', +})` + 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}; +`; diff --git a/src/components/Snackbar/Snackbar.tsx b/src/components/Snackbar/Snackbar.tsx new file mode 100644 index 0000000..788e957 --- /dev/null +++ b/src/components/Snackbar/Snackbar.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { IcAlertTriangleFilled, IcCloseFilled } from '@/style'; + +import { StyledErrorIc, StyledIcMessage, StyledMessage, StyledSnackbar } from './Snackbar.style'; +import { SnackbarHeightType, SnackbarProps } from './Snackbar.type'; +import { useTouchMouseDrag } from './hooks/useMouseTouchDrag'; + +export const Snackbar = ({ + type = 'info', + width, + margin, + message, + onClose, + duration = 5000, + position = 'center', + isClosing: initialIsClosing, +}: SnackbarProps) => { + const messageRef = useRef(null); + const [heightType, setHeightType] = useState(1); + const [isClosing, setIsClosing] = useState(initialIsClosing); + + const closeToast = useCallback(() => { + if (!isClosing && onClose) { + setIsClosing(true); + setTimeout(() => onClose(), 300); + } + }, [isClosing, onClose]); + + useEffect(() => { + const timeout = setTimeout(() => { + closeToast(); + }, duration); + + return () => clearTimeout(timeout); + }, [duration, closeToast]); + + useEffect(() => { + if (messageRef.current) { + const messageHeight = messageRef.current.clientHeight; + const lineHeight = parseInt(window.getComputedStyle(messageRef.current).lineHeight, 10); + const isMultiLine = messageHeight > lineHeight; + setHeightType(isMultiLine ? 2 : 1); + } + }, [message]); + + const snackbarRef = useTouchMouseDrag(() => { + closeToast(); + }); + + return ( + + + {type === 'error' && } + {message} + {type === 'error' && ( + + + + )} + + + ); +}; diff --git a/src/components/Snackbar/Snackbar.type.ts b/src/components/Snackbar/Snackbar.type.ts new file mode 100644 index 0000000..8df12ee --- /dev/null +++ b/src/components/Snackbar/Snackbar.type.ts @@ -0,0 +1,25 @@ +export type SnackbarType = 'info' | 'error'; +export type SnackbarHeightType = 1 | 2; +export type SnackbarPosition = 'left' | 'center' | 'right'; +export type SnackbarWidth = + | 'full-width' + | `${number}px` + | `${number}rem` + | `${number}em` + | `${number}%` + | `${number}vh` + | `calc(${string})`; + +export interface SnackbarProps { + type?: SnackbarType; + width?: SnackbarWidth; + margin?: string; + message: string; + onClose?: () => void; + duration?: number; + position?: SnackbarPosition; + isClosing: boolean; + heightType?: SnackbarHeightType; +} + +export type SnackbarWithoutClosingProps = Omit; diff --git a/src/components/Snackbar/SnackbarProvider.tsx b/src/components/Snackbar/SnackbarProvider.tsx new file mode 100644 index 0000000..b3d1d45 --- /dev/null +++ b/src/components/Snackbar/SnackbarProvider.tsx @@ -0,0 +1,52 @@ +import { createContext, PropsWithChildren, useCallback, useContext, useState } from 'react'; + +import ReactDOM from 'react-dom'; + +import { Snackbar } from './Snackbar'; +import { StyledSnackbarContainer } from './Snackbar.style'; +import { SnackbarWithoutClosingProps } from './Snackbar.type'; + +type SnackbarContextType = { + showSnackbar: (props: SnackbarWithoutClosingProps) => void; +}; + +const SnackbarContext = createContext({ showSnackbar: () => {} }); + +export const SnackbarProvider = ({ children }: PropsWithChildren) => { + const [snackbar, setSnackbar] = useState(null); + const [isClosing, setIsClosing] = useState(false); + + const showSnackbar = useCallback((props: SnackbarWithoutClosingProps) => { + setSnackbar(props); + setIsClosing(false); + }, []); + + const removeSnackbar = useCallback(() => { + setIsClosing(true); + setTimeout(() => setSnackbar(null), 300); + }, []); + + return ( + + {children} + {snackbar && + ReactDOM.createPortal( + + + , + document.body + )} + + ); +}; + +export const useSnackbarContext = () => { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error('useSnackbar must be used within a SnackbarProvider'); + } + return context; +}; diff --git a/src/components/Snackbar/hooks/useMouseTouchDrag.ts b/src/components/Snackbar/hooks/useMouseTouchDrag.ts new file mode 100644 index 0000000..491ba51 --- /dev/null +++ b/src/components/Snackbar/hooks/useMouseTouchDrag.ts @@ -0,0 +1,56 @@ +import { useState, useRef, useEffect } from 'react'; + +export const useTouchMouseDrag = (onDismiss: () => void, threshold: number = 10) => { + const [startY, setStartY] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const elementRef = useRef(null); + + const handleStart = (event: TouchEvent | MouseEvent) => { + const y = event instanceof TouchEvent ? event.touches[0].clientY : event.clientY; + setStartY(y); + setIsDragging(true); + }; + + const handleMove = (event: TouchEvent | MouseEvent) => { + if (!isDragging || startY === null) return; + + const currentY = event instanceof TouchEvent ? event.touches[0].clientY : event.clientY; + const diffY = currentY - startY; + + if (diffY > threshold) { + onDismiss(); + setIsDragging(false); + setStartY(null); + } + }; + + const handleEnd = () => { + setIsDragging(false); + setStartY(null); + }; + + useEffect(() => { + const element = elementRef.current; + if (element) { + element.addEventListener('mousedown', handleStart); + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleEnd); + + element.addEventListener('touchstart', handleStart); + element.addEventListener('touchmove', handleMove); + element.addEventListener('touchend', handleEnd); + + return () => { + element.removeEventListener('mousedown', handleStart); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleEnd); + + element.removeEventListener('touchstart', handleStart); + element.removeEventListener('touchmove', handleMove); + element.removeEventListener('touchend', handleEnd); + }; + } + }, [isDragging, startY]); + + return elementRef; +}; diff --git a/src/components/Snackbar/hooks/useSnackbar.tsx b/src/components/Snackbar/hooks/useSnackbar.tsx new file mode 100644 index 0000000..d47e6b5 --- /dev/null +++ b/src/components/Snackbar/hooks/useSnackbar.tsx @@ -0,0 +1,12 @@ +import { SnackbarWithoutClosingProps } from '../Snackbar.type'; +import { useSnackbarContext } from '../SnackbarProvider'; + +export const useSnackbar = () => { + const { showSnackbar } = useSnackbarContext(); + + const snackbar = (props: SnackbarWithoutClosingProps) => { + showSnackbar(props); + }; + + return { snackbar }; +}; diff --git a/src/components/Snackbar/index.ts b/src/components/Snackbar/index.ts new file mode 100644 index 0000000..396ee3f --- /dev/null +++ b/src/components/Snackbar/index.ts @@ -0,0 +1,3 @@ +export { SnackbarProvider } from './SnackbarProvider'; +export { useSnackbar } from './hooks/useSnackbar'; +export type * from './Snackbar.type'; diff --git a/src/components/index.ts b/src/components/index.ts index bae9dc8..94d7216 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -54,3 +54,7 @@ export type { SwitchProps } from './Switch'; export { Divider } from './Divider'; export type { DividerProps } from './Divider'; + +export { SnackbarProvider } from './Snackbar/SnackbarProvider'; +export { useSnackbar } from './Snackbar/hooks/useSnackbar'; +export type * from './Snackbar';