From 5b336df15f5e631766202c9d174b33fb4e6e102f Mon Sep 17 00:00:00 2001 From: ukkodeveloper Date: Thu, 23 Nov 2023 13:51:56 +0900 Subject: [PATCH] =?UTF-8?q?Feat/#550=20Confirm=20Modal=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: confirm provider 뼈대 구현 * feat: confirm modal 수락 기능 추가 * feat: confirmModal 스타일 및 분리 * feat: ConfirmProvider 분리 * feat: ConfirmModal storybook 추가 * feat: storybook 예시 추가 * refactor: useConfirm 분리 * refactor: confirm modal 로직 응집하기 위해 useEffect 이동 * fix: promise에서 resolve 함수 상태 변경 실패로 인한 ref 사용 * refactor: createPortal를 ConfirmModal 내부로 이동 * refactor: 의미있는 네이밍으로 변경 * fix: keydown 이벤트 적용되지 않는 현상 수정 * style: style lint 적용 및 개행 * chore: 사용하지 않는 파일 삭제 * fix: resolverRef 타입 변경 * feat: 닫기, 수락 button에 type 추가 * refactor: 네이밍 cancel에서 denial으로 변경 * feat: 모달 열릴 때 바로 title로 포커스 이동할 수 있도록 수정 * refactor: 중복된 createPortal 삭제 * refactor: theme 활용하여 색상 코드 변경 * refactor: 전반적인 ConfirmModal 네이밍 변경 - ConfirmProvider > ConfirmModalProvider - useConfirm > useConfirmContext - confirm > confirmPopup * fix: theme color 사용 시 객체분해할당 오류 수정 --- frontend/practice.js | 1 - .../killingParts/components/RegisterPart.tsx | 1 - .../ConfirmModal/ConfirmModal.stories.tsx | 89 ++++++++++++++ .../components/ConfirmModal/ConfirmModal.tsx | 114 ++++++++++++++++++ .../ConfirmModal/ConfirmModalProvider.tsx | 105 ++++++++++++++++ .../ConfirmModal/hooks/useConfirmContext.ts | 11 ++ 6 files changed, 319 insertions(+), 2 deletions(-) delete mode 100644 frontend/practice.js create mode 100644 frontend/src/shared/components/ConfirmModal/ConfirmModal.stories.tsx create mode 100644 frontend/src/shared/components/ConfirmModal/ConfirmModal.tsx create mode 100644 frontend/src/shared/components/ConfirmModal/ConfirmModalProvider.tsx create mode 100644 frontend/src/shared/components/ConfirmModal/hooks/useConfirmContext.ts diff --git a/frontend/practice.js b/frontend/practice.js deleted file mode 100644 index b85d61ba..00000000 --- a/frontend/practice.js +++ /dev/null @@ -1 +0,0 @@ -const a = new Date(); //? diff --git a/frontend/src/features/killingParts/components/RegisterPart.tsx b/frontend/src/features/killingParts/components/RegisterPart.tsx index 8e72cc36..e5a38841 100644 --- a/frontend/src/features/killingParts/components/RegisterPart.tsx +++ b/frontend/src/features/killingParts/components/RegisterPart.tsx @@ -75,7 +75,6 @@ const RegisterButton = styled.button` @media (min-width: ${({ theme }) => theme.breakPoints.md}) { padding: 11px 15px; - font-size: 18px; } `; diff --git a/frontend/src/shared/components/ConfirmModal/ConfirmModal.stories.tsx b/frontend/src/shared/components/ConfirmModal/ConfirmModal.stories.tsx new file mode 100644 index 00000000..9bc0284e --- /dev/null +++ b/frontend/src/shared/components/ConfirmModal/ConfirmModal.stories.tsx @@ -0,0 +1,89 @@ +import styled from 'styled-components'; +import ConfirmModalProvider from './ConfirmModalProvider'; +import { useConfirmContext } from './hooks/useConfirmContext'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'shared/Confirm', + component: ConfirmModalProvider, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = { + render: () => { + const Modal = () => { + const { confirmPopup } = useConfirmContext(); + + const clickHiByeBtn = async () => { + const isConfirmed = await confirmPopup({ + title: '하이바이 모달', + content: ( + <> +

도밥은 정말 도밥입니까?

+

코난은 정말 코난입니까?

+ + ), + denial: '바이', + confirmation: '하이', + }); + + if (isConfirmed) { + alert('confirmed'); + return; + } + + alert('denied'); + }; + + // denial과 confirmation 기본값은 '닫기'와 '확인'입니다. + const clickOpenCloseBtn = async () => { + const isConfirmed = await confirmPopup({ + title: '오쁜클로즈 모달', + content: ( + <> +

코난은 정말 코난입니까?

+

도밥은 정말 도밥입니까?

+ + ), + }); + + if (isConfirmed) { + alert('confirmed'); + return; + } + + alert('denied'); + }; + + return ( + + + + + ); + }; + + return ; + }, +}; + +const Body = styled.div` + height: 2400px; +`; + +const Button = styled.button` + padding: 4px 11px; + color: white; + border: 2px solid white; + border-radius: 4px; +`; diff --git a/frontend/src/shared/components/ConfirmModal/ConfirmModal.tsx b/frontend/src/shared/components/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 00000000..d8920af3 --- /dev/null +++ b/frontend/src/shared/components/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,114 @@ +import { Flex } from 'shook-layout'; +import styled, { css } from 'styled-components'; +import Spacing from '../Spacing'; +import type { ReactNode } from 'react'; + +interface ConfirmModalProps { + title: string; + content: ReactNode; + denial: string; + confirmation: string; + onDeny: () => void; + onConfirm: () => void; +} + +const ConfirmModal = ({ + title, + content, + denial, + confirmation, + onDeny, + onConfirm, +}: ConfirmModalProps) => { + const focusTitle: React.RefCallback = (dom) => { + dom && dom.focus(); + }; + + return ( + <> + + + + {title} + + + {content} + + + + {denial} + + + {confirmation} + + + + + ); +}; + +export default ConfirmModal; + +const Backdrop = styled.div` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + + width: 100%; + height: 100%; + margin: 0; + padding: 0; + + background-color: rgba(0, 0, 0, 0.7); +`; + +const Container = styled.section` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + min-width: 300px; + margin: 0 auto; + padding: 24px; + + color: ${({ theme: { color } }) => color.white}; + + background-color: ${({ theme: { color } }) => color.black300}; + border: none; + border-radius: 16px; +`; + +const ButtonFlex = styled(Flex)` + width: 100%; +`; + +const Title = styled.header` + font-size: 18px; + text-align: left; +`; + +const Content = styled.div``; + +const buttonStyle = css` + flex: 1; + + width: 100%; + height: 36px; + + color: ${({ theme: { color } }) => color.white}; + + border-radius: 10px; +`; + +const DenialButton = styled.button` + background-color: ${({ theme: { color } }) => color.secondary}; + ${buttonStyle} +`; + +const ConfirmButton = styled.button` + background-color: ${({ theme: { color } }) => color.primary}; + ${buttonStyle} +`; diff --git a/frontend/src/shared/components/ConfirmModal/ConfirmModalProvider.tsx b/frontend/src/shared/components/ConfirmModal/ConfirmModalProvider.tsx new file mode 100644 index 00000000..3a6b0145 --- /dev/null +++ b/frontend/src/shared/components/ConfirmModal/ConfirmModalProvider.tsx @@ -0,0 +1,105 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { createContext, useCallback, useEffect, useState, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import ConfirmModal from './ConfirmModal'; +import type { ReactNode } from 'react'; + +export const ConfirmContext = createContext Promise; +}>(null); + +interface ModalContents { + title: string; + content: ReactNode; + denial?: string; + confirmation?: string; +} + +const ConfirmModalProvider = ({ children }: { children: ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + const resolverRef = useRef<{ + resolve: (value: boolean) => void; + } | null>(null); + const [modalContents, setModalContents] = useState({ + title: '', + content: '', + denial: '닫기', + confirmation: '확인', + }); + const { title, content, denial, confirmation } = modalContents; + + // ContextAPI를 통해 confirm 함수만 제공합니다. + const confirmPopup = (contents: ModalContents) => { + openModal(); + setModalContents(contents); + + const promise = new Promise((resolve) => { + resolverRef.current = { resolve }; + }); + + return promise; + }; + + const closeModal = () => { + setIsOpen(false); + }; + + const openModal = () => { + setIsOpen(true); + }; + + const resolveConfirmation = (status: boolean) => { + if (resolverRef?.current) { + resolverRef.current.resolve(status); + } + }; + + const onDeny = useCallback(() => { + resolveConfirmation(false); + closeModal(); + }, []); + + const onConfirm = useCallback(() => { + resolveConfirmation(true); + closeModal(); + }, []); + + const onKeyDown = useCallback(({ key }: KeyboardEvent) => { + if (key === 'Escape') { + resolveConfirmation(false); + closeModal(); + } + }, []); + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', onKeyDown); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.body.style.overflow = 'auto'; + }; + }, [isOpen]); + + return ( + + {children} + {isOpen && + createPortal( + , + document.body + )} + + ); +}; + +export default ConfirmModalProvider; diff --git a/frontend/src/shared/components/ConfirmModal/hooks/useConfirmContext.ts b/frontend/src/shared/components/ConfirmModal/hooks/useConfirmContext.ts new file mode 100644 index 00000000..e9bcfc94 --- /dev/null +++ b/frontend/src/shared/components/ConfirmModal/hooks/useConfirmContext.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { ConfirmContext } from '../ConfirmModalProvider'; + +export const useConfirmContext = () => { + const contextValue = useContext(ConfirmContext); + if (!contextValue) { + throw new Error('ConfirmContext Provider 내부에서 사용 가능합니다.'); + } + + return contextValue; +};