μΉ΄ν…Œκ³ λ¦¬ μ—†μŒ

[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λ₯Ό κ°μ‹œν•˜λ©° μžμ‹μ΄ 좔가될 λ•Œλ§ˆλ‹€ μŠ€ν¬λ‘€μ„ 제거