Skip to content

Commit

Permalink
feat(ui-kit): 스낵바 컴포넌트 추가 (#41)
Browse files Browse the repository at this point in the history
* feat(ui-kit): 스낵바 컴포넌트 추가

* feat(ui-kit): 스낵바 타입 업데이트

* feat(ui-kit): Snackbar 컴포넌트 타입에서 children 제거

* feat(ui-kit): 스낵바에 버튼이 연속으로 등장할 때 간격 설정

* feat(ui-kit): 의미없는 모듈 임포트 제거
  • Loading branch information
evan-moon authored Feb 7, 2021
1 parent 420bb1d commit 429a441
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 6 deletions.
5 changes: 4 additions & 1 deletion ui-kit/src/components/LubyconUIKitProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { ReactNode } from 'react';
import { ToastProvider } from 'contexts/Toast';
import { PortalProvider } from 'contexts/Portal';
import { SnackbarProvider } from 'src/contexts/Snackbar';

interface Props {
children: ReactNode;
Expand All @@ -9,7 +10,9 @@ interface Props {
function LubyconUIKitProvider({ children }: Props) {
return (
<PortalProvider>
<ToastProvider>{children}</ToastProvider>
<SnackbarProvider>
<ToastProvider>{children}</ToastProvider>
</SnackbarProvider>
</PortalProvider>
);
}
Expand Down
25 changes: 25 additions & 0 deletions ui-kit/src/components/Snackbar/SnackbarBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { ReactNode, isValidElement } from 'react';
import classnames from 'classnames';
import Text from 'components/Text';
import Button from '../Button';

interface Props {
message: string;
button: ReactNode;
onClick?: () => void;
}

const SnackbarBody = ({ message, button, onClick }: Props) => {
return (
<div className={classnames('lubycon-snackbar__body', 'lubycon-shadow--3')}>
<Text typography="p2" className="lubycon-snackbar__text">
{message}
</Text>
<div className="lubycon-snackbar__body__buttons">
{isValidElement(button) ? button : <Button onClick={onClick}>{button}</Button>}
</div>
</div>
);
};

export default SnackbarBody;
93 changes: 93 additions & 0 deletions ui-kit/src/components/Snackbar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { HTMLAttributes, useEffect, useState, ReactNode } from 'react';
import { animated, useTransition } from 'react-spring';
import classnames from 'classnames';
import SnackbarBody from './SnackbarBody';
import { Combine } from 'src/types/utils';

export type SnackbarProps = Combine<
{
show: boolean;
message: string;
button: ReactNode;
autoHideDuration?: number;
onShow?: () => void;
onHide?: () => void;
onClick?: () => void;
},
Omit<HTMLAttributes<HTMLDivElement>, 'children'>
>;

const Snackbar = ({
show,
message,
button,
autoHideDuration,
onShow,
onHide,
onClick,
className,
style,
...rest
}: SnackbarProps) => {
const [isOpen, setOpen] = useState(show);
const transition = useTransition(isOpen, null, {
from: {
opacity: 0,
transform: 'translateX(-100%)',
height: 60,
},
enter: [
{ height: 60 },
{
opacity: 1,
transform: 'translateX(0)',
},
],
leave: [
{
opacity: 0,
transform: 'translateX(-100%)',
},
{ height: 0 },
],
onStart: () => {
onShow?.();
},
onDestroyed: () => {
onHide?.();
},
});

useEffect(() => {
let timer: NodeJS.Timeout;
if (autoHideDuration != null && isOpen === true) {
timer = setTimeout(() => {
setOpen(false);
}, autoHideDuration);
}

return () => clearTimeout(timer);
}, []);

return (
<>
{transition.map(({ item, key, props }) => {
return item ? (
<animated.div
key={key}
className={classnames('lubycon-snackbar', className)}
style={{
...style,
...props,
}}
{...rest}
>
<SnackbarBody message={message} button={button} onClick={onClick} />
</animated.div>
) : null;
})}
</>
);
};

export default Snackbar;
1 change: 1 addition & 0 deletions ui-kit/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { default as LubyconUIKitProvider } from './LubyconUIKitProvider';
export { default as Toast } from './Toast';
export { default as Tooltip } from './Tooltip';
export { Tabs, TabPane } from './Tabs';
export { default as Snackbar } from './Snackbar';
79 changes: 79 additions & 0 deletions ui-kit/src/contexts/Snackbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { ReactNode, createContext, useState, useCallback, useContext } from 'react';
import classnames from 'classnames';
import Snackbar, { SnackbarProps } from 'components/Snackbar';
import { generateID } from 'src/utils';
import { Portal } from './Portal';
interface SnackbarOptions extends Omit<SnackbarProps, 'show'> {
duration?: number;
}

interface SnackbarGlobalState {
openSnackbar: (option: SnackbarOptions) => void;
closeSnackbar: (toastId: string) => void;
}
const SnackbarContext = createContext<SnackbarGlobalState>({
openSnackbar: () => {},
closeSnackbar: () => {},
});

interface SnackbarProviderProps {
children: ReactNode;
maxStack?: number;
}
export function SnackbarProvider({ children, maxStack = 1 }: SnackbarProviderProps) {
const [openedSnackbarQueue, setOpenedSnackbarQueue] = useState<SnackbarOptions[]>([]);

const openSnackbar = useCallback(
({ id = generateID('lubycon-snackbar'), ...option }: SnackbarOptions) => {
const snackbar = { id, ...option };
const [, ...rest] = openedSnackbarQueue;

if (openedSnackbarQueue.length >= maxStack) {
setOpenedSnackbarQueue([...rest, snackbar]);
} else {
setOpenedSnackbarQueue([...openedSnackbarQueue, snackbar]);
}
},
[openedSnackbarQueue]
);

const closeSnackbar = useCallback(
(closedSnackbarId: string) => {
setOpenedSnackbarQueue(
openedSnackbarQueue.filter((snackbar) => snackbar.id !== closedSnackbarId)
);
},
[openedSnackbarQueue]
);

return (
<SnackbarContext.Provider
value={{
openSnackbar,
closeSnackbar,
}}
>
{children}
<Portal>
<div className={classnames('lubycon-snackbar__context-container')}>
{openedSnackbarQueue.map(({ id, onHide, duration = 3000, ...snackbarProps }) => (
<Snackbar
key={id}
show={true}
autoHideDuration={duration}
onHide={() => {
closeSnackbar(id ?? '');
onHide?.();
}}
{...snackbarProps}
/>
))}
</div>
</Portal>
</SnackbarContext.Provider>
);
}

export function useSnackbar() {
return useContext(SnackbarContext);
}
3 changes: 0 additions & 3 deletions ui-kit/src/sass/components/_Button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,12 @@
transition: background-color 0.2s ease-in-out;

&__small {
height: 32px;
padding: 4px 16px;
}
&__medium {
height: 40px;
padding: 8px 16px;
}
&__large {
height: 56px;
padding: 12px 32px;
border-radius: 8px;
}
Expand Down
41 changes: 41 additions & 0 deletions ui-kit/src/sass/components/_Snackbar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.lubycon-snackbar {
overflow: visible;

.lubycon-snackbar__body {
display: inline-flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
min-width: 400px;
border-radius: 4px;
background-color: white;
margin: 12px 0;
}

.lubycon-snackbar__text {
white-space: pre;
}

.lubycon-snackbar__body__buttons {
margin-left: 16px;
.lubycon-button + .lubycon-button {
margin-left: 8px;
}
}

&:first-of-type {
.lubycon-snackbar__body {
margin-bottom: 0;
}
}
}

.lubycon-snackbar__context-container {
position: fixed;
display: flex;
flex-direction: column-reverse;
top: auto;
bottom: 40px;
left: 40px;
right: auto;
}
1 change: 1 addition & 0 deletions ui-kit/src/sass/components/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
@import './Toast';
@import './Tooltip';
@import './Tabs';
@import './Snackbar';
99 changes: 99 additions & 0 deletions ui-kit/src/stories/Snackbar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { Meta } from '@storybook/react/types-6-0';
import Snackbar from 'components/Snackbar';
import Button from 'components/Button';
import { useSnackbar } from 'contexts/Snackbar';

export default {
title: 'Lubycon UI Kit/Snackbar',
component: Snackbar,
} as Meta;

export const Default = () => {
return (
<div>
<Snackbar show={true} message="데이터 전송이 완료되었습니다." button="실행취소" />
<Snackbar
show={true}
message={`16개의 이미지가\n“동물" 폴더에 추가되었습니다.`}
button="실행취소"
/>
</div>
);
};

export const AutoHide = () => {
const [show, setShow] = useState(true);
return (
<div>
<Snackbar show={true} message="데이터 전송이 완료되었습니다." button="실행취소" />
<Snackbar
show={show}
autoHideDuration={3000}
onHide={() => setShow(true)}
message={`16개의 이미지가\n“동물" 폴더에 추가되었습니다.`}
button="실행취소"
/>
</div>
);
};

export const SnackbarHooks = () => {
const { openSnackbar } = useSnackbar();
return (
<div>
<Button
onClick={() =>
openSnackbar({
message: `파일이 휴지통으로 이동되었습니다.`,
button: '실행취소',
})
}
>
스낵바 열기
</Button>
</div>
);
};

export const onClick = () => {
const { openSnackbar } = useSnackbar();
return (
<div>
<Button
onClick={() =>
openSnackbar({
message: `파일이 휴지통으로 이동되었습니다.`,
button: '실행취소',
onClick: () => alert('실행 취소 완료'),
})
}
>
스낵바 열기
</Button>
</div>
);
};

export const multipleButton = () => {
const { openSnackbar } = useSnackbar();
return (
<div>
<Button
onClick={() =>
openSnackbar({
message: '메세지가 전송되었습니다.',
button: (
<>
<Button onClick={() => alert('실행 취소 완료')}>실행취소</Button>
<Button onClick={() => alert('메세지 보기 클릭')}>메세지 보기</Button>
</>
),
})
}
>
스낵바 열기
</Button>
</div>
);
};
Loading

0 comments on commit 429a441

Please sign in to comment.