/* ============================================================================
* 서명전에 — 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 폴백.
function _confirmButtonBase(extraCls, fallbackColor, defaults, props) {
const {
size,
color,
fontWeight,
variant,
className = "",
style,
children,
onClick,
...rest
} = props;
const _size = size || defaults.size;
const _color = color || defaults.color;
const _fw = fontWeight || defaults.fontWeight;
const _variant = variant || defaults.variant;
const cls = ["tds-confirmdialog__btn", extraCls, className].filter(Boolean).join(" ");
if (typeof window !== "undefined" && typeof window.TextButton === "function") {
return React.createElement(
window.TextButton,
Object.assign(
{
size: _size,
variant: _variant,
className: cls,
style: style,
onClick: onClick,
color: _color,
fontWeight: _fw,
},
rest
),
children
);
}
// 폴백 — TextButton 미로드 환경
const fallbackCls = [cls, "tds-confirmdialog__btn--fallback"].filter(Boolean).join(" ");
const mergedStyle = Object.assign(
{
color: _color || fallbackColor,
fontWeight:
_fw === "regular" ? 400
: _fw === "medium" ? 500
: _fw === "semibold" ? 600
: 700,
},
style || {}
);
return (
{children}
);
}
// ─── 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,
});