권장
* 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 폴백.
function AlertDialogAlertButton({
size = "medium",
color,
fontWeight = "bold",
variant,
className = "",
style,
children,
onClick,
...rest
}) {
const cls = ["tds-alertdialog__alert-button", 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,
// TextButton 은 color/fontWeight 를 typography/style 오버라이드 프로퍼티로 받음
color: color,
fontWeight: fontWeight,
},
rest
),
children
);
}
// 폴백 — TextButton 미로드 환경
const fallbackCls = [cls, "tds-alertdialog__alert-button--fallback"].filter(Boolean).join(" ");
const mergedStyle = Object.assign(
{
color: color || "var(--tds-blue-500)",
fontWeight: fontWeight === "regular" ? 400 : fontWeight === "medium" ? 500 : fontWeight === "semibold" ? 600 : 700,
},
style || {}
);
return (
{children}
);
}
// ─── 루트 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,
});