diff --git a/packages/core/lib/components/Modal.tsx b/packages/core/lib/components/Modal.tsx new file mode 100644 index 0000000..c850a2b --- /dev/null +++ b/packages/core/lib/components/Modal.tsx @@ -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> = (props) => ( + + + + +); + +export const Modal: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + children, + showCancelButton = true, + showCloseIcon = true, + width = 'm', + closeOnOverlayClick = true, + cancelLabel = '취소', + confirmLabel = '확인', +}) => { + const modalRef = useRef(null); + const previousFocusRef = useRef(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 ( +
+ +
e.stopPropagation()} + > +
+
+ +
+ +
+ {children} +
+ + {(onConfirm || showCancelButton) && ( +
+ {showCancelButton && ( + + )} + {onConfirm && ( + + )} +
+ )} + + {showCloseIcon && ( + + )} +
+
+
+ ); +}; diff --git a/packages/core/lib/index.ts b/packages/core/lib/index.ts index 2106604..bb7168f 100644 --- a/packages/core/lib/index.ts +++ b/packages/core/lib/index.ts @@ -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 { @@ -37,4 +38,5 @@ export { TextArea, Breadcrumb, Tabs, + Modal, }; diff --git a/stories/core/Modal.stories.tsx b/stories/core/Modal.stories.tsx new file mode 100644 index 0000000..ab11e23 --- /dev/null +++ b/stories/core/Modal.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +interface ModalWrapperProps { + title: string; + children: React.ReactNode; +} + +const ModalWrapper: React.FC = (args) => { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + setIsOpen(false)} + onCancel={() => setIsOpen(false)} + onConfirm={ + args.onConfirm + ? () => { + args.onConfirm(); + setIsOpen(false); + } + : undefined + } + /> +
+ ); +}; + +export const Default = { + render: (args) => , + args: { + title: '모달 제목', + children: ( +

+ 모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 수 있습니다. 모달 + 내용입니다. 여기에 원하는 컨텐츠를 넣을 수 있습니다.모달 내용입니다. + 여기에 원하는 컨텐츠를 넣을 수 있습니다.모달 내용입니다. 여기에 원하는 + 컨텐츠를 넣을 수 있습니다.모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 + 수 있습니다.모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 수 + 있습니다.모달 내용입니다. 여기에 원하는 컨텐츠를 넣을 수 있습니다. +

+ ), + showCancelButton: true, + showCloseIcon: true, + onConfirm: () => console.log('확인 함수'), + }, +}; + +export const NoCloseIcon = { + render: (args) => , + args: { + ...Default.args, + showCloseIcon: false, + }, +}; + +export const NoCancelButton = { + render: (args) => , + args: { + ...Default.args, + showCancelButton: false, + }, +}; + +export const ConfirmOnly = { + render: (args) => , + args: { + ...Default.args, + showCancelButton: false, + showCloseIcon: false, + closeOnOverlayClick: false, + }, +}; + +export const Medium = { + render: (args) => , + args: { + ...Default.args, + width: 'm', + }, +}; + +export const Large = { + render: (args) => , + args: { + ...Default.args, + width: 'l', + }, +}; + +export const CustomLabel = { + render: (args) => , + args: { + ...Default.args, + cancelLabel: '취소하기', + confirmLabel: '확인하기', + }, +}; + +export const NoFooter = { + render: (args) => , + args: { + ...Default.args, + onConfirm: undefined, + showCancelButton: false, + }, +};