-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/#550 Confirm Modal 구현 #553
Changes from all commits
d3aee9e
3a8246e
31ecdd0
580124d
3273fa7
c25781d
d526aa9
e382050
a526142
1179f35
dbc1c32
2af5e0b
6441a12
3f60df5
a225ac0
aab67ba
67963b6
b1ad8a7
ac5d64c
f27bd28
a6eb162
a2fb196
b94ea94
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
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; | ||
`; |
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} | ||
`; |
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; | ||
} | ||
Comment on lines
+11
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 denial, confirmation 메세지가 옵셔널이네요~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬 요거 위쪽의 ConfirmModalProps랑 비슷한 타입이라서 재사용을 해볼 수도 있을것 같기도 하고요~ 의도를 몰라서 우선 남겨봅니다~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
제가 조심스럽게 타입을 사용하는 경향이 있어요! 우선 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
아 네엡 저도 버튼의 메세지를 말한거였습니당
이게 메인 질문이었어요! |
||
|
||
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; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
inset
으로 1줄로 줄여볼까용~?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좋아요!