Skip to content

Commit

Permalink
Feat/#550 Confirm Modal 구현 (#553)
Browse files Browse the repository at this point in the history
* 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 사용 시 객체분해할당 오류 수정
  • Loading branch information
ukkodeveloper authored Nov 23, 2023
1 parent ebf9b3d commit 5b336df
Show file tree
Hide file tree
Showing 6 changed files with 319 additions and 2 deletions.
1 change: 0 additions & 1 deletion frontend/practice.js

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ const RegisterButton = styled.button`
@media (min-width: ${({ theme }) => theme.breakPoints.md}) {
padding: 11px 15px;
font-size: 18px;
}
`;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof ConfirmModalProvider> = {
title: 'shared/Confirm',
component: ConfirmModalProvider,
decorators: [
(Story) => (
<ConfirmModalProvider>
<Story />
</ConfirmModalProvider>
),
],
};

export default meta;

type Story = StoryObj<typeof ConfirmModalProvider>;

export const Example: Story = {
render: () => {
const Modal = () => {
const { confirmPopup } = useConfirmContext();

const clickHiByeBtn = async () => {
const isConfirmed = await confirmPopup({
title: '하이바이 모달',
content: (
<>
<p>도밥은 정말 도밥입니까?</p>
<p>코난은 정말 코난입니까?</p>
</>
),
denial: '바이',
confirmation: '하이',
});

if (isConfirmed) {
alert('confirmed');
return;
}

alert('denied');
};

// denial과 confirmation 기본값은 '닫기'와 '확인'입니다.
const clickOpenCloseBtn = async () => {
const isConfirmed = await confirmPopup({
title: '오쁜클로즈 모달',
content: (
<>
<p>코난은 정말 코난입니까?</p>
<p>도밥은 정말 도밥입니까?</p>
</>
),
});

if (isConfirmed) {
alert('confirmed');
return;
}

alert('denied');
};

return (
<Body>
<Button onClick={clickHiByeBtn}>하이바이 모달열기</Button>
<Button onClick={clickOpenCloseBtn}>닫기확인 모달열기</Button>
</Body>
);
};

return <Modal />;
},
};

const Body = styled.div`
height: 2400px;
`;

const Button = styled.button`
padding: 4px 11px;
color: white;
border: 2px solid white;
border-radius: 4px;
`;
114 changes: 114 additions & 0 deletions frontend/src/shared/components/ConfirmModal/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> = (dom) => {
dom && dom.focus();
};

return (
<>
<Backdrop role="dialog" aria-modal="true" />
<Container>
<Title ref={focusTitle} tabIndex={0}>
{title}
</Title>
<Spacing direction="vertical" size={10} />
<Content>{content}</Content>
<Spacing direction="vertical" size={10} />
<ButtonFlex $gap={16}>
<DenialButton type="button" onClick={onDeny}>
{denial}
</DenialButton>
<ConfirmButton type="button" onClick={onConfirm}>
{confirmation}
</ConfirmButton>
</ButtonFlex>
</Container>
</>
);
};

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}
`;
105 changes: 105 additions & 0 deletions frontend/src/shared/components/ConfirmModal/ConfirmModalProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<null | {
confirmPopup: (modalState: ModalContents) => Promise<boolean>;
}>(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<ModalContents>({
title: '',
content: '',
denial: '닫기',
confirmation: '확인',
});
const { title, content, denial, confirmation } = modalContents;

// ContextAPI를 통해 confirm 함수만 제공합니다.
const confirmPopup = (contents: ModalContents) => {
openModal();
setModalContents(contents);

const promise = new Promise<boolean>((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 (
<ConfirmContext.Provider value={{ confirmPopup }}>
{children}
{isOpen &&
createPortal(
<ConfirmModal
title={title}
content={content}
denial={denial ?? '닫기'}
confirmation={confirmation ?? '확인'}
onDeny={onDeny}
onConfirm={onConfirm}
/>,
document.body
)}
</ConfirmContext.Provider>
);
};

export default ConfirmModalProvider;
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit 5b336df

Please sign in to comment.