μΉ΄ν
κ³ λ¦¬ μμ
[UI] Modal [UIμμ λ§λ€κΈ°]
κ±°λ
2025. 4. 13. 22:52
π λͺ¨λ¬(Modal) μ»΄ν¬λνΈλ?
λͺ¨λ¬(Modal)μ μ¬μ©μμκ² μ€μν μ 보λ₯Ό μ λ¬νκ±°λ, μ¬μ©μλ‘λΆν° μ λ ₯μ λ°μ λ νλ©΄ μμ κ²Ήμ³μ νμλλ UI μ»΄ν¬λνΈμ λλ€. λ³΄ν΅ λ°°κ²½μ νλ¦¬κ² μ²λ¦¬νμ¬ μ¬μ©μ μμ μ λͺ¨λ¬μ μ§μ€μν€λ©°, μ¬μ©μμ νλ¦μ μ μ λ©μΆκ² νλ μ°¨λ¨μ μΈν°νμ΄μ€μ λλ€.
π― μ£Όμ κΈ°λ₯
- μ€μ μ 보 νμ: κ²½κ³ , νμΈ μμ², νΌ μ λ ₯ λ± μ€μν λ΄μ©μ μ¬μ©μμκ² μ λ¬.
- μ°¨λ¨μ UI: λͺ¨λ¬μ΄ μ΄λ¦° λμμλ λ°°κ²½κ³Όμ μνΈμμ©μ΄ λΆκ°λ₯νμ¬ μ¬μ©μμ μ£Όμλ₯Ό μ§μ€μν΄.
- λ«κΈ° κΈ°λ₯ μ 곡: ‘λ«κΈ°’ λ²νΌ, λ°κΉ₯ μμ ν΄λ¦, ESC ν€ λ± λ€μν λ°©μμΌλ‘ λ«μ μ μμ.
- 컀μ€ν°λ§μ΄μ§ κ°λ₯: μ λͺ©, λ³Έλ¬Έ, λ²νΌ λ±μ μμ λ‘κ² μ‘°ν©νμ¬ λ€μν λͺ©μ μ λ§κ² μ¬μ© κ°λ₯.
- μ λλ©μ΄μ : λΆλλ¬μ΄ νμ΄λ μΈ/μμ λ±μ μ ν ν¨κ³Όλ‘ μμ°μ€λ¬μ΄ μ¬μ©μ κ²½ν μ 곡.
βοΈκ΅¬ν λ΄μ©
μ»΄νμ΄λ ν¨ν΄μ νμ©ν λͺ¨λ¬ μ»΄ν¬λνΈ λ§λ€κΈ°
μ»΄νμ΄λ ν¨ν΄μ΄λ?
Reactμμ μ»΄νμ΄λ ν¨ν΄μ μ¬λ¬ κ°μ νμ μ»΄ν¬λνΈλ₯Ό νλμ μμ μ»΄ν¬λνΈμ ν¨κ» μ‘°ν©ν΄ μ¬μ©νλ ν¨ν΄μ λλ€. μμ μ»΄ν¬λνΈκ° 곡ν΅λ μνλ λ‘μ§μ κ΄λ¦¬νκ³ , νμ μ»΄ν¬λνΈλ€μ μ΄ μ½ν μ€νΈμ μ κ·Όνμ¬ λμνλλ‘ μ€κ³λ©λλ€.
import { ReactNode, SyntheticEvent } from 'react'
import cx from '../cx'
import { useSetModals } from './modalContext'
const Modal = ({
id,
hideOnClickOutside = false,
children,
}: {
id: string
hideOnClickOutside?: boolean
children: ReactNode
}) => {
const { closeModal } = useSetModals()
const closeThis = () => closeModal(id)
const stopPropagation = (e: SyntheticEvent) => e.stopPropagation()
return (
<div className={cx('Modal')} onClick={hideOnClickOutside ? closeThis : undefined}>
<div className={cx('inner')} onClick={stopPropagation}>
{children}
</div>
</div>
)
}
const ModalHeader = ({
title,
children,
hide,
}: {
title?: string
children?: ReactNode
hide?: () => void
}) => {
return (
<div className={cx('ModalHeader')}>
<div className={cx('title')}>{title}</div>
{children}
<button className={cx('close')} onClick={hide} />
</div>
)
}
const ModalContent = ({ children }: { children: ReactNode }) => {
return <div className={cx('ModalContent')}>{children}</div>
}
const ModalFooter = ({ children }: { children: ReactNode }) => {
return <div className={cx('ModalFooter')}>{children}</div>
}
Modal.Header = ModalHeader
Modal.Content = ModalContent
Modal.Footer = ModalFooter
/* Compound Component */
export default Modal
//ex: Alert Modal
export const AlertModal = ({ id, text }: { id: string; text: string }) => {
const { closeModal } = useSetModals()
const closeThis = () => closeModal(id)
return (
<Modal id={id}>
<Modal.Content>
<p>{text}</p>
</Modal.Content>
<Modal.Footer>
<button onClick={closeThis}>νμΈ</button>
</Modal.Footer>
</Modal>
)
}
μ½ν μ€νΈλ₯Ό νμ©ν λͺ¨λ¬ ꡬν
Modal Provider
type ModalState = Map<string, ReactNode>
type ModalDispatchState = Dispatch<SetStateAction<ModalState>>
const ModalContext = createContext<ModalState>(new Map())
const ModalDispatchContext = createContext<ModalDispatchState>(() => {})
export const ModalContextProvider = ({ children }: { children: ReactNode }) => {
const [modals, setModals] = useState<ModalState>(new Map())
const modalValues = Array.from(modals.values())
useEffect(() => {
document.body.classList.toggle('no-scroll', modals.size > 0)
}, [modals])
return (
<ModalContext.Provider value={modals}>
<ModalDispatchContext.Provider value={setModals}>
{children}
<div id="modalRoot">
{modalValues.map((children, i) => (
<Fragment key={i}>{children}</Fragment>
))}
</div>
</ModalDispatchContext.Provider>
</ModalContext.Provider>
)
}
- λͺ¨λ¬μ λͺ¨λ¬ id → ReactNode μνλ‘ κ΄λ¦¬
export const useSetModals = () => {
const setModals = useContext(ModalDispatchContext)
const openModal = useCallback((id: string, children: ReactNode) => {
setModals(prev => {
const newMap = new Map(prev)
newMap.set(id, children)
return newMap
})
}, [])
const closeModal = useCallback((id: string) => {
setModals(prev => {
const newMap = new Map(prev)
newMap.delete(id)
return newMap
})
}, [])
// const closeAll
return {
openModal,
closeModal,
}
}
id
μ 리μ‘νΈ λ Έλλ₯Ό λ°λopenModal
κ³Όid
λ₯Ό λ°μ λͺ¨λ¬μ μ κ±°νλclose
λͺ¨λ¬- μ΄ λ°©λ²μ μ₯μ μ λͺ¨λ λͺ¨λ¬μ λͺ¨λ¬ μ½ν μ€νΈ μ€μμμ κ΄λ¦¬ν μ μλ€λ μ . μ΄λ¬ν μ₯μ μΌλ‘, λͺ¨λ¬ κ°μμ λ°λ₯Έ νμ΄μ§ μ€ν¬λ‘€ μ κ±° μ²λ¦¬λ₯Ό ν μ μμ λΏλ§ μλλΌ λͺ¨λ¬μ κ°μλ₯Ό μ ννκ±°λ μΆκ°μ μΈ κΈ°νμ μ μ©νκ² λμ²ν μ μμ
- νμ§λ§ λ¨μ λ μμ. μ¬μ©ν λ μλ μ½λμ²λΌ μ¬μ©νκ² λλλ°, μ΄λ κ² λλ©΄ λͺ¨λ¬μμ μνλ
openAlertModalμ
μ¬μ©ν λ κ²°μ λλ©° μ λ°μ΄νΈκ° μΌμ΄λμ§ μμ
// openAlertModalμ νΈμΆνλ μμ μ idμ textκ° κ²°μ
const AlertTrigger = ({ id, text }: { id: string; text: string }) => {
const { openModal } = useSetModals()
const openAlertModal = () => {
openModal(id, <AlertModal id={id} text={text} />)
}
return <button onClick={openAlertModal}>μΌλΏ λμ°κΈ°</button>
}
Create Portal
const Modal = ({
hideOnClickOutside = false,
children,
opened,
hide,
}: {
hideOnClickOutside?: boolean
children: ReactNode
opened: boolean
hide: () => void
}) => {
const stopPropagation = (e: SyntheticEvent) => e.stopPropagation()
return opened
? createPortal(
<div className={cx('Modal')} onClick={hideOnClickOutside ? hide : undefined}>
<div className={cx('inner')} onClick={stopPropagation}>
{children}
</div>
</div>,
document.querySelector('#modalRoot')!,
)
: null
}
- λͺ¨λ¬μ μ¬λ
createPortal
μ½λκ° λͺ¨λ¬ ν¨μ λ΄λΆμ μμ
const useModal = () => {
const [opened, toggleModal] = useState(false)
const openModal = useCallback(() => {
toggleModal(true)
}, [])
const closeModal = useCallback(() => {
toggleModal(false)
}, [])
return {
opened,
openModal,
closeModal,
}
}
- λͺ¨λ¬μ μνκ° μ μμ μΌλ‘ 곡μ λμ§ μκΈ° λλ¬Έμ λͺ¨λ¬μ΄ μ΄λ¦΄μ§ λ§μ§μ λν μνλ§ λͺ¨λ¬μκ² μ κ³΅ν΄ μ£Όλ©΄ λ¨
- μ΄λ κ² νλ©΄ λͺ¨λ¬μ΄
react jsx
λ΄λΆμ μκΈ° λλ¬Έμ μνκ° νμ λ°μλλ€.
const ConfirmTrigger = ({ id, children }: { id: string; children: ReactNode }) => {
const { opened, openModal, closeModal } = useModal()
const [confirmed, setConfirmed] = useState<boolean | null>(null)
return (
<>
<button onClick={openModal}>νμΈλͺ¨λ¬μ΄κΈ° {confirmed ? 'νμΈλ¨' : 'νμΈμλ¨'}</button>
<ConfirmModal
opened={opened}
confirmed={confirmed}
onConfirm={() => {
setConfirmed(true)
closeModal()
}}
onCancel={() => {
setConfirmed(false)
closeModal()
}}
hide={closeModal}
>
{children}
</ConfirmModal>
</>
)
}
- νμ§λ§, μ΄λ κ² λλ©΄ λͺ¨λ¬μ λ§μ΄ κ°μ§κ³ μλ μ»΄ν¬λνΈμ κ²½μ°μλ
jsx
λΆλΆμ λͺ¨λ¬μ΄ λ§μμ Έ κ°λ μ±μ΄ λ¨μ΄μ§ μ μλ€. - λν, κ°κ°μ λͺ¨λ¬μ μ€μμμ κ΄λ¦¬νκ³ μμ§ μκΈ° λλ¬Έμ λͺ¨λ¬ κ°μλ₯Ό 컨νΈλ‘€νκ±°λ λͺ¨λ¬μ΄ μ΄λ €μλμ§ νμΈνκΈ° μ΄λ ΅λ€.
const mutationObserverOption: MutationObserverInit = {
childList: true,
subtree: false,
}
const ModalRoot = () => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
let observer: MutationObserver
if (ref.current) {
observer = new MutationObserver(() => {
const size = ref.current?.childNodes.length || 0
document.body.classList.toggle('no-scroll', size > 0)
})
observer.observe(ref.current, mutationObserverOption)
}
return () => {
observer.disconnect()
}
}, [])
return <div id="modalRoot" ref={ref} />
}
- μ μ½λμμ νμ¬ λͺ¨λ¬μ΄ μ΄λ €μλμ§ νμΈνκ³ μν΄
mutationObserver
λ₯Ό μ¬μ©νμmodalRoot
λ₯Ό κ°μνλ©° μμμ΄ μΆκ°λ λλ§λ€ μ€ν¬λ‘€μ μ κ±°