/* ============================================================================
* 서명전에 — 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 });