Skip to content

Commit

Permalink
fea(ui-kit): Toast 추가 (#33)
Browse files Browse the repository at this point in the history
* feat(ui-kit): Toast 컴포넌트 추가

* feat(ui-kit): sleep 유틸 함수 추가

* feat(ui-kit): Portal Context 추가

* feat(ui-kit): LubyconUIKitProvider 추가

* feat(ui-kit): Toast가 Portal에 렌더되도록 변경

* fix(ui-kit): 깨진 디펜던시 수정

* feat(ui-kit): Toast 스토리 추가

* feat(ui-kit): useToast 훅 추가

* feat(ui-kit): 토스트 애니메이션을 useSpring이 아니라 useTransition으로 변경

* feat(ui-kit): 토스트 스토리 업데이트

* feat(ui-kit): 토스트 애니메이션 개선

* feat(ui-kit): fix lint
  • Loading branch information
evan-moon authored Jan 18, 2021
1 parent 41ade6b commit 680bee9
Show file tree
Hide file tree
Showing 16 changed files with 559 additions and 367 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ module.exports = {
],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
},
};
7 changes: 5 additions & 2 deletions ui-kit/.storybook/preview.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import '../src/sass/index.scss';
import React from 'react';
import { LubyconUIKitProvider } from '../src/components';

export const decorators = [(Story => (
<Story />
))]
<LubyconUIKitProvider>
<Story />
</LubyconUIKitProvider>
))];
11 changes: 6 additions & 5 deletions ui-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"dependencies": {
"@types/classnames": "^2.2.11",
"classnames": "^2.2.6",
"ionicons": "^5.2.3"
"ionicons": "^5.2.3",
"react-spring": "^8.0.27"
},
"scripts": {
"start": "start-storybook -p 6006 --no-dll",
Expand Down Expand Up @@ -61,11 +62,11 @@
"@babel/core": "^7.12.3",
"@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-node-resolve": "^10.0.0",
"@storybook/addon-actions": "^6.0.28",
"@storybook/addon-essentials": "^6.0.28",
"@storybook/node-logger": "^6.0.28",
"@storybook/addon-actions": "^6.1.14",
"@storybook/addon-essentials": "^6.1.14",
"@storybook/node-logger": "^6.1.14",
"@storybook/preset-create-react-app": "^3.1.5",
"@storybook/react": "^6.0.28",
"@storybook/react": "^6.1.14",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
Expand Down
7 changes: 0 additions & 7 deletions ui-kit/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import path from 'path';

import autoprefixer from 'autoprefixer';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import postcss from 'rollup-plugin-postcss';
import typescript from 'rollup-plugin-typescript2';
// import babel from 'rollup-plugin-babel';

const extensions = ['.js', '.jsx', '.ts', '.tsx'];

Expand All @@ -29,11 +27,6 @@ function buildJS(input, output, format) {
typescript({
tsconfig: 'tsconfig.json',
}),
// babel({
// extensions,
// runtimeHelpers: true,
// include: ['src/**'],
// }),
resolve({ extensions }),
commonjs({
namedExports: {
Expand Down
17 changes: 17 additions & 0 deletions ui-kit/src/components/LubyconUIKitProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import { ToastProvider } from 'contexts/Toast';
import { PortalProvider } from 'contexts/Portal';

interface Props {
children: ReactNode;
}

function LubyconUIKitProvider({ children }: Props) {
return (
<PortalProvider>
<ToastProvider>{children}</ToastProvider>
</PortalProvider>
);
}

export default LubyconUIKitProvider;
17 changes: 17 additions & 0 deletions ui-kit/src/components/Toast/ToastBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import classnames from 'classnames';
import Text from 'components/Text';

interface Props {
message: string;
}

const ToastBody = ({ message }: Props) => {
return (
<div className={classnames('lubycon-toast--inbox', 'lubycon-shadow--3')}>
<Text typography="p2">{message}</Text>
</div>
);
};

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

export interface ToastProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
show: boolean;
message: string;
autoHideDuration?: number;
onShow?: () => void;
onHide?: () => void;
}
const Toast = ({
show,
message,
autoHideDuration,
onShow,
onHide,
className,
style,
...rest
}: ToastProps) => {
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-toast', className)}
style={{
...style,
...props,
}}
{...rest}
>
<div>
<ToastBody message={message} />
</div>
</animated.div>
) : null;
})}
</>
);
};

export default Toast;
2 changes: 2 additions & 0 deletions ui-kit/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export { default as Radio } from './Radio';
export { default as Selection } from './Selection';
export { default as Switch } from './Switch';
export { default as Text } from './Text';
export { default as LubyconUIKitProvider } from './LubyconUIKitProvider';
export { default as Toast } from './Toast';
36 changes: 36 additions & 0 deletions ui-kit/src/contexts/Portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { createContext, ReactNode, useContext, useState } from 'react';
import { createPortal } from 'react-dom';

export const PortalContext = createContext<HTMLDivElement | null>(null);

interface PortalProviderProps {
children: ReactNode;
}

export function PortalProvider({ children }: PortalProviderProps) {
const [portalRef, setPortalRef] = useState<HTMLDivElement | null>(null);

return (
<PortalContext.Provider value={portalRef}>
{children}
<div
id="lubycon-portal-container"
ref={(element) => {
if (portalRef !== null || element === null) {
return;
}

setPortalRef(element);
}}
/>
</PortalContext.Provider>
);
}

interface PortalConsumerProps {
children: ReactNode;
}
export function Portal({ children }: PortalConsumerProps) {
const portalRef = useContext(PortalContext);
return portalRef == null ? null : createPortal(children, portalRef);
}
78 changes: 78 additions & 0 deletions ui-kit/src/contexts/Toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React, { ReactNode, createContext, useState, useCallback, useContext } from 'react';
import classnames from 'classnames';
import Toast, { ToastProps } from 'components/Toast';
import { generateID } from 'src/utils';
import { Portal } from './Portal';

interface ToastOptions extends Omit<ToastProps, 'show'> {
duration?: number;
}
interface ToastGlobalState {
openToast: (option: ToastOptions) => void;
closeToast: (toastId: string) => void;
}
const ToastContext = createContext<ToastGlobalState>({
openToast: () => {},
closeToast: () => {},
});

interface ToastProviderProps {
children: ReactNode;
maxStack?: number;
}
export function ToastProvider({ children, maxStack = 3 }: ToastProviderProps) {
const [openedToastsQueue, setOpenedToastsQueue] = useState<ToastOptions[]>([]);

const openToast = useCallback(
(option: ToastOptions) => {
const id = option.id ?? generateID('lubycon-toast');
const toast = { id, ...option };
const [, ...rest] = openedToastsQueue;

if (openedToastsQueue.length >= maxStack) {
setOpenedToastsQueue([...rest, toast]);
} else {
setOpenedToastsQueue([...openedToastsQueue, toast]);
}
},
[openedToastsQueue]
);

const closeToast = useCallback(
(closedToastId: string) => {
setOpenedToastsQueue(openedToastsQueue.filter((toast) => toast.id !== closedToastId));
},
[openedToastsQueue]
);

return (
<ToastContext.Provider
value={{
openToast,
closeToast,
}}
>
{children}
<Portal>
<div className={classnames('lubycon-toast--context-container')}>
{openedToastsQueue.map(({ id, onHide, duration = 3000, ...toastProps }) => (
<Toast
key={id}
show={true}
autoHideDuration={duration}
onHide={() => {
closeToast(id ?? '');
onHide?.();
}}
{...toastProps}
/>
))}
</div>
</Portal>
</ToastContext.Provider>
);
}

export function useToast() {
return useContext(ToastContext);
}
32 changes: 32 additions & 0 deletions ui-kit/src/sass/components/_Toast.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.lubycon-toast {
overflow: visible;

.lubycon-toast--inbox {
display: inline-block;
padding: 8px 16px;
min-width: 336px;
border-radius: 4px;
background-color: white;
margin: 12px 0;
}

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

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

.lubycon-toast--context-container {
display: flex;
position: fixed;
flex-direction: column-reverse;
top: auto;
right: auto;
bottom: 40px;
left: 40px;
}
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 @@ -7,3 +7,4 @@
@import './Switch';
@import './Selection';
@import './Icon';
@import './Toast';
Loading

0 comments on commit 680bee9

Please sign in to comment.