/* ============================================================================ * 서명전에 — TDS AlertDialog 컴포넌트 (Task #161) * * · TDS Mobile 2026-03 AlertDialog 스펙. 중요한 정보를 전달하고 단일 확인 * 버튼으로 닫을 수 있는 다이얼로그. 작업 완료 알림 · 상태 변경 알림 · 피드백. * * · 구성: * AlertDialog.Title — 제목 (기본

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

+ t6 + medium + grey-600) * AlertDialog.AlertButton — 단일 확인 버튼 (기본 medium + blue-500 + bold) * * · props (root): * open* boolean * title React.ReactNode — 권장 * description React.ReactNode — 없어도 됨 * alertButton React.ReactNode — 권장 * closeOnDimmerClick boolean (기본 true) — false 면 딤 클릭 시 Wiggle * closeOnBackEvent boolean (기본 true) — 뒤로가기 시 닫힘 (popstate) * onClose () => void * onEntered () => void — 등장 애니메이션 완료 후 * onExited () => void — 퇴장 애니메이션 완료 후 * portalContainer HTMLElement (기본 document.body) * * · Modal(#137) 과 역할 구분: * 자유 레이아웃 다이얼로그 = Modal / 제목+설명+1버튼 표준 = AlertDialog. * ConfirmDialog(#162) 와 구분: 단일 CTA = AlertDialog / 취소+확인 = ConfirmDialog. * * · Wiggle: closeOnDimmerClick=false 로 딤 클릭 시 `.tds-alertdialog--wiggle` * 클래스를 400ms 부여 → CSS keyframe 좌우 흔들림. 닫히지 않음. * * · 모든 색/폰트/간격은 var(--tds-…) 토큰. 하드코딩 없음. * · prefers-reduced-motion 시 모든 transition/애니메이션 OFF (fade 만 120ms). * · aria-modal="true" + role="alertdialog" + aria-labelledby(Title)/aria-describedby(Description) 자동. * ========================================================================== */ // ─── Title ─────────────────────────────────────────────────────────── function AlertDialogTitle({ as = "h3", color, typography = "t4", fontWeight = "bold", className = "", style, children, ...rest }) { const Tag = as || "h3"; const cls = [ "tds-alertdialog__title", "tds-alertdialog__title--" + typography, "tds-alertdialog__title--w-" + fontWeight, className, ] .filter(Boolean) .join(" "); const mergedStyle = Object.assign({}, style || {}, color ? { color: color } : {}); return ( {children} ); } // ─── Description ───────────────────────────────────────────────────── function AlertDialogDescription({ as = "h3", color, typography = "t6", fontWeight = "medium", className = "", style, children, ...rest }) { // 스펙은 as 기본이 "h3" — 시멘틱으로는 어색하지만 스펙 준수. 호출자가

로 오버라이드 권장. const Tag = as || "h3"; const cls = [ "tds-alertdialog__description", "tds-alertdialog__description--" + typography, "tds-alertdialog__description--w-" + fontWeight, className, ] .filter(Boolean) .join(" "); const mergedStyle = Object.assign({}, style || {}, color ? { color: color } : {}); return ( {children} ); } // ─── AlertButton ───────────────────────────────────────────────────── // TextButton(#153) 위임. 미로드 시 native ); } // ─── 루트 AlertDialog ─────────────────────────────────────────────── function AlertDialog({ open, title, description, alertButton, 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 exitTimerRef = React.useRef(null); const wiggleTimerRef = React.useRef(null); const contentRef = React.useRef(null); const enteredCalledRef = React.useRef(false); // 고유 id — aria-labelledby / aria-describedby 자동 페어링 const uidRef = React.useRef(null); if (uidRef.current == null) { uidRef.current = "tds-alertdialog-" + 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-alertdialog-open"); return function () { root.classList.remove("tds-alertdialog-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; // 열림 직후 history state 를 하나 push → popstate 시 우리 것을 pop 한 것으로 간주 const marker = { __tds_alertdialog: 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); // 우리가 push 한 state 가 아직 top 이면 pop — 중복 뒤로가기 방지 try { if (window.history.state && window.history.state.__tds_alertdialog === 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; } // Wiggle 트리거 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; } }; }, []); if (!isMounted || !container) return null; const rootCls = [ "tds-alertdialog-portal", "tds-alertdialog", isEntered && "tds-alertdialog--entered", wiggle > 0 && "tds-alertdialog--wiggle", className, ] .filter(Boolean) .join(" "); // aria-labelledby / aria-describedby: 제공된 경우에만 id 부여 const titleId = title ? uid + "-title" : undefined; const descId = description ? uid + "-desc" : undefined; // title / description 에 id 주입 — 이미 id 가 있으면 유지. 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; return ReactDOM.createPortal(

{titleNode ?
{titleNode}
: null} {descNode ?
{descNode}
: null} {alertButton ?
{alertButton}
: null}
, container ); } // 서브 정적 부착 AlertDialog.Title = AlertDialogTitle; AlertDialog.Description = AlertDialogDescription; AlertDialog.AlertButton = AlertDialogAlertButton; window.AlertDialog = AlertDialog; window.AlertDialogTitle = AlertDialogTitle; window.AlertDialogDescription = AlertDialogDescription; window.AlertDialogAlertButton = AlertDialogAlertButton; Object.assign(window, { AlertDialog, AlertDialogTitle, AlertDialogDescription, AlertDialogAlertButton, });