/* ============================================================================ * 서명전에 — TDS Modal 컴포넌트 * · 사용자의 주의가 필요한 중요한 내용을 화면 위에 띄워요. 다른 화면을 * 덮고 사용자가 확인/액션을 끝내야만 돌아갈 수 있어요. * · 2 서브: * Modal.Overlay — 뒤에 깔리는 dim. role="button" 으로 클릭 가능 (onClick). * Modal.Content — 중앙에 뜨는 카드. 자동 포커스(tabIndex=0). * * · ReactDOM.createPortal 로 portalContainer (기본 document.body) 아래 렌더. * · open 이 true 가 되면 mount + 다음 프레임에서 entered 클래스 부여 → CSS * transition 으로 페이드/슬라이드 인. * · open 이 false 가 되면 entered 클래스를 떼고, content transitionend 후 * 실제 unmount + onExited() 호출. 안전장치로 setTimeout 폴백 (300ms). * * · 외부 콘텐츠 aria-hidden: * 열림 시 portalContainer 의 직속 자식들 중 우리 portal node 가 아닌 것들에 * aria-hidden="true" 부여. 닫힘 시 원상복구. * * · ESC 닫힘은 스펙에 명시 안 됐지만 모달 표준 — 추가 (closeOnEscape, 기본 true). * * · MODAL_CONTEXT 로 Overlay/Content 가 부모 Modal 의 상태를 공유해요. * ========================================================================== */ const __ModalContext = React.createContext({ isMounted: false, isEntered: false, contentRef: { current: null }, registerOverlay: function () {}, }); // ---------- Overlay ---------- function ModalOverlay({ onClick, className = "", style, ...rest }) { const ctx = React.useContext(__ModalContext); const handleClick = function (e) { if (typeof onClick === "function") onClick(e); }; const cls = ["tds-modal__overlay", className].filter(Boolean).join(" "); // role="button" + tabIndex=-1 (overlay 는 키보드 포커스 대상이 아니라 // 마우스 클릭 보조용. 키보드 사용자는 ESC 또는 안의 닫기 버튼 사용) return (
); } // ---------- Content ---------- function ModalContent({ children, className = "", style, ...rest }) { const ctx = React.useContext(__ModalContext); // mount 시 자동 포커스 — tabIndex={0} 으로 키보드 포커스 받을 수 있게 const localRef = React.useRef(null); const setRef = function (el) { localRef.current = el; if (ctx && ctx.contentRef) ctx.contentRef.current = el; }; React.useEffect(function () { if (!localRef.current) return undefined; // 다음 틱에 포커스 (애니메이션 시작 직후) const id = window.setTimeout(function () { if (localRef.current && typeof localRef.current.focus === "function") { try { localRef.current.focus({ preventScroll: true }); } catch (_) { localRef.current.focus(); } } }, 0); return function () { window.clearTimeout(id); }; }, []); const cls = ["tds-modal__content", className].filter(Boolean).join(" "); return (
{children}
); } // ---------- Modal (root, Portal + 상태) ---------- function Modal({ open, onOpenChange, onExited, portalContainer, closeOnEscape = true, children, }) { // mount 는 open 이 true 가 되는 순간 즉시, false 가 된 후엔 transition // 끝날 때까지 유지. entered 는 시각 상태 (CSS 클래스 토글). const [isMounted, setIsMounted] = React.useState(!!open); const [isEntered, setIsEntered] = React.useState(false); const contentRef = React.useRef(null); const exitTimerRef = React.useRef(null); const container = portalContainer && typeof portalContainer.appendChild === "function" ? portalContainer : (typeof document !== "undefined" ? document.body : null); // open 변화 처리 React.useEffect(function () { if (open) { // mount → 다음 프레임에 entered = true (transition 트리거) if (exitTimerRef.current) { window.clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } setIsMounted(true); // requestAnimationFrame 두 번 — 마운트 직후 첫 페인트를 기다린 뒤 클래스 토글 let raf2 = 0; const raf1 = window.requestAnimationFrame(function () { raf2 = window.requestAnimationFrame(function () { setIsEntered(true); }); }); return function () { window.cancelAnimationFrame(raf1); if (raf2) window.cancelAnimationFrame(raf2); }; } // close path — entered 떼고 transition 끝나면 unmount + onExited if (!isMounted) return undefined; setIsEntered(false); // 안전장치: 300ms 안에 transitionend 가 안 와도 강제 unmount if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current); exitTimerRef.current = window.setTimeout(function () { setIsMounted(false); if (typeof onExited === "function") onExited(); exitTimerRef.current = null; }, 300); return function () { // open 이 다시 true 로 바뀌면 이 effect 가 cleanup 됨 — 타이머 정리 if (exitTimerRef.current) { window.clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // body 스크롤 락 — 마운트되어 있는 동안만 React.useEffect(function () { if (!isMounted || typeof document === "undefined") return undefined; const root = document.documentElement; root.classList.add("tds-modal-open"); return function () { root.classList.remove("tds-modal-open"); }; }, [isMounted]); // 외부 콘텐츠 aria-hidden — portalContainer 의 직속 자식들 중 우리 portal 외 모두 React.useEffect(function () { if (!isMounted || !container) return undefined; // 우리 portal 의 부모 div 를 식별하기 위해 마커 — container 에 추가될 마지막 자식 const portalNodes = Array.prototype.filter.call( container.children, function (n) { return n && n.classList && n.classList.contains("tds-modal-portal"); } ); const ourPortal = portalNodes[portalNodes.length - 1]; const siblings = Array.prototype.filter.call( container.children, function (n) { return n !== ourPortal; } ); const restore = []; siblings.forEach(function (el) { if (!el || !el.setAttribute) return; restore.push({ el: el, prev: el.getAttribute("aria-hidden") }); el.setAttribute("aria-hidden", "true"); }); return function () { restore.forEach(function (r) { if (r.prev == null) r.el.removeAttribute("aria-hidden"); else r.el.setAttribute("aria-hidden", r.prev); }); }; }, [isMounted, container]); // ESC 닫기 React.useEffect(function () { if (!isMounted || !closeOnEscape) return undefined; const onKey = function (e) { if (e.key === "Escape" || e.key === "Esc") { e.stopPropagation(); if (typeof onOpenChange === "function") onOpenChange(false); } }; document.addEventListener("keydown", onKey); return function () { document.removeEventListener("keydown", onKey); }; }, [isMounted, closeOnEscape, onOpenChange]); // content transitionend 로 unmount 트리거 — 안전장치 setTimeout 보다 먼저 도착 // 이 시점에 onExited 도 호출. 단 entered 가 false 일 때만 (퇴장 transition). const onPortalTransitionEnd = function (e) { if (isEntered) return; // overlay/content 양쪽 transition 이 끝나는데, opacity 만 보면 충분 if (e && e.propertyName && e.propertyName !== "opacity") return; if (exitTimerRef.current) { window.clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } setIsMounted(false); if (typeof onExited === "function") onExited(); }; if (!isMounted || !container) return null; // 자동으로 Modal.Overlay / Modal.Content 가 둘 다 들어 있지 않은 경우 // (예: {children only}) 도 렌더는 시도해 보되, 권장은 둘 다. const cls = ["tds-modal", isEntered ? "tds-modal--entered" : ""].filter(Boolean).join(" "); return ReactDOM.createPortal( <__ModalContext.Provider value={{ isMounted: isMounted, isEntered: isEntered, contentRef: contentRef }} >
{children}
, container ); } Modal.Overlay = ModalOverlay; Modal.Content = ModalContent; window.Modal = Modal; window.ModalOverlay = ModalOverlay; window.ModalContent = ModalContent; Object.assign(window, { Modal, ModalOverlay, ModalContent });