import React, { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { Button, type ButtonVariant } from './Button' // ─── Modal ──────────────────────────────────────────────────────────────────── export interface ModalProps { open: boolean onClose: () => void title?: string children: React.ReactNode footer?: React.ReactNode size?: 'sm' | 'md' | 'lg' } const sizeClass = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg' } export const Modal: React.FC = ({ open, onClose, title, children, footer, size = 'md', }) => { const dialogRef = useRef(null) useEffect(() => { if (!open) return const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } }, [open, onClose]) useEffect(() => { if (!open || !dialogRef.current) return const el = dialogRef.current const focusable = el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) const first = focusable[0] const last = focusable[focusable.length - 1] first?.focus() const trap = (e: KeyboardEvent) => { if (e.key !== 'Tab') return if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last?.focus() } } else { if (document.activeElement === last) { e.preventDefault(); first?.focus() } } } document.addEventListener('keydown', trap) return () => document.removeEventListener('keydown', trap) }, [open]) if (!open) return null return createPortal(
e.target === e.currentTarget && onClose()} >
{title && ( )}
{children}
{footer &&
{footer}
}
, document.body ) } // ─── SectionedModal ─────────────────────────────────────────────────────────── // 헤더/바디/푸터가 border로 분리되고 children이 자유롭게 들어가는 모달. // 풍부한 콘텐츠(폼, 리스트, 테이블, 경고/안내 박스 등)에 적합. const sectionedSizeClass = { sm: 'max-w-md', // 448px md: 'max-w-lg', // 512px lg: 'max-w-2xl', // 672px xl: 'max-w-4xl', // 896px '2xl': 'max-w-6xl', // 1152px } export interface SectionedModalProps { open: boolean onClose: () => void /** 헤더 좌측 제목. ReactNode 허용 (아이콘+텍스트 조합 등) */ title?: React.ReactNode /** 제목 좌측에 표시할 material icon 이름 (선택) */ headerIcon?: string /** 제목 우측에 표시할 부가 메타 (파일명·페이지 정보 등) */ headerExtra?: React.ReactNode /** 모달 본문 */ children: React.ReactNode /** 푸터 영역. 비우면 footer 자체가 렌더되지 않음 */ footer?: React.ReactNode /** 푸터 정렬. 기본 'end' (우측 정렬) */ footerAlign?: 'start' | 'end' | 'between' /** 모달 크기. 기본 'md'. xl/2xl은 풍부한 콘텐츠용 */ size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' /** body 영역 className override (기본 'px-5 py-4'). padding/style 커스터마이징용 */ bodyClassName?: string } export const SectionedModal: React.FC = ({ open, onClose, title, headerIcon, headerExtra, children, footer, footerAlign = 'end', size = 'md', bodyClassName = 'px-5 py-4', }) => { const dialogRef = useRef(null) useEffect(() => { if (!open) return const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } }, [open, onClose]) useEffect(() => { if (!open || !dialogRef.current) return const el = dialogRef.current const focusable = el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) const first = focusable[0] const last = focusable[focusable.length - 1] first?.focus() const trap = (e: KeyboardEvent) => { if (e.key !== 'Tab') return if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last?.focus() } } else { if (document.activeElement === last) { e.preventDefault(); first?.focus() } } } document.addEventListener('keydown', trap) return () => document.removeEventListener('keydown', trap) }, [open]) if (!open) return null const footerJustify = footerAlign === 'start' ? 'justify-start' : footerAlign === 'between' ? 'justify-between' : 'justify-end' return createPortal(
e.target === e.currentTarget && onClose()} >
{/* Header */}
{headerIcon && ( {headerIcon} )} {title && (

{title}

)} {headerExtra && (
{headerExtra}
)}
{/* Body */}
{children}
{/* Footer */} {footer && (
{footer}
)}
, document.body ) } // ─── ConfirmModal ───────────────────────────────────────────────────────────── type ConfirmVariant = 'delete' | 'reset' | 'confirm' const confirmConfig: Record< ConfirmVariant, { icon: string; iconBg: string; iconColor: string; btnVariant: ButtonVariant; btnClassName?: string } > = { delete: { icon: 'warning', iconBg: 'bg-red-50', iconColor: 'text-red-500', btnVariant: 'danger', }, reset: { icon: 'restart_alt', iconBg: 'bg-amber-50', iconColor: 'text-amber-500', btnVariant: 'primary', btnClassName: 'bg-amber-500 hover:bg-amber-600 active:bg-amber-700', }, confirm: { icon: 'help', iconBg: 'bg-teal-50', iconColor: 'text-teal-500', btnVariant: 'primary', }, } export interface ConfirmModalProps { open: boolean onClose: () => void onConfirm: () => void title: string description: string confirmLabel?: string cancelLabel?: string variant?: ConfirmVariant } export const ConfirmModal: React.FC = ({ open, onClose, onConfirm, title, description, confirmLabel = '확인', cancelLabel = '취소', variant = 'confirm', }) => { const cfg = confirmConfig[variant] useEffect(() => { if (!open) return const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', handleKey) document.body.style.overflow = 'hidden' return () => { document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } }, [open, onClose]) if (!open) return null return createPortal(
e.target === e.currentTarget && onClose()} >
{cfg.icon}

{title}

{description}

, document.body ) }