/* ============================================================================ * 서명전에 — TDS Overlay Extension (Task #170 #171 #172) * * · TDS Mobile 2026-03 Overlay Extension 스펙 — useDialog / useToast / useBottomSheet * 세 개의 훅(hook)을 제공해, 기존 컴포넌트를 명령형으로 띄울 수 있게 해요. * * useDialog → openAlert / openConfirm / openAsyncConfirm * (AlertDialog #161 · ConfirmDialog #162 래핑) * useToast → openToast / closeToast * (Toast #154 래핑 — 웹 3000ms · 앱 5000ms 기본) * useBottomSheet → open / close * openOneButtonSheet / openTwoButtonSheet / openAsyncTwoButtonSheet * (BottomSheet #125 래핑) * * · 구조 — 모듈 스코프 싱글턴 `__OverlayManager` 가 Map 를 들고 * `` 컴포넌트가 그 상태를 구독해 document.body 위에 모든 오버레이를 * 렌더링. 훅은 manager 의 push/pop 메서드만 노출하는 thin wrapper. * → 같은 페이지에서 여러 컴포넌트가 동시에 훅을 호출해도 충돌 없이 쌓여요. * * · Async 메서드: * openAsyncConfirm → Promise (confirm=true / cancel=false) * openAsyncTwoButtonSheet → Promise (right=true / left=false) * Promise resolve 직후 오버레이는 자동으로 닫혀요. reject 는 없어요 — 사용자 * 취소(뒤로가기 · 딤 · ESC) 도 false 로 resolve. * * · ID 할당 — `++__overlayIdSeq` 단순 증가. 동일 훅 호출에서 open 이 여러 번 * 호출돼도 기존을 close 하지 않고 stack — 원하는 동작이 아닐 경우 호출자가 * close 후 다시 open. * * · 환경 감지 (Toast duration) — window.TossAdapter.isInToss() 가 true 면 5000ms, * 아니면 3000ms. button 이 있으면 duration 관계없이 기본 5000ms (Toast 컴포넌트 * 내부 로직 그대로 유지 — 오버라이드하지 않음). * * · UNSAFE_ 플래그 — BottomSheet 옵션은 그대로 패스스루. 접근성 우려가 있어 * 이름에 UNSAFE_ 접두사. 사용 전 접근성 팀 리뷰 필요. * * · 접근성: * - 오버레이 오픈 시 트리거 요소(activeElement) 를 기억, 닫힐 때 포커스 복원. * - 여러 오버레이가 쌓이면 가장 최근 것만 포커스 트랩(기본) — bottomsheet 의 * UNSAFE_disableFocusLock 으로 오버라이드 가능. * - reduce-motion 은 개별 컴포넌트 CSS 에서 이미 처리. * * · Babel Standalone 환경 호환: __ox 접두사 alias. * ========================================================================== */ const __oxUseState = (typeof React !== "undefined" && React.useState) || function (v) { return [v, function () {}]; }; const __oxUseEffect = (typeof React !== "undefined" && React.useEffect) || function () {}; const __oxUseCallback = (typeof React !== "undefined" && React.useCallback) || function (fn) { return fn; }; const __oxUseMemo = (typeof React !== "undefined" && React.useMemo) || function (fn) { return fn(); }; const __oxUseRef = (typeof React !== "undefined" && React.useRef) || function (v) { return { current: v }; }; /* ---------------------------------------------------------------------------- * 싱글턴 manager * -------------------------------------------------------------------------- */ let __overlayIdSeq = 0; const __overlayListeners = new Set(); /** * overlays: Array<{ * id: number, * kind: "alert" | "confirm" | "toast" | "bottomsheet", * props: object, * // 내부 상태 — closing 이면 open=false 로 바뀌어 퇴장 애니메이션 중 * closing: boolean, * // activeElement 복원용 * trigger: HTMLElement | null, * // async 해제 용 (confirm / two-button-sheet 에서만 사용) * resolve: ((v: boolean) => void) | null, * }> */ const __overlayState = { overlays: [] }; function __oxEmit() { const listeners = Array.from(__overlayListeners); for (let i = 0; i < listeners.length; i += 1) { try { listeners[i](); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] listener 실패", e); } } } function __oxPush(kind, props, resolve) { const id = ++__overlayIdSeq; const trigger = (typeof document !== "undefined" && document.activeElement instanceof HTMLElement) ? document.activeElement : null; __overlayState.overlays = __overlayState.overlays.concat([{ id: id, kind: kind, props: props || {}, closing: false, trigger: trigger, resolve: resolve || null, }]); __oxEmit(); return id; } function __oxUpdate(id, patch) { let changed = false; __overlayState.overlays = __overlayState.overlays.map(function (o) { if (o.id !== id) return o; changed = true; return Object.assign({}, o, patch); }); if (changed) __oxEmit(); } function __oxClose(id, asyncValue) { let target = null; __overlayState.overlays = __overlayState.overlays.map(function (o) { if (o.id !== id) return o; target = o; return Object.assign({}, o, { closing: true }); }); if (target) { if (typeof target.resolve === "function") { try { target.resolve(!!asyncValue); } catch (_) {} } __oxEmit(); } } function __oxRemove(id) { const before = __overlayState.overlays.length; let target = null; __overlayState.overlays = __overlayState.overlays.filter(function (o) { if (o.id === id) { target = o; return false; } return true; }); if (before !== __overlayState.overlays.length) { // 포커스 복원 if (target && target.trigger && typeof target.trigger.focus === "function" && document.contains(target.trigger)) { try { target.trigger.focus(); } catch (_) {} } __oxEmit(); } } function __oxSubscribe(listener) { __overlayListeners.add(listener); return function () { __overlayListeners.delete(listener); }; } /* ---------------------------------------------------------------------------- * OverlayRoot — document.body 에 싱글 마운트되어 모든 오버레이 렌더 * -------------------------------------------------------------------------- */ function OverlayRoot() { const [tick, setTick] = __oxUseState(0); __oxUseEffect(function () { const unsubscribe = __oxSubscribe(function () { setTick(function (n) { return n + 1; }); }); return unsubscribe; }, []); // tick 은 재렌더 트리거용 — 실제 값은 __overlayState 에서 읽음 void tick; const nodes = []; const overlays = __overlayState.overlays; for (let i = 0; i < overlays.length; i += 1) { const o = overlays[i]; const node = __oxRender(o); if (node) nodes.push(node); } return React.createElement(React.Fragment, null, nodes); } /* ---------------------------------------------------------------------------- * __oxRender — 종류별 JSX * -------------------------------------------------------------------------- */ function __oxRender(o) { if (o.kind === "alert") return __oxRenderAlert(o); if (o.kind === "confirm") return __oxRenderConfirm(o); if (o.kind === "toast") return __oxRenderToast(o); if (o.kind === "bottomsheet") return __oxRenderBottomSheet(o); return null; } // ── Alert ────────────────────────────────────────────────────────────── function __oxRenderAlert(o) { const p = o.props || {}; if (typeof window.AlertDialog !== "function") { if (window.console && console.warn) console.warn("[Overlay] AlertDialog 미로드 — openAlert 무시"); return null; } const AD = window.AlertDialog; const open = !o.closing; const titleNode = (p.title != null && typeof p.title === "string") ? React.createElement(AD.Title, null, p.title) : p.title; const descNode = (p.description != null && typeof p.description === "string") ? React.createElement(AD.Description, null, p.description) : p.description; const buttonLabel = p.buttonText || p.confirmLabel || "확인"; const buttonNode = React.createElement( AD.AlertButton, { onClick: function () { try { if (typeof p.onConfirm === "function") p.onConfirm(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] onConfirm 예외", e); } __oxClose(o.id, true); }, }, buttonLabel ); return React.createElement(AD, { key: "ox-alert-" + o.id, open: open, title: titleNode, description: descNode, alertButton: buttonNode, closeOnDimmerClick: p.closeOnDimmerClick !== false, closeOnBackEvent: p.closeOnBackEvent !== false, onClose: function () { __oxClose(o.id, false); }, onExited: function () { __oxRemove(o.id); }, }); } // ── Confirm ──────────────────────────────────────────────────────────── function __oxRenderConfirm(o) { const p = o.props || {}; if (typeof window.ConfirmDialog !== "function") { if (window.console && console.warn) console.warn("[Overlay] ConfirmDialog 미로드 — openConfirm 무시"); return null; } const CD = window.ConfirmDialog; const open = !o.closing; const titleNode = (p.title != null && typeof p.title === "string") ? React.createElement(CD.Title, null, p.title) : p.title; const descNode = (p.description != null && typeof p.description === "string") ? React.createElement(CD.Description, null, p.description) : p.description; const cancelLabel = p.cancelLabel || p.cancelText || "취소"; const confirmLabel = p.confirmLabel || p.confirmText || "확인"; const cancelNode = React.createElement( CD.CancelButton, { onClick: function () { try { if (typeof p.onCancel === "function") p.onCancel(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] onCancel 예외", e); } __oxClose(o.id, false); }, }, cancelLabel ); const confirmNode = React.createElement( CD.ConfirmButton, { onClick: function () { try { if (typeof p.onConfirm === "function") p.onConfirm(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] onConfirm 예외", e); } __oxClose(o.id, true); }, }, confirmLabel ); return React.createElement(CD, { key: "ox-confirm-" + o.id, open: open, title: titleNode, description: descNode, cancelButton: cancelNode, confirmButton: confirmNode, buttonOrder: p.buttonOrder || "cancel-first", forceVerticalButtons: !!p.forceVerticalButtons, closeOnDimmerClick: p.closeOnDimmerClick !== false, closeOnBackEvent: p.closeOnBackEvent !== false, onClose: function () { __oxClose(o.id, false); }, onExited: function () { __oxRemove(o.id); }, }); } // ── Toast ────────────────────────────────────────────────────────────── function __oxRenderToast(o) { const p = o.props || {}; if (typeof window.Toast !== "function") { if (window.console && console.warn) console.warn("[Overlay] Toast 미로드 — openToast 무시"); return null; } const T = window.Toast; const open = !o.closing; // icon / lottie 슬롯 구성 let leftAddon = null; if (p.icon && typeof T.Icon === "function") { if (typeof p.icon === "string") { leftAddon = React.createElement(T.Icon, { name: p.icon }); } else if (typeof p.icon === "object" && p.icon.name) { leftAddon = React.createElement(T.Icon, p.icon); } else { leftAddon = p.icon; // ReactNode 그대로 } } else if (p.lottie && typeof T.Lottie === "function") { if (typeof p.lottie === "string") { leftAddon = React.createElement(T.Lottie, { src: p.lottie }); } else if (typeof p.lottie === "object" && p.lottie.src) { leftAddon = React.createElement(T.Lottie, p.lottie); } else { leftAddon = p.lottie; } } else if (p.leftAddon) { leftAddon = p.leftAddon; } // button 슬롯 let buttonNode = null; if (p.button && typeof T.Button === "function") { if (typeof p.button === "object" && (p.button.label != null || p.button.children != null)) { const label = p.button.label != null ? p.button.label : p.button.children; const onBtnClick = p.button.onClick; buttonNode = React.createElement( T.Button, { onClick: function () { try { if (typeof onBtnClick === "function") onBtnClick(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] toast button onClick 예외", e); } __oxClose(o.id, true); }, }, label ); } else { buttonNode = p.button; } } return React.createElement(T, { key: "ox-toast-" + o.id, open: open, position: p.position || "bottom", text: p.text || "", leftAddon: leftAddon, button: buttonNode, duration: p.duration, higherThanCTA: !!p.higherThanCTA, "aria-live": p["aria-live"] || "polite", onClose: function () { __oxClose(o.id, false); }, onExited: function () { __oxRemove(o.id); }, }); } // ── BottomSheet ──────────────────────────────────────────────────────── function __oxRenderBottomSheet(o) { const p = o.props || {}; if (typeof window.BottomSheet !== "function") { if (window.console && console.warn) console.warn("[Overlay] BottomSheet 미로드 — open 무시"); return null; } const BS = window.BottomSheet; const open = !o.closing; // header / description 슬롯 let headerNode = p.header || null; if (headerNode == null && p.title != null) { headerNode = typeof BS.Header === "function" ? React.createElement(BS.Header, null, p.title) : p.title; } let descNode = p.headerDescription || null; if (descNode == null && p.description != null) { descNode = typeof BS.HeaderDescription === "function" ? React.createElement(BS.HeaderDescription, null, p.description) : p.description; } // cta 슬롯 — variant 에 따라 구성 let ctaNode = p.cta || null; const variant = p.__variant; // "one-button" | "two-button" | 없으면 children 만 if (!ctaNode && variant === "one-button" && typeof BS.CTA === "function") { ctaNode = React.createElement( BS.CTA, { onClick: function () { try { if (typeof p.onConfirm === "function") p.onConfirm(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] bottomsheet onConfirm 예외", e); } __oxClose(o.id, true); }, }, p.confirmLabel || p.buttonText || "확인" ); } else if (!ctaNode && variant === "two-button" && typeof BS.DoubleCTA === "function") { // leftButton / rightButton 은 TDS Button 을 기대 — window.Button 있으면 사용, 없으면 native const BtnFallback = typeof window.Button === "function" ? window.Button : null; const leftLabel = p.cancelLabel || p.cancelText || "취소"; const rightLabel = p.confirmLabel || p.confirmText || "확인"; const leftBtn = BtnFallback ? React.createElement(BtnFallback, { color: "secondary", variant: "fill", size: "xlarge", display: "block", onClick: function () { try { if (typeof p.onCancel === "function") p.onCancel(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] bottomsheet onCancel 예외", e); } __oxClose(o.id, false); }, }, leftLabel) : React.createElement("button", { type: "button", onClick: function () { try { if (typeof p.onCancel === "function") p.onCancel(); } catch (_) {} __oxClose(o.id, false); }, }, leftLabel); const rightBtn = BtnFallback ? React.createElement(BtnFallback, { color: "primary", variant: "fill", size: "xlarge", display: "block", onClick: function () { try { if (typeof p.onConfirm === "function") p.onConfirm(); } catch (e) { if (window.console && console.warn) console.warn("[Overlay] bottomsheet onConfirm 예외", e); } __oxClose(o.id, true); }, }, rightLabel) : React.createElement("button", { type: "button", onClick: function () { try { if (typeof p.onConfirm === "function") p.onConfirm(); } catch (_) {} __oxClose(o.id, true); }, }, rightLabel); ctaNode = React.createElement(BS.DoubleCTA, { leftButton: leftBtn, rightButton: rightBtn }); } return React.createElement( BS, { key: "ox-bs-" + o.id, open: open, onClose: function () { __oxClose(o.id, false); }, onExited: function () { __oxRemove(o.id); }, header: headerNode, headerDescription: descNode, cta: ctaNode, className: p.className, dimmerClassName: p.dimmerClassName, disableDimmer: !!p.disableDimmer, hasTextField: !!p.hasTextField, maxHeight: p.maxHeight, expandedMaxHeight: p.expandedMaxHeight, expandBottomSheet: !!p.expandBottomSheet, expandBottomSheetWhenScroll: !!p.expandBottomSheetWhenScroll, ctaContentGap: p.ctaContentGap, ariaLabelledBy: p.ariaLabelledBy, ariaDescribedBy: p.ariaDescribedBy, onEntered: p.onEntered, onExpanded: p.onExpanded, onDimmerClick: p.onDimmerClick, onHandlerTouchStart: p.onHandlerTouchStart, onHandlerTouchEnd: p.onHandlerTouchEnd, UNSAFE_disableFocusLock: !!p.UNSAFE_disableFocusLock, UNSAFE_ignoreDimmerClick: !!p.UNSAFE_ignoreDimmerClick, UNSAFE_ignoreBackEvent: !!p.UNSAFE_ignoreBackEvent, a11yIncludeHeaderInScroll: p.a11yIncludeHeaderInScroll !== false, disableChildrenDragging: !!p.disableChildrenDragging, }, p.children ); } /* ---------------------------------------------------------------------------- * OverlayRoot 마운트 — 모듈 최초 훅 호출 시 1회만 document.body 에 마운트 * -------------------------------------------------------------------------- */ let __overlayRootMounted = false; function __oxEnsureMount() { if (__overlayRootMounted) return; if (typeof document === "undefined") return; // 동일 id 로 여러 번 mount 되는 것 방지 let host = document.getElementById("tds-overlay-root"); if (!host) { host = document.createElement("div"); host.id = "tds-overlay-root"; // Portal 들이 각자 document.body 로 붙으므로 이 host 는 그냥 root 마운트 지점 document.body.appendChild(host); } try { if (typeof ReactDOM !== "undefined" && typeof ReactDOM.createRoot === "function") { const root = ReactDOM.createRoot(host); root.render(React.createElement(OverlayRoot)); } else if (typeof ReactDOM !== "undefined" && typeof ReactDOM.render === "function") { // React 17 이하 fallback ReactDOM.render(React.createElement(OverlayRoot), host); } else { if (window.console && console.warn) console.warn("[Overlay] ReactDOM 미로드 — OverlayRoot 마운트 실패"); return; } __overlayRootMounted = true; } catch (e) { if (window.console && console.warn) console.warn("[Overlay] OverlayRoot 마운트 실패", e); } } /* ---------------------------------------------------------------------------- * useDialog * -------------------------------------------------------------------------- */ function useDialog() { __oxEnsureMount(); const openAlert = __oxUseCallback(function (options) { const opts = options || {}; return __oxPush("alert", opts, null); }, []); const openConfirm = __oxUseCallback(function (options) { const opts = options || {}; return __oxPush("confirm", opts, null); }, []); const openAsyncConfirm = __oxUseCallback(function (options) { const opts = options || {}; return new Promise(function (resolve) { __oxPush("confirm", opts, resolve); }); }, []); return __oxUseMemo(function () { return { openAlert: openAlert, openConfirm: openConfirm, openAsyncConfirm: openAsyncConfirm, }; }, [openAlert, openConfirm, openAsyncConfirm]); } /* ---------------------------------------------------------------------------- * useToast * -------------------------------------------------------------------------- */ function useToast() { __oxEnsureMount(); const openToast = __oxUseCallback(function (options) { const opts = options || {}; // 기본 duration — 앱인토스에선 5000, 웹에선 3000 (button 있을 때 Toast 컴포넌트 자체에서 5000 사용) if (opts.duration == null) { let isInToss = false; try { if (typeof window.TossAdapter === "object" && typeof window.TossAdapter.isInToss === "function") { isInToss = !!window.TossAdapter.isInToss(); } } catch (_) {} // button 이 있으면 Toast 컴포넌트 내부에서 5000 으로 올림. 여기선 button 없을 때만 환경별 기본. if (!opts.button) { opts = Object.assign({}, opts, { duration: isInToss ? 5000 : 3000 }); } } return __oxPush("toast", opts, null); }, []); const closeToast = __oxUseCallback(function (id) { if (id != null) { __oxClose(id, false); return; } // id 생략 시 — 가장 최근 toast 만 닫음 const overlays = __overlayState.overlays; for (let i = overlays.length - 1; i >= 0; i -= 1) { if (overlays[i].kind === "toast" && !overlays[i].closing) { __oxClose(overlays[i].id, false); break; } } }, []); return __oxUseMemo(function () { return { openToast: openToast, closeToast: closeToast }; }, [openToast, closeToast]); } /* ---------------------------------------------------------------------------- * useBottomSheet * -------------------------------------------------------------------------- */ function useBottomSheet() { __oxEnsureMount(); const open = __oxUseCallback(function (options) { const opts = options || {}; return __oxPush("bottomsheet", opts, null); }, []); const close = __oxUseCallback(function (id) { if (id != null) { __oxClose(id, false); return; } const overlays = __overlayState.overlays; for (let i = overlays.length - 1; i >= 0; i -= 1) { if (overlays[i].kind === "bottomsheet" && !overlays[i].closing) { __oxClose(overlays[i].id, false); break; } } }, []); const openOneButtonSheet = __oxUseCallback(function (options) { const opts = Object.assign({}, options || {}, { __variant: "one-button" }); return __oxPush("bottomsheet", opts, null); }, []); const openTwoButtonSheet = __oxUseCallback(function (options) { const opts = Object.assign({}, options || {}, { __variant: "two-button" }); return __oxPush("bottomsheet", opts, null); }, []); const openAsyncTwoButtonSheet = __oxUseCallback(function (options) { const opts = Object.assign({}, options || {}, { __variant: "two-button" }); return new Promise(function (resolve) { __oxPush("bottomsheet", opts, resolve); }); }, []); return __oxUseMemo(function () { return { open: open, close: close, openOneButtonSheet: openOneButtonSheet, openTwoButtonSheet: openTwoButtonSheet, openAsyncTwoButtonSheet: openAsyncTwoButtonSheet, }; }, [open, close, openOneButtonSheet, openTwoButtonSheet, openAsyncTwoButtonSheet]); } /* ---------------------------------------------------------------------------- * window 등록 * -------------------------------------------------------------------------- */ window.useDialog = useDialog; window.useToast = useToast; window.useBottomSheet = useBottomSheet; window.OverlayRoot = OverlayRoot; // 디버그/테스트용 노출 window.__tdsOverlay = { state: __overlayState, push: __oxPush, close: __oxClose, remove: __oxRemove, ensureMount: __oxEnsureMount, }; Object.assign(window, { useDialog, useToast, useBottomSheet, OverlayRoot });