/* ============================================================================ * 서명전에 — 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 : {children}; } const childRef = child.ref; const setTriggerRef = function (node) { triggerRef.current = node; if (typeof childRef === "function") childRef(node); else if (childRef && typeof childRef === "object") childRef.current = node; }; // 기존 핸들러 체이닝 function chain(orig, ours) { return function (e) { if (typeof orig === "function") orig(e); ours(e); }; } const triggerExtra = { ref: setTriggerRef, "aria-describedby": mounted ? idAuto : (child.props && child.props["aria-describedby"]) || undefined, }; if (openOnHover) { triggerExtra.onMouseEnter = chain(child.props.onMouseEnter, function () { requestSetOpen(true); }); triggerExtra.onMouseLeave = chain(child.props.onMouseLeave, function () { // dismissible 이면 외부 클릭/ESC 만으로 닫게 두기 (마우스 떠나도 유지) if (dismissible) return; requestSetOpen(false); }); } if (openOnFocus) { triggerExtra.onFocus = chain(child.props.onFocus, function () { requestSetOpen(true); }); triggerExtra.onBlur = chain(child.props.onBlur, function () { if (dismissible) return; requestSetOpen(false); }); } const triggerCloned = React.cloneElement(child, triggerExtra); // ----- 메시지 박스 렌더 ----- const arrowMeta = __TOOLTIP_ARROW[size] || __TOOLTIP_ARROW.medium; const effPlacement = (pos && pos.actualPlacement) || placement; const cls = [ "tds-tooltip", `tds-tooltip--${size}`, `tds-tooltip--${effPlacement}`, `tds-tooltip--align-${messageAlign}`, `tds-tooltip--motion-${motionVariant}`, `tds-tooltip--clip-${clipToEnd}`, visible ? "is-visible" : "is-leaving", className, ].filter(Boolean).join(" "); const boxStyle = { position: strategy, top: pos.top, left: pos.left, ...(style || {}), }; const arrowStyle = { left: pos.arrowLeft, }; // 화살표 SVG — 위치에 따라 회전 const aw = arrowMeta.w; const ah = arrowMeta.h; const arrowSvg = ( ); const tooltipNode = ( ); return ( {triggerCloned} {mounted && typeof document !== "undefined" ? ReactDOM.createPortal(tooltipNode, document.body) : null} ); } window.Tooltip = Tooltip; Object.assign(window, { Tooltip });