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 ( +
+ + + +
+
+
+ + 26h +
+ + + +
+
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 ( +
+ + + +
+
+
+ + 26h +
+ + + +
+
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: , + }, ], }, ]);