/* ============================================================================
* 서명전에 — TDS Tooltip 컴포넌트
* · 트리거(아이콘/버튼 등) 위 또는 아래에 떠올라 짧은 보조 정보를 보여줘요.
* Toast(#154) 와 다른 점: 트리거에 종속(트리거를 가리키는 화살표)이고,
* 호버/포커스/클릭 같은 사용자 의도가 있어야 열려요.
*
* · 단일 컴포넌트 — children 으로 트리거 1개를 감싸요. React.cloneElement 로
* 이벤트(onMouseEnter/Leave/Focus/Blur)와 ref 를 트리거에 합성해 부착해요.
*
* · 라이프사이클 (controlled / uncontrolled 둘 다):
* open 명시 → controlled (외부에서 관리, onOpenChange 로 신호)
* open 누락 → uncontrolled (defaultOpen=false / openOnHover / openOnFocus
* / dismissible 로 내부에서 관리)
* mount → 위치 계산(트리거 rect + anchorPositionByRatio + offset) →
* autoFlip 시 viewport 검사 후 placement 뒤집기 → 16ms 후 visible →
* 퇴장 시 visible 해제 → 모션 끝나면 unmount.
*
* · 시각:
* 배경 grey-900 / 흰 텍스트 / 8px 라운드 / shadow-elevation-medium.
* messageAlign 은 메시지 텍스트 정렬 (style.width 가 명시돼야 의미 있음).
* 화살표는 tooltip 과 같은 색 (currentColor 상속) 인라인 SVG.
* anchorPositionByRatio 는 화살표의 tooltip 내 가로 위치 비율 (0~1).
* 0.5 → 가운데. 0.1 → 좌측. 0.9 → 우측.
* clipToEnd: none / left(왼쪽 절반만 남김) / right(오른쪽 절반만 남김).
*
* · size → padding / font / arrow:
* small : 6px 8px / 12/16 / 5×4
* medium: 8px 10px / 13/18 / 6×5
* large : 10px 12px/ 14/20 / 7×6
* default offset = arrow.height + 2px (size 별 자동).
*
* · 위치 계산 — Portal(body) 마운트 + 트리거 getBoundingClientRect:
* strategy="absolute": top/left = rect + window.scrollX/Y
* strategy="fixed": top/left = rect 그대로 (viewport 좌표)
* anchor X = rect.left + rect.width/2 (트리거 가로 중앙)
* tooltip.left = anchor X - tooltip.width * anchorPositionByRatio
* placement=bottom: tooltip.top = rect.bottom + offset
* placement=top : tooltip.top = rect.top - tooltip.height - offset
* autoFlip: bottom 인데 아래로 넘치면 top, top 인데 위로 넘치면 bottom.
* 좌우 viewport 8px 마진 안으로 clamp.
*
* · 접근성:
* tooltip 본체에 role="tooltip" + id 부여 → 트리거에 aria-describedby 자동.
* openOnFocus 일 때 키보드 포커스로 열림 (포커스 사용자 접근 가능).
* Escape 닫기 (dismissible).
* visible/leaving 클래스만 토글 — 트리거 콘텐츠는 건드리지 않음.
*
* · 모션:
* motionVariant=weak : opacity 120ms + translateY 4px
* motionVariant=strong: opacity 200ms + translateY 8px + scale(0.96→1)
* prefers-reduced-motion 시 transform 없이 fade 만 80ms.
* ========================================================================== */
const {
useState: __ttUseState,
useEffect: __ttUseEffect,
useRef: __ttUseRef,
useMemo: __ttUseMemo,
useCallback: __ttUseCallback,
useId: __ttUseIdMaybe,
} = React;
const __TOOLTIP_SIZES = ["small", "medium", "large"];
const __TOOLTIP_PLACEMENTS = ["top", "bottom"];
const __TOOLTIP_ALIGNS = ["left", "center", "right"];
const __TOOLTIP_VARIANTS = ["weak", "strong"];
const __TOOLTIP_CLIPS = ["none", "left", "right"];
const __TOOLTIP_STRATEGIES = ["absolute", "fixed"];
// size 별 화살표 / 기본 offset
const __TOOLTIP_ARROW = {
small: { w: 10, h: 5, defaultOffset: 7 },
medium: { w: 12, h: 6, defaultOffset: 8 },
large: { w: 14, h: 7, defaultOffset: 10 },
};
// useId 폴백 (React 17 환경 대비)
let __tooltipSeq = 0;
function __ttGenId() {
__tooltipSeq += 1;
return `tds-tooltip-${__tooltipSeq}`;
}
function Tooltip({
// 컨텐츠
message,
messageAlign = "left",
children,
// 시각
size = "medium",
placement = "bottom",
motionVariant = "weak",
clipToEnd = "none",
// 상태
open,
defaultOpen = false,
onOpenChange,
openOnHover = false,
openOnFocus = false,
dismissible = false,
// 위치
offset,
anchorPositionByRatio = 0.5,
autoFlip = false,
strategy = "absolute",
// 외부 스타일 (메시지 박스에 붙음)
className = "",
style,
id,
// 접근성
"aria-label": ariaLabel,
...rest
}) {
// ----- props 검증 -----
if (!__TOOLTIP_SIZES.includes(size)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] size 는 ${__TOOLTIP_SIZES.join(" | ")} 만. 받은 값: ${size}. "medium" 폴백.`);
}
size = "medium";
}
if (!__TOOLTIP_PLACEMENTS.includes(placement)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] placement 는 "top" | "bottom" 만. 받은 값: ${placement}. "bottom" 폴백.`);
}
placement = "bottom";
}
if (!__TOOLTIP_ALIGNS.includes(messageAlign)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] messageAlign 은 "left" | "center" | "right" 만. 받은 값: ${messageAlign}. "left" 폴백.`);
}
messageAlign = "left";
}
if (!__TOOLTIP_VARIANTS.includes(motionVariant)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] motionVariant 는 "weak" | "strong" 만. 받은 값: ${motionVariant}. "weak" 폴백.`);
}
motionVariant = "weak";
}
if (!__TOOLTIP_CLIPS.includes(clipToEnd)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] clipToEnd 는 "none" | "left" | "right" 만. 받은 값: ${clipToEnd}. "none" 폴백.`);
}
clipToEnd = "none";
}
if (!__TOOLTIP_STRATEGIES.includes(strategy)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] strategy 는 "absolute" | "fixed" 만. 받은 값: ${strategy}. "absolute" 폴백.`);
}
strategy = "absolute";
}
if (typeof anchorPositionByRatio !== "number" || !isFinite(anchorPositionByRatio)) {
if (window.console && console.warn) {
console.warn(`[Tooltip] anchorPositionByRatio 는 number(0~1) 여야 해요. 받은 값: ${anchorPositionByRatio}. 0.5 폴백.`);
}
anchorPositionByRatio = 0.5;
} else if (anchorPositionByRatio < 0 || anchorPositionByRatio > 1) {
if (window.console && console.warn) {
console.warn(`[Tooltip] anchorPositionByRatio 는 [0, 1] 범위. 받은 값: ${anchorPositionByRatio}. 클램프함.`);
}
anchorPositionByRatio = Math.max(0, Math.min(1, anchorPositionByRatio));
}
if (open !== undefined && open !== null && typeof open !== "boolean") {
if (window.console && console.warn) {
console.warn(`[Tooltip] open 은 boolean 이어야 해요. 받은 값: ${open}. 무시.`);
}
open = undefined;
}
if (children == null || (Array.isArray(children) && children.length === 0)) {
if (window.console && console.warn) {
console.warn("[Tooltip] children(트리거)이 필요해요.");
}
}
const isControlled = typeof open === "boolean";
// ----- state -----
const [innerOpen, setInnerOpen] = __ttUseState(!!defaultOpen);
const effectiveOpen = isControlled ? open : innerOpen;
// 위치 / 크기
const [pos, setPos] = __ttUseState({ top: 0, left: 0, arrowLeft: 0, flipped: false });
const [mounted, setMounted] = __ttUseState(effectiveOpen);
const [visible, setVisible] = __ttUseState(false);
// refs
const triggerRef = __ttUseRef(null);
const tooltipRef = __ttUseRef(null);
const exitTimerRef = __ttUseRef(null);
const enterRafRef = __ttUseRef(null);
const repositionRafRef = __ttUseRef(null);
// id (aria-describedby 용)
const idAuto = __ttUseMemo(function () {
return id || __ttGenId();
}, [id]);
// ----- open 변경 dispatch -----
const requestSetOpen = __ttUseCallback(function (next) {
if (typeof onOpenChange === "function") onOpenChange(next);
if (!isControlled) setInnerOpen(next);
}, [isControlled, onOpenChange]);
// ----- 위치 계산 -----
const computePosition = __ttUseCallback(function () {
const trig = triggerRef.current;
const tip = tooltipRef.current;
if (!trig || !tip) return;
const rect = trig.getBoundingClientRect();
const tipRect = tip.getBoundingClientRect();
const arrowMeta = __TOOLTIP_ARROW[size] || __TOOLTIP_ARROW.medium;
const effOffset = (typeof offset === "number" && isFinite(offset)) ? offset : arrowMeta.defaultOffset;
// 트리거 가로 중심
const anchorX = rect.left + rect.width / 2;
// tooltip 가로 위치 — anchorPositionByRatio 만큼 왼쪽에서 안으로
let left = anchorX - tipRect.width * anchorPositionByRatio;
// viewport 좌우 8px 마진 clamp
const vw = window.innerWidth;
const minLeft = 8;
const maxLeft = vw - tipRect.width - 8;
if (maxLeft >= minLeft) left = Math.max(minLeft, Math.min(maxLeft, left));
// 화살표 위치 (tooltip 좌측 기준 px) — anchorX 와 left 차이로 재계산
let arrowLeft = anchorX - left;
arrowLeft = Math.max(arrowMeta.w / 2 + 4, Math.min(tipRect.width - arrowMeta.w / 2 - 4, arrowLeft));
// 세로 위치 + autoFlip
let actualPlacement = placement;
let top;
if (placement === "bottom") {
top = rect.bottom + effOffset;
if (autoFlip && top + tipRect.height > window.innerHeight - 8) {
const altTop = rect.top - tipRect.height - effOffset;
if (altTop >= 8) {
actualPlacement = "top";
top = altTop;
}
}
} else {
top = rect.top - tipRect.height - effOffset;
if (autoFlip && top < 8) {
const altTop = rect.bottom + effOffset;
if (altTop + tipRect.height <= window.innerHeight - 8) {
actualPlacement = "bottom";
top = altTop;
}
}
}
// strategy=absolute → window.scrollX/Y 더하기
if (strategy === "absolute") {
top += window.scrollY || window.pageYOffset || 0;
left += window.scrollX || window.pageXOffset || 0;
}
setPos({
top: Math.round(top),
left: Math.round(left),
arrowLeft: Math.round(arrowLeft),
flipped: actualPlacement !== placement,
actualPlacement: actualPlacement,
});
}, [size, offset, anchorPositionByRatio, placement, autoFlip, strategy]);
// ----- 마운트 / 가시성 토글 -----
__ttUseEffect(function () {
if (effectiveOpen) {
setMounted(true);
// 다음 frame 에 위치 계산 + visible
const id1 = requestAnimationFrame(function () {
enterRafRef.current = requestAnimationFrame(function () {
computePosition();
setVisible(true);
});
});
return function () {
cancelAnimationFrame(id1);
if (enterRafRef.current) cancelAnimationFrame(enterRafRef.current);
};
} else {
setVisible(false);
}
}, [effectiveOpen, computePosition]);
// 퇴장 후 unmount
__ttUseEffect(function () {
if (mounted && !effectiveOpen && !visible) {
const exitMs = motionVariant === "strong" ? 200 : 120;
exitTimerRef.current = setTimeout(function () {
setMounted(false);
}, exitMs);
return function () {
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
};
}
}, [mounted, effectiveOpen, visible, motionVariant]);
// ----- 위치 재계산 (스크롤 / resize) -----
__ttUseEffect(function () {
if (!visible) return;
const onScrollOrResize = function () {
if (repositionRafRef.current) cancelAnimationFrame(repositionRafRef.current);
repositionRafRef.current = requestAnimationFrame(computePosition);
};
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return function () {
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
if (repositionRafRef.current) cancelAnimationFrame(repositionRafRef.current);
};
}, [visible, computePosition]);
// ----- dismissible: outside click + ESC -----
__ttUseEffect(function () {
if (!visible) return;
if (!dismissible) return;
const onDocClick = function (e) {
const t = e.target;
if (tooltipRef.current && tooltipRef.current.contains(t)) return;
if (triggerRef.current && triggerRef.current.contains(t)) return;
requestSetOpen(false);
};
const onKey = function (e) {
if (e.key === "Escape") {
requestSetOpen(false);
}
};
document.addEventListener("mousedown", onDocClick, true);
document.addEventListener("touchstart", onDocClick, true);
document.addEventListener("keydown", onKey);
return function () {
document.removeEventListener("mousedown", onDocClick, true);
document.removeEventListener("touchstart", onDocClick, true);
document.removeEventListener("keydown", onKey);
};
}, [visible, dismissible, requestSetOpen]);
// ----- 트리거 prop 합성 (cloneElement) -----
const childArr = React.Children.toArray(children).filter(Boolean);
const child = childArr[0];
if (!React.isValidElement(child)) {
// children 이 valid element 가 아니면 그냥 children 만 반환
return children == null ? null :