/* ============================================================================ * 서명전에 — TDS ConfirmDialog 컴포넌트 (Task #162) * * · TDS Mobile 2026-03 ConfirmDialog 스펙. 사용자의 액션이나 선택이 필요한 * 상황에서 취소/확인 두 개의 버튼을 통해 명확한 선택을 돕는 다이얼로그. * * · 구성: * ConfirmDialog.Title — 제목 (기본

+ t4 + bold + grey-800) * ConfirmDialog.Description — 설명 (기본

+ t6 + medium + grey-600) * ConfirmDialog.CancelButton — 취소 버튼 (보조) * ConfirmDialog.ConfirmButton — 확인 버튼 (주요) * * · props (root): * open* boolean * title React.ReactNode — 권장 * description React.ReactNode — 없어도 됨 * cancelButton* React.ReactNode — 권장 * confirmButton* React.ReactNode — 권장 * buttonOrder "cancel-first" | "confirm-first" (기본 "cancel-first") * — iOS/Android 표준은 cancel-first(좌) 순서. * forceVerticalButtons boolean (기본 false) — 버튼 길이와 무관하게 세로 강제 * closeOnDimmerClick boolean (기본 true) — false 면 딤 클릭 시 Wiggle * closeOnBackEvent boolean (기본 true) — 뒤로가기 시 닫힘 (popstate) * onClose () => void — 딤/뒤로가기/ESC/취소 모두에서 호출 (CTA 버튼은 호출자가 onClose 추가 호출) * onEntered () => void * onExited () => void * portalContainer HTMLElement (기본 document.body) * * · 자동 레이아웃 — 기본은 가로 1:1 배치, useLayoutEffect 로 버튼 텍스트가 줄바꿈 * (offsetHeight > singleLineHeight * 1.5) 되었는지 측정해 세로로 전환. * forceVerticalButtons=true 면 측정 생략하고 즉시 세로. * * · AlertDialog(#161) 와 같은 portal/wiggle/popstate 인프라. z-index 1960/2060 * (AlertDialog 1950/2050 위에 살짝 — 단순 안내가 떠 있는 상태에서도 결정 다이얼로그를 위로). * * · Modal(#137) 과 역할 구분: * 자유 레이아웃 다이얼로그 = Modal / 제목+설명+1버튼 = AlertDialog / 취소+확인 = ConfirmDialog. * * · 모든 색/폰트/간격은 var(--tds-…) 토큰. 하드코딩 없음. * · prefers-reduced-motion 시 모든 transition/애니메이션 OFF. * · aria-modal="true" + role="alertdialog" + aria-labelledby/aria-describedby 자동. * ========================================================================== */ // ─── Title ─────────────────────────────────────────────────────────── function ConfirmDialogTitle({ as = "h3", color, typography = "t4", fontWeight = "bold", className = "", style, children, ...rest }) { const Tag = as || "h3"; const cls = [ "tds-confirmdialog__title", "tds-confirmdialog__title--" + typography, "tds-confirmdialog__title--w-" + fontWeight, className, ] .filter(Boolean) .join(" "); const mergedStyle = Object.assign({}, style || {}, color ? { color: color } : {}); return ( {children} ); } // ─── Description ───────────────────────────────────────────────────── function ConfirmDialogDescription({ as = "h3", color, typography = "t6", fontWeight = "medium", className = "", style, children, ...rest }) { const Tag = as || "h3"; const cls = [ "tds-confirmdialog__description", "tds-confirmdialog__description--" + typography, "tds-confirmdialog__description--w-" + fontWeight, className, ] .filter(Boolean) .join(" "); const mergedStyle = Object.assign({}, style || {}, color ? { color: color } : {}); return ( {children} ); } // ─── 공통 버튼 베이스 ──────────────────────────────────────────────── // TextButton(#153) 위임 + native ); } // ─── CancelButton ──────────────────────────────────────────────────── function ConfirmDialogCancelButton(props) { return _confirmButtonBase( "tds-confirmdialog__btn--cancel", "var(--tds-grey-600)", { size: "medium", color: undefined, fontWeight: "medium", variant: "clear" }, props ); } // ─── ConfirmButton ─────────────────────────────────────────────────── function ConfirmDialogConfirmButton(props) { return _confirmButtonBase( "tds-confirmdialog__btn--confirm", "var(--tds-blue-500)", { size: "medium", color: undefined, fontWeight: "bold", variant: "clear" }, props ); } // ─── 루트 ConfirmDialog ────────────────────────────────────────────── function ConfirmDialog({ open, title, description, cancelButton, confirmButton, buttonOrder = "cancel-first", forceVerticalButtons = false, closeOnDimmerClick = true, closeOnBackEvent = true, onClose, onEntered, onExited, portalContainer, className = "", ...rest }) { const [isMounted, setIsMounted] = React.useState(!!open); const [isEntered, setIsEntered] = React.useState(false); const [wiggle, setWiggle] = React.useState(0); const [vertical, setVertical] = React.useState(!!forceVerticalButtons); const exitTimerRef = React.useRef(null); const wiggleTimerRef = React.useRef(null); const contentRef = React.useRef(null); const buttonRowRef = React.useRef(null); const enteredCalledRef = React.useRef(false); // 고유 id — aria 페어링 const uidRef = React.useRef(null); if (uidRef.current == null) { uidRef.current = "tds-confirmdialog-" + Math.random().toString(36).slice(2, 10); } const uid = uidRef.current; const container = portalContainer && typeof portalContainer.appendChild === "function" ? portalContainer : typeof document !== "undefined" ? document.body : null; // ── open 변화: mount + enter, unmount + exit ── React.useEffect(function () { if (open) { if (exitTimerRef.current) { window.clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } setIsMounted(true); enteredCalledRef.current = false; let raf2 = 0; const raf1 = window.requestAnimationFrame(function () { raf2 = window.requestAnimationFrame(function () { setIsEntered(true); }); }); return function () { window.cancelAnimationFrame(raf1); if (raf2) window.cancelAnimationFrame(raf2); }; } if (!isMounted) return undefined; setIsEntered(false); if (exitTimerRef.current) window.clearTimeout(exitTimerRef.current); exitTimerRef.current = window.setTimeout(function () { setIsMounted(false); if (typeof onExited === "function") { try { onExited(); } catch (_) {} } exitTimerRef.current = null; }, 300); return function () { 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-confirmdialog-open"); return function () { root.classList.remove("tds-confirmdialog-open"); }; }, [isMounted]); // ── ESC ── React.useEffect(function () { if (!isMounted) return undefined; const onKey = function (e) { if (e.key === "Escape" || e.key === "Esc") { e.stopPropagation(); if (typeof onClose === "function") onClose(); } }; document.addEventListener("keydown", onKey); return function () { document.removeEventListener("keydown", onKey); }; }, [isMounted, onClose]); // ── popstate (뒤로가기) ── React.useEffect(function () { if (!isMounted || !closeOnBackEvent) return undefined; const marker = { __tds_confirmdialog: uid }; try { window.history.pushState(marker, ""); } catch (_) {} const onPop = function () { if (typeof onClose === "function") onClose(); }; window.addEventListener("popstate", onPop); return function () { window.removeEventListener("popstate", onPop); try { if (window.history.state && window.history.state.__tds_confirmdialog === uid) { window.history.back(); } } catch (_) {} }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMounted, closeOnBackEvent, uid]); // ── transitionend → onEntered / onExited ── const onTransitionEnd = function (e) { if (!e || !e.propertyName) return; if (e.target !== contentRef.current) return; if (e.propertyName !== "opacity" && e.propertyName !== "transform") return; if (isEntered && !enteredCalledRef.current) { enteredCalledRef.current = true; if (typeof onEntered === "function") { try { onEntered(); } catch (_) {} } return; } if (!isEntered) { if (exitTimerRef.current) { window.clearTimeout(exitTimerRef.current); exitTimerRef.current = null; } setIsMounted(false); if (typeof onExited === "function") { try { onExited(); } catch (_) {} } } }; // ── 딤(overlay) 클릭 ── const onOverlayClick = function () { if (closeOnDimmerClick) { if (typeof onClose === "function") onClose(); return; } if (wiggleTimerRef.current) window.clearTimeout(wiggleTimerRef.current); setWiggle(function (n) { return n + 1; }); wiggleTimerRef.current = window.setTimeout(function () { setWiggle(0); wiggleTimerRef.current = null; }, 420); }; React.useEffect(function () { return function () { if (wiggleTimerRef.current) { window.clearTimeout(wiggleTimerRef.current); wiggleTimerRef.current = null; } }; }, []); // ── 자동 레이아웃 측정 — 가로 → 세로 전환 ── // 가로 모드일 때 버튼 중 하나라도 두 줄(또는 원래 한 줄 높이의 1.5배) 이상으로 // 줄바꿈됐다면 세로로 전환. forceVerticalButtons 면 즉시 세로. React.useLayoutEffect(function () { if (!isMounted) return; if (forceVerticalButtons) { if (!vertical) setVertical(true); return; } // 가로 모드에서만 측정. 일단 가로를 한 번 그려본 뒤 측정 → 세로 전환. if (vertical) return; const row = buttonRowRef.current; if (!row) return; const buttons = row.querySelectorAll(".tds-confirmdialog__btn"); if (!buttons || buttons.length === 0) return; let needsVertical = false; for (let i = 0; i < buttons.length; i += 1) { const b = buttons[i]; // 한 줄 기준 line-height (computed style) — 폴백 22 (sub-ty-5 lh) const cs = window.getComputedStyle(b); let lh = parseFloat(cs.lineHeight); if (!isFinite(lh) || lh <= 0) lh = 22; // offsetHeight 가 line-height 의 1.5배 이상이면 줄바꿈된 것으로 간주. // 패딩 등 다른 영향 보정용 임계는 살짝 보수적으로(1.45). if (b.offsetHeight > lh * 1.45) { needsVertical = true; break; } // 가로 폭이 컨테이너의 1/2 - gap 보다 큰 경우(scrollWidth > clientWidth + 1)도 // overflow → 세로 권장. if (b.scrollWidth > b.clientWidth + 1) { needsVertical = true; break; } } if (needsVertical) setVertical(true); // open/forceVerticalButtons/wiggle/buttons 변화 시 재측정 // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMounted, forceVerticalButtons, vertical, cancelButton, confirmButton, wiggle]); // open 이 false 로 닫힐 때 vertical 초기화 (다음 열림에서 다시 측정) React.useEffect(function () { if (!open) { setVertical(!!forceVerticalButtons); } }, [open, forceVerticalButtons]); if (!isMounted || !container) return null; const rootCls = [ "tds-confirmdialog-portal", "tds-confirmdialog", isEntered && "tds-confirmdialog--entered", wiggle > 0 && "tds-confirmdialog--wiggle", className, ] .filter(Boolean) .join(" "); // aria-labelledby / aria-describedby const titleId = title ? uid + "-title" : undefined; const descId = description ? uid + "-desc" : undefined; const titleNode = title && React.isValidElement(title) ? React.cloneElement(title, Object.assign({}, { id: title.props && title.props.id ? title.props.id : titleId })) : title; const descNode = description && React.isValidElement(description) ? React.cloneElement(description, Object.assign({}, { id: description.props && description.props.id ? description.props.id : descId })) : description; // 버튼 정렬 — 기본 "cancel-first" (좌측 cancel, 우측 confirm) const left = buttonOrder === "confirm-first" ? confirmButton : cancelButton; const right = buttonOrder === "confirm-first" ? cancelButton : confirmButton; const buttonRowCls = [ "tds-confirmdialog__button-row", vertical ? "tds-confirmdialog__button-row--vertical" : "tds-confirmdialog__button-row--horizontal", ].join(" "); return ReactDOM.createPortal(
{titleNode ?
{titleNode}
: null} {descNode ?
{descNode}
: null} {(cancelButton || confirmButton) ? (
{left ?
{left}
: null} {right ?
{right}
: null}
) : null}
, container ); } // 서브 정적 부착 ConfirmDialog.Title = ConfirmDialogTitle; ConfirmDialog.Description = ConfirmDialogDescription; ConfirmDialog.CancelButton = ConfirmDialogCancelButton; ConfirmDialog.ConfirmButton = ConfirmDialogConfirmButton; window.ConfirmDialog = ConfirmDialog; window.ConfirmDialogTitle = ConfirmDialogTitle; window.ConfirmDialogDescription = ConfirmDialogDescription; window.ConfirmDialogCancelButton = ConfirmDialogCancelButton; window.ConfirmDialogConfirmButton = ConfirmDialogConfirmButton; Object.assign(window, { ConfirmDialog, ConfirmDialogTitle, ConfirmDialogDescription, ConfirmDialogCancelButton, ConfirmDialogConfirmButton, });