From d9fb989e130ebd911040da40faa635351e5efe2c Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Fri, 15 Dec 2023 03:18:06 +0900 Subject: [PATCH 1/7] =?UTF-8?q?:sparkles:=20Portal,=20AnimationPortal=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/portal/AnimationPortal.tsx | 31 +++++++++++++++++++++++ src/components/portal/Portal.tsx | 24 ++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 src/components/portal/AnimationPortal.tsx create mode 100644 src/components/portal/Portal.tsx diff --git a/src/components/portal/AnimationPortal.tsx b/src/components/portal/AnimationPortal.tsx new file mode 100644 index 00000000..fbe5699a --- /dev/null +++ b/src/components/portal/AnimationPortal.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { type ComponentProps } from 'react'; +import { AnimatePresence } from 'framer-motion'; + +import Portal from './Portal'; + +interface Props extends ComponentProps { + /** + * children의 렌더링 여부 + */ + isShowing: boolean; + /** + * framer-motion AnimatePresence의 mode + * @default 'wait' + */ + mode?: ComponentProps['mode']; +} + +/** + * @description Portal을 AnimatePresence 와 함께 사용합니다 + */ +const AnimatePortal = ({ children, isShowing, mode = 'wait' }: Props) => { + return ( + + {isShowing && children} + + ); +}; + +export default AnimatePortal; diff --git a/src/components/portal/Portal.tsx b/src/components/portal/Portal.tsx new file mode 100644 index 00000000..00153ad8 --- /dev/null +++ b/src/components/portal/Portal.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { type PropsWithChildren, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +/** + * @description react.createPortal을 이용해 document.body에 children을 렌더링합니다 + * @param children + */ +const Portal = ({ children }: PropsWithChildren) => { + const [container, setContainer] = useState(null); + + useEffect(() => { + if (document) { + setContainer(document.body); + } + }, []); + + if (!container) return null; + + return createPortal(children, container); +}; + +export default Portal; From cc11199a0837e915f8c775ce02eba8e70ec6681e Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Fri, 15 Dec 2023 03:53:38 +0900 Subject: [PATCH 2/7] =?UTF-8?q?:sparkles:=20Modal=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal.tsx | 45 ---------------- src/components/Modal/Modal.stories.tsx | 43 +++++++++++++++ src/components/Modal/Modal.tsx | 74 ++++++++++++++++++++++++++ src/hooks/lifeCycle/useOutsideClick.ts | 29 ++++++++++ 4 files changed, 146 insertions(+), 45 deletions(-) delete mode 100644 src/components/Modal.tsx create mode 100644 src/components/Modal/Modal.stories.tsx create mode 100644 src/components/Modal/Modal.tsx create mode 100644 src/hooks/lifeCycle/useOutsideClick.ts diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx deleted file mode 100644 index 9373c839..00000000 --- a/src/components/Modal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { css } from '@/styled-system/css'; -import { modalConstant } from '@/styles/constant'; -import { createPortal } from 'react-dom'; - -interface ModalProps { - isOpen: boolean; - onClose: () => void; - children: React.ReactNode; -} - -const Modal: React.FC = ({ isOpen, children, ...props }) => { - if (!isOpen) { - return null; - } - - return createPortal( -
-
{children}
-
, - document.body, - ); -}; -const modalOverlayCss = css({ - position: 'fixed', - top: '0', - left: '0', - width: '100%', - height: '100%', - background: 'rgba(0, 0, 0, 0.5)', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - zIndex: `${modalConstant}`, -}); -const modalContentCss = css({ - background: '#fff', - padding: '20px', - borderRadius: '30px', - maxWidth: '400px', - width: '100%', - textAlign: 'center', -}); - -export default Modal; diff --git a/src/components/Modal/Modal.stories.tsx b/src/components/Modal/Modal.stories.tsx new file mode 100644 index 00000000..c817cccc --- /dev/null +++ b/src/components/Modal/Modal.stories.tsx @@ -0,0 +1,43 @@ +import Button from '@/components/Button/Button'; +import Modal from '@/components/Modal/Modal'; +import useModal from '@/hooks/useModal'; +import type { Meta, StoryObj } from '@storybook/react'; +import { css } from '@styled-system/css'; + +const ModalStory = () => { + const { isOpen, openModal, closeModal } = useModal(); + return ( +
+ + +
+ 모달입니다 +
+
+
+ ); +}; + +const meta = { + title: 'Component/Modal', + component: ModalStory, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: {}, +}; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 00000000..0cb15bd8 --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React, { type PropsWithChildren, useRef } from 'react'; +import AnimatePortal from '@/components/portal/AnimationPortal'; +import useOutsideClick from '@/hooks/lifeCycle/useOutsideClick'; +import { modalConstant } from '@/styles/constant'; +import { css } from '@styled-system/css'; +import { type Property } from '@styled-system/types/csstype'; +import { motion } from 'framer-motion'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + padding?: Property.Padding; +} + +/** + * @description Modal 컴포넌트 + * @param isOpen 모달 오픈 여부 + * @param onClose 모달 닫기 + * @param padding 모달 패딩 값 (default: 20px 24px) + * @param children 모달 내부 컨텐츠 + * + */ +function Modal({ isOpen, children, onClose, padding = '20px 24px' }: PropsWithChildren) { + const modalRef = useRef(null); + + useOutsideClick({ + ref: modalRef, + handler: () => { + onClose(); + }, + }); + + return ( + +
+ + {children} + +
+
+ ); +} + +const modalOverlayCss = css({ + position: 'absolute', + top: '0', + left: '0', + width: '100%', + height: '100%', + background: 'scrim.screen', + padding: '28px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); +const modalContentCss = css({ + background: 'bg.surface4', + borderRadius: '30px', + maxWidth: '400px', + width: '100%', + zIndex: `${modalConstant}`, +}); + +export default Modal; diff --git a/src/hooks/lifeCycle/useOutsideClick.ts b/src/hooks/lifeCycle/useOutsideClick.ts new file mode 100644 index 00000000..2bc9b2a8 --- /dev/null +++ b/src/hooks/lifeCycle/useOutsideClick.ts @@ -0,0 +1,29 @@ +import { type RefObject, useEffect } from 'react'; + +type UseOutsideClickProps = { + ref: RefObject; + handler: (event: MouseEvent) => void; +}; + +/** + * @description ref를 제외한 영역을 클릭했을 때 실행되는 hook 입니다. + * @param ref + * @param handler 클릭 이벤트가 발생했을 때 실행되는 함수입니다. + */ +function useOutsideClick({ ref, handler }: UseOutsideClickProps) { + useEffect(() => { + const listener = (event: MouseEvent) => { + if (!ref.current || ref.current.contains(event.target as Node)) { + return; + } + handler(event); + }; + + document.addEventListener('mousedown', listener); + return () => { + document.removeEventListener('mousedown', listener); + }; + }); +} + +export default useOutsideClick; From db31892d47fbc97f65cd2c52d90b9198f000ab79 Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Fri, 15 Dec 2023 04:05:49 +0900 Subject: [PATCH 3/7] =?UTF-8?q?:sparkles:=20DefaultDialog=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dialog/DefaultDialog.tsx | 107 +++++++++++++++++++++++ src/components/Dialog/Dialog.stories.tsx | 39 +++++++++ src/components/Dialog/Dialog.tsx | 16 ++++ src/components/Dialog/Dialog.types.ts | 35 ++++++++ 4 files changed, 197 insertions(+) create mode 100644 src/components/Dialog/DefaultDialog.tsx create mode 100644 src/components/Dialog/Dialog.stories.tsx create mode 100644 src/components/Dialog/Dialog.tsx create mode 100644 src/components/Dialog/Dialog.types.ts diff --git a/src/components/Dialog/DefaultDialog.tsx b/src/components/Dialog/DefaultDialog.tsx new file mode 100644 index 00000000..71b8f504 --- /dev/null +++ b/src/components/Dialog/DefaultDialog.tsx @@ -0,0 +1,107 @@ +import Button from '@/components/Button/Button'; +import { type DefaultDialogProps } from '@/components/Dialog/Dialog.types'; +import Modal from '@/components/Modal/Modal'; +import { css } from '@styled-system/css'; + +/** + * @description DefaultDialog 컴포넌트 + * @param onCancel 취소 버튼 클릭 시 실행되는 함수 + * @param onConfirm 확인 버튼 클릭 시 실행되는 함수 + * @param cancelText 취소 버튼 텍스트 (없을시 버튼 노출 안함) + * @param confirmText 확인 버튼 텍스트 (없을시 버튼 노출 안함) + * @param content 다이얼로그 내용 + * @param title 다이얼로그 타이틀 + * + * @param onClose 다이얼로그 닫기 + * @param isOpen 다이얼로그 오픈 여부 + * @constructor + */ +function DefaultDialog({ + onCancel, + onConfirm, + cancelText, + confirmText, + title, + content, + onClose, + isOpen, +}: DefaultDialogProps) { + const handleCancel = () => { + onClose(); + onCancel && onCancel(); + }; + + const handleConfirm = () => { + onClose(); + onConfirm && onConfirm(); + }; + return ( + +
+
+ {title && ( +

+ {title} +

+ )} + {content && ( +

+ {content} +

+ )} +
+
+
+ {cancelText && ( + + )} + {confirmText && ( + + )} +
+
+
+
+ ); +} + +export default DefaultDialog; + +const dialogWrapperCss = css({ + display: 'flex', + flexDirection: 'column', + gap: '16px', +}); + +const textWrapperCss = css({ + display: 'flex', + flexDirection: 'column', + gap: '12px', +}); + +const buttonWrapperCss = css({ + display: 'flex', + justifyContent: 'flex-end', + gap: '4px', +}); diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx new file mode 100644 index 00000000..7cad880a --- /dev/null +++ b/src/components/Dialog/Dialog.stories.tsx @@ -0,0 +1,39 @@ +import Button from '@/components/Button/Button'; +import Dialog from '@/components/Dialog/Dialog'; +import { type DefaultDialogProps } from '@/components/Dialog/Dialog.types'; +import useModal from '@/hooks/useModal'; +import type { Meta, StoryObj } from '@storybook/react'; + +const DialogStory = (arg: Pick) => { + const { isOpen, openModal, closeModal } = useModal(); + return ( +
+ + +
+ ); +}; + +const meta = { + title: 'Component/Dialog', + component: DialogStory, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + title: 'Modal Title', + content: 'Modal Content', + cancelText: '취소', + confirmText: '확인', + }, +}; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx new file mode 100644 index 00000000..3dfd5cc6 --- /dev/null +++ b/src/components/Dialog/Dialog.tsx @@ -0,0 +1,16 @@ +import DefaultDialog from '@/components/Dialog/DefaultDialog'; +import { type DialogProps } from '@/components/Dialog/Dialog.types'; +import { type ModalProps } from '@/components/Modal/Modal'; + +/** + * dialog 컴포넌트 + * @description 각 dialog variant 타입에 따라 다른 컴포넌트들을 랜더링합니다. + * @param variant 'default' | 'list' | 'select' + */ +function Dialog(props: DialogProps & ModalProps) { + if (props.variant === 'default') { + return ; + } +} + +export default Dialog; diff --git a/src/components/Dialog/Dialog.types.ts b/src/components/Dialog/Dialog.types.ts new file mode 100644 index 00000000..f1492cdc --- /dev/null +++ b/src/components/Dialog/Dialog.types.ts @@ -0,0 +1,35 @@ +import { type ModalProps } from '@/components/Modal/Modal'; + +interface ButtonGroupProps { + confirmText?: string; + onConfirm?: () => void; + cancelText?: string; + onCancel?: () => void; +} + +type Value = { + value: string; + text: string; +}; + +export interface DefaultDialogProps extends ButtonGroupProps, ModalProps { + variant: 'default'; + title?: string; + content?: string; +} + +export interface ListDialogProps extends ModalProps { + variant: 'list'; + title: string; + list: Value[]; + onClick: (value: Value) => void; +} + +export interface SelectDialogProps extends Omit, ModalProps { + variant: 'select'; + title?: string; + content?: string; + selects: Value[]; + onConfirm: (value: Value) => void; +} +export type DialogProps = DefaultDialogProps | ListDialogProps | SelectDialogProps; From 5e085bcc69dc8f1364c8872f1500788dc07af857 Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Fri, 15 Dec 2023 04:09:53 +0900 Subject: [PATCH 4/7] =?UTF-8?q?:memo:=20Dialog=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dialog/Dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx index 3dfd5cc6..415443e1 100644 --- a/src/components/Dialog/Dialog.tsx +++ b/src/components/Dialog/Dialog.tsx @@ -5,7 +5,7 @@ import { type ModalProps } from '@/components/Modal/Modal'; /** * dialog 컴포넌트 * @description 각 dialog variant 타입에 따라 다른 컴포넌트들을 랜더링합니다. - * @param variant 'default' | 'list' | 'select' + * @param props.variant 'default' | 'list' | 'select' */ function Dialog(props: DialogProps & ModalProps) { if (props.variant === 'default') { From 35e69a608cb1cf91fded37afd19c536d4b6f6d75 Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Mon, 18 Dec 2023 15:05:17 +0900 Subject: [PATCH 5/7] =?UTF-8?q?:recycle:=20inline=20css=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dialog/DefaultDialog.tsx | 45 ++++++++++--------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/src/components/Dialog/DefaultDialog.tsx b/src/components/Dialog/DefaultDialog.tsx index 71b8f504..ad1a5749 100644 --- a/src/components/Dialog/DefaultDialog.tsx +++ b/src/components/Dialog/DefaultDialog.tsx @@ -39,38 +39,13 @@ function DefaultDialog({
- {title && ( -

- {title} -

- )} - {content && ( -

- {content} -

- )} + {title &&

{title}

} + {content &&

{content}

}
{cancelText && ( - )} @@ -88,6 +63,20 @@ function DefaultDialog({ export default DefaultDialog; +const dialogTitleCss = css({ + textStyle: 'title3', + color: 'text.primary', +}); + +const dialogContentCss = css({ + textStyle: 'body2', + color: 'text.secondary', +}); + +const dialogButtonTextCss = css({ + color: 'text.tertiary', +}); + const dialogWrapperCss = css({ display: 'flex', flexDirection: 'column', From e31dca08160cd59dc906fd9311758fd1cfe6adec Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Mon, 18 Dec 2023 15:06:02 +0900 Subject: [PATCH 6/7] =?UTF-8?q?:recycle:=20confirmText=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dialog/DefaultDialog.tsx | 8 +++----- src/components/Dialog/Dialog.types.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/Dialog/DefaultDialog.tsx b/src/components/Dialog/DefaultDialog.tsx index ad1a5749..a747e398 100644 --- a/src/components/Dialog/DefaultDialog.tsx +++ b/src/components/Dialog/DefaultDialog.tsx @@ -49,11 +49,9 @@ function DefaultDialog({ {cancelText} )} - {confirmText && ( - - )} +
diff --git a/src/components/Dialog/Dialog.types.ts b/src/components/Dialog/Dialog.types.ts index f1492cdc..f3b594ad 100644 --- a/src/components/Dialog/Dialog.types.ts +++ b/src/components/Dialog/Dialog.types.ts @@ -1,7 +1,7 @@ import { type ModalProps } from '@/components/Modal/Modal'; interface ButtonGroupProps { - confirmText?: string; + confirmText: string; onConfirm?: () => void; cancelText?: string; onCancel?: () => void; From ee1073a0ce77e2af1503b33df6317e5a73b83a38 Mon Sep 17 00:00:00 2001 From: Dongmin Ahn Date: Mon, 18 Dec 2023 15:07:52 +0900 Subject: [PATCH 7/7] =?UTF-8?q?:recycle:=20=ED=83=80=EC=9E=85=EC=84=A0?= =?UTF-8?q?=EC=96=B8=EA=B0=84=20=EC=A4=84=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dialog/Dialog.types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Dialog/Dialog.types.ts b/src/components/Dialog/Dialog.types.ts index f3b594ad..67f21628 100644 --- a/src/components/Dialog/Dialog.types.ts +++ b/src/components/Dialog/Dialog.types.ts @@ -32,4 +32,5 @@ export interface SelectDialogProps extends Omit, selects: Value[]; onConfirm: (value: Value) => void; } + export type DialogProps = DefaultDialogProps | ListDialogProps | SelectDialogProps;