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(ui-kit): Modal 컴포넌트 추가 #50

Merged
merged 16 commits into from
Feb 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions ui-kit/src/components/LubyconUIKitProvider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { ReactNode } from 'react';
import { ToastProvider } from 'contexts/Toast';
import { PortalProvider } from 'contexts/Portal';
import { SnackbarProvider } from 'src/contexts/Snackbar';
import { ModalProvider } from 'src/contexts/Modal';

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

interface ModalBackdropProps {
onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
}

const ModalBackdrop = forwardRef<HTMLDivElement, ModalBackdropProps>(function ModalBackdrop(
{ onClick },
ref
) {
return (
<div
ref={ref}
className={classnames('lubycon-modal', 'lubycon-modal__overlay')}
aria-hidden={true}
tabIndex={-1}
onClick={onClick}
/>
);
});

export default ModalBackdrop;
24 changes: 24 additions & 0 deletions ui-kit/src/components/Modal/ModalContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React, { ReactNode, isValidElement } from 'react';
import classnames from 'classnames';
import Text from 'components/Text';
import { Typographys } from 'components/Text/types';
import { CombineElementProps } from 'types/utils';

interface BaseProps {
size?: 'small' | 'medium';
children?: ReactNode;
}

type ModalContentProps = CombineElementProps<'div', BaseProps>;

const ModalContent = ({ children, size }: ModalContentProps) => {
const typography: Typographys = size === 'small' ? 'p2' : 'p1';

return (
<div className={classnames('lubycon-modal__content')}>
{isValidElement(children) ? children : <Text typography={typography}>{children}</Text>}
</div>
);
};

export default ModalContent;
11 changes: 11 additions & 0 deletions ui-kit/src/components/Modal/ModalFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React, { ReactNode } from 'react';

interface ModalFooterProps {
children?: ReactNode;
}

const ModalFooter = ({ children }: ModalFooterProps) => {
return <div className="lubycon-modal__footer">{children}</div>;
};

export default ModalFooter;
20 changes: 20 additions & 0 deletions ui-kit/src/components/Modal/ModalHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React, { ReactNode, isValidElement } from 'react';
import Text from 'components/Text';
import { Typographys } from 'components/Text/types';

interface ModalHeaderProps {
size?: 'small' | 'medium';
children?: ReactNode;
}

const ModalHeader = ({ size, children }: ModalHeaderProps) => {
const typography: Typographys = size === 'small' ? 'subtitle' : 'h6';

return (
<header className="lubycon-modal__title">
{isValidElement(children) ? children : <Text typography={typography}>{children}</Text>}
</header>
);
};

export default ModalHeader;
17 changes: 17 additions & 0 deletions ui-kit/src/components/Modal/ModalWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import classnames from 'classnames';

interface ModalWindowProps {
children: ReactNode;
size: 'small' | 'medium';
}

const ModalWindow = ({ children, size }: ModalWindowProps) => {
return (
<div className={classnames('lubycon-modal__window', `lubycon-modal__window--${size}`)}>
{children}
</div>
);
};

export default ModalWindow;
54 changes: 54 additions & 0 deletions ui-kit/src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { ReactElement, cloneElement, useRef } from 'react';
import ModalBackdrop from './ModalBackdrop';
import ModalWindow from './ModalWindow';
import classnames from 'classnames';
import { generateID } from 'utils/index';
import { useEffect } from 'react';

export interface ModalProps extends React.HTMLAttributes<HTMLDivElement> {
show: boolean;
size?: 'small' | 'medium';
children: ReactElement[];
onOpen?: () => void;
onClose?: () => void;
evan-moon marked this conversation as resolved.
Show resolved Hide resolved
}

const Modal = ({ show, size = 'small', children, onOpen, onClose }: ModalProps) => {
const backdropRef = useRef(null);
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (backdropRef.current == null) return;
if (event.target === backdropRef.current) onClose?.();
};

const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose?.();
};
useEffect(() => {
window.addEventListener('keydown', onKeydown);
return () => {
window.removeEventListener('keydown', onKeydown);
};
}, []);

useEffect(() => {
if (show === true) {
onOpen?.();
}
}, [show]);

return show ? (
<div className={classnames('lubycon-modal')} tabIndex={-1} aria-hidden={true}>
<ModalBackdrop onClick={handleBackdropClick} ref={backdropRef} />
<ModalWindow size={size}>
{children.map((element) => {
return cloneElement(element, { key: generateID('lubycon-modal__children'), size: size });
evan-moon marked this conversation as resolved.
Show resolved Hide resolved
})}
</ModalWindow>
</div>
) : null;
};

export default Modal;
export { default as ModalHeader } from './ModalHeader';
export { default as ModalContent } from './ModalContent';
export { default as ModalFooter } from './ModalFooter';
64 changes: 64 additions & 0 deletions ui-kit/src/contexts/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useContext, ReactNode, createContext, useState, useCallback } from 'react';
import Modal, { ModalProps } from 'components/Modal';
import { generateID } from 'src/utils';
import { Portal } from './Portal';

interface ModalOptions extends Omit<ModalProps, 'show'> {
test?: boolean;
}
interface ModalGlobalState {
openModal: (option: ModalOptions) => void;
closeModal: (modalId: string) => void;
}
interface ModalProviderProps {
children: ReactNode;
}

const ModalContext = createContext<ModalGlobalState>({
openModal: () => {},
closeModal: () => {},
});

export function ModalProvider({ children }: ModalProviderProps) {
const [openedModalStack, setOpenedModalStack] = useState<ModalOptions[]>([]);

const openModal = useCallback(
({ id = generateID('lubycon-modal'), ...option }: ModalOptions) => {
const modal = { id, ...option };
setOpenedModalStack([...openedModalStack, modal]);
},
[openedModalStack]
);
const closeModal = useCallback(
(closedModalId: string) => {
setOpenedModalStack(openedModalStack.filter((modal) => modal.id !== closedModalId));
},
[openedModalStack]
);

return (
<ModalContext.Provider
value={{
openModal,
closeModal,
}}
>
{children}
<Portal>
{openedModalStack.map(({ id, handleClick, ...modalProps }) => (
<Modal
show={true}
key={id}
onClose={() => closeModal(id ?? '')}
handleClick={() => handleClick?.()}
{...modalProps}
/>
))}
</Portal>
</ModalContext.Provider>
);
}

export function useModal() {
return useContext(ModalContext);
}
1 change: 1 addition & 0 deletions ui-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { default as List, ListItem } from './components/List';
export { default as Input } from './components/Input';
export { default as ProgressBar } from './components/ProgressBar';
export { default as Accordion } from './components/Accordion';
export { default as Modal, ModalHeader, ModalContent, ModalFooter } from './components/Modal';
export { Portal } from './contexts/Portal';
export { useToast } from './contexts/Toast';
export { useSnackbar } from './contexts/Snackbar';
Expand Down
47 changes: 47 additions & 0 deletions ui-kit/src/sass/components/_Modal.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.lubycon-modal {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
z-index: 1000;

&__overlay {
background-color: get-color('gray100');
opacity: 0.5;
}
&__window {
background-color: get-color('gray10');
border-radius: 4px;
box-sizing: border-box;
z-index: 1001;
&--small {
width: 280px;
padding: 16px 20px;
}
&--medium {
width: 400px;
padding: 20px 24px;
}
}
&__title {
color: get-color('gray100');
margin-top: 0;
margin-bottom: 12px;
}
&__content {
color: get-color('gray70');
margin-bottom: 24px;
white-space: pre-wrap;
}
&__footer {
display: flex;
align-items: center;
justify-content: flex-end;
}
}
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 @@ -18,3 +18,4 @@
@import './List';
@import './Input';
@import './ProgressBar';
@import './Modal';
42 changes: 42 additions & 0 deletions ui-kit/src/stories/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Meta } from '@storybook/react/types-6-0';
import React, { useState } from 'react';
import { Modal, ModalHeader, ModalContent, ModalFooter } from 'src';
import Button from 'components/Button';
import { colors } from 'constants/colors';

export default {
title: 'Lubycon UI kit/Modal',
component: Modal,
} as Meta;

export const Default = () => {
const [showModal, setShowModal] = useState(false);

const closeModal = () => setShowModal(false);
const handleOpen = () => console.info('open');

return (
<>
<Button onClick={() => setShowModal(true)}>모달 열기</Button>
<Modal show={showModal} onClose={closeModal} onOpen={handleOpen}>
<ModalHeader>타이틀입니다</ModalHeader>
<ModalContent className="Test">
<div>여기에 본문 텍스트가 들어갑니다</div>
<div>여기에 본문 텍스트가 들어갑니다</div>
</ModalContent>
<ModalFooter>
<Button
size="small"
style={{ color: colors.gray80, background: 'transparent', marginRight: '4px' }}
onClick={closeModal}
>
취소
</Button>
<Button size="small" onClick={closeModal}>
저장하기
</Button>
</ModalFooter>
</Modal>
</>
);
};