diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 817ac3ce..f8328580 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -1,4 +1,5 @@
import type { Preview } from '@storybook/react';
+import 'reset-css';
const preview: Preview = {
parameters: {
diff --git a/src/App.tsx b/src/App.tsx
index 0ddb9df4..033d0b14 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,6 +16,7 @@ const App = () => {
이모션 테스트
Test Constant Page
Test 5 Page
+ Modal Test Page
하단버튼
diff --git a/src/assets/icons/caretDown.svg b/src/assets/icons/caretDown.svg
new file mode 100644
index 00000000..5aa9497f
--- /dev/null
+++ b/src/assets/icons/caretDown.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/icons/hourGlass.svg b/src/assets/icons/hourGlass.svg
new file mode 100644
index 00000000..a2cc1db5
--- /dev/null
+++ b/src/assets/icons/hourGlass.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/icons/siren.svg b/src/assets/icons/siren.svg
new file mode 100644
index 00000000..2c83c794
--- /dev/null
+++ b/src/assets/icons/siren.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/IconButton/index.stories.tsx b/src/components/IconButton/index.stories.tsx
index 192a4372..a0ca521f 100644
--- a/src/components/IconButton/index.stories.tsx
+++ b/src/components/IconButton/index.stories.tsx
@@ -31,7 +31,7 @@ export const Primary: Story = {
};
export const 아이콘_버튼: StoryObj = {
- storyName: '아이콘 버튼(상단, 하단, 오른쪽, 왼쪽 화살표)',
+ name: '아이콘 버튼(상단, 하단, 오른쪽, 왼쪽 화살표)',
render: () => (
diff --git a/src/components/Modal/index.stories.tsx b/src/components/Modal/index.stories.tsx
new file mode 100644
index 00000000..38d542d2
--- /dev/null
+++ b/src/components/Modal/index.stories.tsx
@@ -0,0 +1,170 @@
+import { Fragment, useRef } from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { css } from '@emotion/react';
+import useModal from '@/hooks/useModal';
+import HourGlass from '@/assets/icons/hourGlass.svg?react';
+import CaretDown from '@/assets/icons/caretDown.svg?react';
+import Siren from '@/assets/icons/siren.svg?react';
+import IconButton from '../IconButton';
+import Button from '../Button';
+import Modal from '.';
+
+const styles = {
+ container: css`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ box-sizing: border-box;
+ min-height: 100svh;
+ padding: 1rem;
+ `,
+ header: css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0.25rem 0 0;
+ color: white;
+ `,
+ from: css`
+ display: inline-block;
+ margin-right: 0.5rem;
+ color: var(--gray1, #333);
+ font-weight: 600;
+ font-size: 0.875rem;
+ line-height: 1.5rem;
+ `,
+ nickname: css`
+ color: var(--gray2, #4f4f4f);
+ font-weight: 500;
+ font-size: 0.875rem;
+ line-height: 1rem;
+ `,
+ glassLabel: css`
+ display: flex;
+ gap: 0.25rem;
+ justify-content: center;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ border-radius: 6.25rem;
+ background-color: white;
+ color: black;
+ `,
+ mainSection: css`
+ flex-grow: 1;
+ `,
+ card: css`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 1.25rem 1.25rem 2.5rem;
+ border-radius: 0.5rem;
+ background-color: white;
+ `,
+ paragraph: css`
+ font-size: 0.875rem;
+ line-height: 1.5rem;
+ `,
+ date: css`
+ color: var(--gray4, #bdbdbd);
+ font-size: 0.75rem;
+ line-height: 1rem;
+ text-align: right;
+ `,
+ bottomCard: css`
+ display: flex;
+ gap: 0.5rem;
+ justify-content: center;
+ align-items: center;
+ padding: 1.25rem;
+ border-radius: 0.5rem;
+ background-color: white;
+ color: var(--gray4, #bdbdbd);
+ cursor: pointer;
+ user-select: none;
+ `,
+};
+
+const meta = {
+ title: 'Components/Modal',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ '모달 컴포넌트입니다.\nESC 키를 누르거나, 모달 바깥을 클릭하면 모달이 닫힙니다.',
+ },
+ },
+ },
+ argTypes: {},
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const 편지_작성_모달: Story = {
+ render: () => {
+ const ModalTestPage = () => {
+ const modal = useModal();
+ const backgroundRef = useRef(null);
+ const { open, close } = modal;
+
+ return (
+
+
모달 열기
+
+
+
+
+ e.target === backgroundRef.current && close()}
+ >
+
+
+ From.
+ 낯선고양이
+
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ 여기까지가 끝인가 보오 이제 나는 돌아서겠소 억지
+ 노력으로 인연을 거슬러 괴롭히지는 않겠소 하고 싶은 말
+ 하려 했던 말 이대로 다 남겨두고서 혹시나 기대도 포기하려
+ 하오 그대 부디 잘 지내시오 기나긴 그대 침묵을 이별로
+ 받아두겠소 행여 이 맘 다칠까 근심은 접어두오 오 사랑한
+ 사람이여 더 이상 못보아도 사실 그대 있음으로 힘겨운
+ 날들을 견뎌 왔음에 감사하오 좋은 사람 만나오 사는 동안
+ 날 잊고 사시오 진정 행복하길 바라겠소 이 맘만 가져가오
+ 기나긴 그대 침묵을 이별로 받아두겠소 행여 이 맘 다칠까
+ 근심은 접어두오 오 사랑한 사람이여
+
+ ))}
+
+
24년 01월 20일
+
+
+
+ 접기
+
+
+
+
+
+ );
+ };
+
+ return ;
+ },
+};
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
new file mode 100644
index 00000000..f015e2f1
--- /dev/null
+++ b/src/components/Modal/index.tsx
@@ -0,0 +1,45 @@
+import React, { useEffect, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import useModal from '@/hooks/useModal';
+import styles from './styles';
+
+interface ModalProps extends ReturnType {
+ /** 모달 컨텐츠 영역에 보여줄 컨텐츠입니다. */
+ children: React.ReactNode;
+}
+
+const Modal = ({ isOpen, close, children }: ModalProps) => {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ close();
+ }
+ };
+
+ window.addEventListener('keydown', handleEscape);
+ return () => window.removeEventListener('keydown', handleEscape);
+ }, [isOpen, close]);
+
+ return createPortal(
+
+ {isOpen && (
+ e.target === containerRef.current && close()}
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ exit={{ opacity: 0 }}
+ >
+ {children}
+
+ )}
+ ,
+ document.body,
+ );
+};
+
+export default Modal;
diff --git a/src/components/Modal/styles.ts b/src/components/Modal/styles.ts
new file mode 100644
index 00000000..2a176ee5
--- /dev/null
+++ b/src/components/Modal/styles.ts
@@ -0,0 +1,18 @@
+import { css } from '@emotion/react';
+
+const styles = {
+ container: css`
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ min-height: 100%;
+ background-color: rgb(0 0 0 / 0.6);
+ `,
+ content: css`
+ max-width: 600px;
+ margin: 0 auto;
+ `,
+};
+
+export default styles;
diff --git a/src/hooks/useModal.ts b/src/hooks/useModal.ts
new file mode 100644
index 00000000..2e559497
--- /dev/null
+++ b/src/hooks/useModal.ts
@@ -0,0 +1,13 @@
+import { useCallback, useState } from 'react';
+
+const useModal = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
+ const open = useCallback(() => setIsOpen(true), []);
+ const close = useCallback(() => setIsOpen(false), []);
+
+ return { isOpen, open, close, toggle };
+};
+
+export default useModal;
diff --git a/src/pages/ModalTestPage/index.tsx b/src/pages/ModalTestPage/index.tsx
new file mode 100644
index 00000000..f6f7768d
--- /dev/null
+++ b/src/pages/ModalTestPage/index.tsx
@@ -0,0 +1,70 @@
+import { Fragment, useRef } from 'react';
+import Modal from '@/components/Modal';
+import HourGlass from '@/assets/icons/hourGlass.svg?react';
+import CaretDown from '@/assets/icons/caretDown.svg?react';
+import Siren from '@/assets/icons/siren.svg?react';
+import useModal from '@/hooks/useModal';
+import IconButton from '@/components/IconButton';
+import Button from '@/components/Button';
+import styles from './styles';
+
+/** TODO: 모달 확인용 페이지입니다. 추후 제거해도 좋습니다. */
+const ModalTestPage = () => {
+ const modal = useModal();
+ const backgroundRef = useRef(null);
+ const { open, close } = modal;
+
+ return (
+
+
모달 열기
+
+
+
+
+ e.target === backgroundRef.current && close()}
+ >
+
+
+ From.
+ 낯선고양이
+
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ 여기까지가 끝인가 보오 이제 나는 돌아서겠소 억지 노력으로
+ 인연을 거슬러 괴롭히지는 않겠소 하고 싶은 말 하려 했던 말
+ 이대로 다 남겨두고서 혹시나 기대도 포기하려 하오 그대 부디
+ 잘 지내시오 기나긴 그대 침묵을 이별로 받아두겠소 행여 이 맘
+ 다칠까 근심은 접어두오 오 사랑한 사람이여 더 이상 못보아도
+ 사실 그대 있음으로 힘겨운 날들을 견뎌 왔음에 감사하오 좋은
+ 사람 만나오 사는 동안 날 잊고 사시오 진정 행복하길 바라겠소
+ 이 맘만 가져가오 기나긴 그대 침묵을 이별로 받아두겠소 행여
+ 이 맘 다칠까 근심은 접어두오 오 사랑한 사람이여
+
+ ))}
+
+
24년 01월 20일
+
+
+
+ 접기
+
+
+
+
+
+ );
+};
+
+export default ModalTestPage;
diff --git a/src/pages/ModalTestPage/styles.ts b/src/pages/ModalTestPage/styles.ts
new file mode 100644
index 00000000..0dcc78ae
--- /dev/null
+++ b/src/pages/ModalTestPage/styles.ts
@@ -0,0 +1,80 @@
+import { css } from '@emotion/react';
+
+const styles = {
+ container: css`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ box-sizing: border-box;
+ min-height: 100svh;
+ padding: 1rem;
+ `,
+ header: css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0.25rem 0 0;
+ color: white;
+ `,
+ from: css`
+ display: inline-block;
+ margin-right: 0.5rem;
+ color: var(--gray1, #333);
+ font-weight: 600;
+ font-size: 0.875rem;
+ line-height: 1.5rem;
+ `,
+ nickname: css`
+ color: var(--gray2, #4f4f4f);
+ font-weight: 500;
+ font-size: 0.875rem;
+ line-height: 1rem;
+ `,
+ glassLabel: css`
+ display: flex;
+ gap: 0.25rem;
+ justify-content: center;
+ align-items: center;
+ padding: 0.5rem 1rem;
+ border-radius: 6.25rem;
+ background-color: white;
+ color: black;
+ `,
+ mainSection: css`
+ flex-grow: 1;
+ `,
+ card: css`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 1.25rem 1.25rem 2.5rem;
+ border-radius: 0.5rem;
+ background-color: white;
+ `,
+ paragraph: css`
+ font-size: 0.875rem;
+ line-height: 1.5rem;
+ `,
+ date: css`
+ color: var(--gray4, #bdbdbd);
+ font-size: 0.75rem;
+ line-height: 1rem;
+ text-align: right;
+ `,
+ bottomCard: css`
+ display: flex;
+ gap: 0.5rem;
+ justify-content: center;
+ align-items: center;
+ padding: 1.25rem;
+ border-radius: 0.5rem;
+ background-color: white;
+ color: var(--gray4, #bdbdbd);
+ cursor: pointer;
+ user-select: none;
+ `,
+};
+
+export default styles;
diff --git a/src/router.tsx b/src/router.tsx
index da8051e7..5d974778 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -1,10 +1,12 @@
import { createBrowserRouter } from 'react-router-dom';
import App from './App';
+import ModalTestPage from './pages/ModalTestPage';
const ROUTER_PATHS = {
ROOT: '/',
TEST_CONSTANT: '/test/const',
TEST_VARIABLE: (variableId: string) => `/test/variable/${variableId}`,
+ MODAL_TEST: '/modal-test',
} as const;
const router = createBrowserRouter([
@@ -20,6 +22,10 @@ const router = createBrowserRouter([
path: ROUTER_PATHS.TEST_VARIABLE(':variableId'),
element: test variable path
,
},
+ {
+ path: ROUTER_PATHS.MODAL_TEST,
+ element: ,
+ },
],
},
]);