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: add modal component #82

Merged
merged 2 commits into from
Aug 20, 2024
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
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,
},
};