Skip to content

Commit

Permalink
feat: add modal component (#82)
Browse files Browse the repository at this point in the history
Signed-off-by: Lukas.J.Han <[email protected]>
  • Loading branch information
lukasjhan authored Aug 20, 2024
1 parent eb2ccda commit d657f38
Show file tree
Hide file tree
Showing 3 changed files with 300 additions and 0 deletions.
153 changes: 153 additions & 0 deletions packages/core/lib/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React, { useEffect, useRef } from 'react';
import { Button } from './Button';
import { Label } from './Label';

interface ModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm?: () => void;
title: string;
children: React.ReactNode;
showCancelButton?: boolean;
showCloseIcon?: boolean;
width?: 'm' | 'l';
closeOnOverlayClick?: boolean;
cancelLabel?: string;
confirmLabel?: string;
}

const CloseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);

export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
children,
showCancelButton = true,
showCloseIcon = true,
width = 'm',
closeOnOverlayClick = true,
cancelLabel = '취소',
confirmLabel = '확인',
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);

useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
previousFocusRef.current?.focus();
}

return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);

if (!isOpen) return null;

const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};

const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation();
onClose();
};

const modalWidthStyle = {
m: 'w-[560px]',
l: 'w-[850px]',
}[width];

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none"
onClick={closeOnOverlayClick ? handleCancel : undefined}
>
<div
className="fixed inset-0 bg-black opacity-50"
aria-hidden="true"
></div>
<div
ref={modalRef}
className={`relative mx-auto my-6 bg-white rounded-lg shadow-lg ${modalWidthStyle}`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col max-h-[90vh]">
<div className="flex items-start justify-between p-5 border-b border-gray-10">
<Label id="modal-title" size="l" weight="bold" color="gray-90">
{title}
</Label>
</div>

<div className="relative flex-auto p-6 overflow-y-auto">
{children}
</div>

{(onConfirm || showCancelButton) && (
<div className="flex items-center justify-end p-6 border-t border-gray-10 gap-4">
{showCancelButton && (
<Button
variant="tertiary"
onClick={handleCancel}
size="medium"
className="px-6"
>
{cancelLabel}
</Button>
)}
{onConfirm && (
<Button
variant="primary"
onClick={onConfirm}
size="medium"
className="px-6"
>
{confirmLabel}
</Button>
)}
</div>
)}

{showCloseIcon && (
<button
className="absolute top-5 right-5 p-1 ml-auto text-gray-50 transition-colors duration-200 hover:text-gray-70 hover:bg-gray-20 rounded-2"
onClick={handleCancel}
aria-label="닫기"
>
<CloseIcon />
</button>
)}
</div>
</div>
</div>
);
};
2 changes: 2 additions & 0 deletions packages/core/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Chip } from './components/Chip';
import { Checkbox } from './components/Checkbox';
import { RadioButtonGroup } from './components/RadioButton';
import { Tabs } from './components/Tab';
import { Modal } from './components/Modal';

export { Display, Heading, Title, Body, Detail, Label, Link, colors };
export {
Expand All @@ -37,4 +38,5 @@ export {
TextArea,
Breadcrumb,
Tabs,
Modal,
};
145 changes: 145 additions & 0 deletions stories/core/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Modal } from '../../packages/core/lib';

const meta = {
title: 'Components/Modal',
component: Modal,
parameters: {
layout: 'fullscreen',
docs: {
story: {
inline: false,
iframeHeight: 800,
},
},
},
tags: ['autodocs'],
argTypes: {
onClose: { action: 'clicked' },
title: { control: 'text' },
children: { control: 'text' },
isOpen: { control: 'boolean' },
},
} satisfies Meta<typeof Modal>;

export default meta;
type Story = StoryObj<typeof meta>;

interface ModalWrapperProps {
title: string;
children: React.ReactNode;
}

const ModalWrapper: React.FC<ModalWrapperProps> = (args) => {
const [isOpen, setIsOpen] = useState(false);

return (
<div style={{ width: '100vw', height: '100vh', position: 'relative' }}>
<button
style={{
border: '1px solid black',
padding: '10px',
borderRadius: '4px',
}}
onClick={() => setIsOpen(true)}
>
모달 열기
</button>
<Modal
{...args}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onCancel={() => setIsOpen(false)}
onConfirm={
args.onConfirm
? () => {
args.onConfirm();
setIsOpen(false);
}
: undefined
}
/>
</div>
);
};

export const Default = {
render: (args) => <ModalWrapper {...args} />,
args: {
title: '모달 제목',
children: (
<p>
모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 수 있습니다. 모달
내용입니다. 여기에 원하는 컨텐츠를 넣을 수 있습니다.모달 내용입니다.
여기에 원하는 컨텐츠를 넣을 수 있습니다.모달 내용입니다. 여기에 원하는
컨텐츠를 넣을 수 있습니다.모달 내용입니다. 여기에 원하는 컨텐츠를 넣을
수 있습니다.모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 수
있습니다.모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 수 있습니다.
</p>
),
showCancelButton: true,
showCloseIcon: true,
onConfirm: () => console.log('확인 함수'),
},
};

export const NoCloseIcon = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
showCloseIcon: false,
},
};

export const NoCancelButton = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
showCancelButton: false,
},
};

export const ConfirmOnly = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
showCancelButton: false,
showCloseIcon: false,
closeOnOverlayClick: false,
},
};

export const Medium = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
width: 'm',
},
};

export const Large = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
width: 'l',
},
};

export const CustomLabel = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
cancelLabel: '취소하기',
confirmLabel: '확인하기',
},
};

export const NoFooter = {
render: (args) => <ModalWrapper {...args} />,
args: {
...Default.args,
onConfirm: undefined,
showCancelButton: false,
},
};

0 comments on commit d657f38

Please sign in to comment.