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] Modal 컴포넌트, 스토리북 제작 #19

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"framer-motion": "^11.15.0",
"framer-motion": "^11.18.2",
"init": "^0.1.2",
"jest": "^29.7.0",
"msw": "^2.7.0",
Expand Down
6 changes: 6 additions & 0 deletions src/shared/constants/motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const MODAL_MOTION = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.3 },
};
73 changes: 73 additions & 0 deletions src/shared/ui/Modal/Modal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { Modal } from './Modal';

export default {
title: 'components/common/Modal',
component: Modal,
tags: ['autodocs'],
argTypes: {
isOpen: {
control: 'boolean',
defaultValue: false,
},
mode: {
control: { type: 'select' },
options: ['wait', 'sync', 'popLayout'],
defaultValue: 'sync',
},
},
parameters: {
docs: {
description: {
component:
'사용자가 원하는 상태 모드에 따라 **모달을 띄우고 닫을 수 있는** 컴포넌트입니다.',
},
},
},
} satisfies Meta<typeof Modal>;

export const Basic: StoryObj<typeof Modal> = {
args: {
isOpen: false,
},
render: (args) => {
const [isOpen, setIsOpen] = useState(args.isOpen);

const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);

return (
<div className="flex flex-col items-center gap-4">
<button
className="rounded-md bg-primary-300 px-4 py-2 font-semibold text-white"
onClick={openModal}
>
모달 열기
</button>

<Modal {...args} isOpen={isOpen} closeModal={closeModal}>
<div className="flex flex-col items-center gap-4 rounded-lg bg-white p-6 shadow-sm">
<div className="p-2 text-2xl font-semibold">ddingdong 모달입니다.</div>

<div className="flex gap-3">
<button
onClick={closeModal}
className="rounded-md bg-gray-100 px-4 py-2 font-semibold text-black hover:bg-gray-200"
>
취소
</button>
<button
onClick={closeModal}
className="rounded-md bg-red-200 px-4 py-2 font-semibold text-white hover:bg-red-300"
>
확인하기
</button>
</div>
</div>
</Modal>
</div>
);
},
};
50 changes: 50 additions & 0 deletions src/shared/ui/Modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useEffect, useRef } from 'react';
import { motion } from 'framer-motion';

import { MODAL_MOTION } from '@/shared/constants/motion';

import { Portal } from '../Portal';

type Props = {
isOpen: boolean;
closeModal: () => void;
children: React.ReactNode;
mode?: 'wait' | 'sync' | 'popLayout';
};

export function Modal({ isOpen, closeModal, children, mode }: Props) {
const modalRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
closeModal();
}
};

if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}

return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, closeModal]);
ujinsim marked this conversation as resolved.
Show resolved Hide resolved

return (
<Portal isOpen={isOpen} mode={mode}>
<motion.div
{...MODAL_MOTION}
className="fixed inset-0 z-30 flex w-full items-center justify-center"
>
<div
ref={modalRef}
onClick={(e) => e.stopPropagation()}
className="items-center justify-center rounded-lg bg-white shadow-[0px_20px_24px_rgba(16,24,40,0.1),_0px_8px_8px_-4px_rgba(16,24,40,0.04)]"
>
{children}
</div>
</motion.div>
</Portal>
);
}
1 change: 1 addition & 0 deletions src/shared/ui/Modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Modal } from './Modal';
21 changes: 21 additions & 0 deletions src/shared/ui/Portal/Portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AnimatePresence } from 'framer-motion';
import ReactDom from 'react-dom';

type Props = {
isOpen: boolean;
mode?: 'wait' | 'sync' | 'popLayout';
children: React.ReactNode;
};

export function Portal({ isOpen, mode = 'sync', children }: Props) {
if (typeof window === 'undefined' || !isOpen) {
return null;
}

const container = document.body as HTMLElement;

return ReactDom.createPortal(
<AnimatePresence mode={mode}>{children}</AnimatePresence>,
container
);
}
1 change: 1 addition & 0 deletions src/shared/ui/Portal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Portal } from './Portal';