From 01bbbbd6af2a51614f34f5300303a9b1b7c5f4f6 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 24 Apr 2026 08:39:04 +0900 Subject: [PATCH] =?UTF-8?q?GlobalTooltip:=20delay=20=EC=A4=91=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B0=EC=8A=A4=20=EB=B2=97=EC=96=B4=EB=82=98=EB=A9=B4=20tit?= =?UTF-8?q?le=20=EB=B3=B5=EC=9B=90=20=EC=95=88=20=EB=90=98=EB=8D=98=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showFor 가 호출되면 title 속성을 즉시 제거하고 titleMap 에 저장한 뒤 setTimeout 으로 지연. 지연 중에 마우스가 벗어나 handleOut 가 호출되어도 triggerRef 는 아직 null 이라 hide() 가 title 을 복원하지 못해 해당 요소의 title 이 영구 제거되고 이후 [title] 선택자에 걸리지 않아 툴팁이 안 뜸. - pendingRef 추가해 "delay 중 title 제거된 타겟" 추적 - hide / showFor / handleOver / handleOut 모두 pendingRef 고려해 복원 - 타이머 중 다른 타겟으로 이동 / 같은 타겟 반복 hover 케이스 처리 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/common/GlobalTooltip.jsx | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/common/GlobalTooltip.jsx b/frontend/src/components/common/GlobalTooltip.jsx index a8f7b57..fc03213 100644 --- a/frontend/src/components/common/GlobalTooltip.jsx +++ b/frontend/src/components/common/GlobalTooltip.jsx @@ -17,42 +17,62 @@ const DELAY_DEFAULT = 200 */ export default function GlobalTooltip() { const [state, setState] = useState({ open: false, text: '', placement: 'top', coords: null }) - const triggerRef = useRef(null) + const triggerRef = useRef(null) // 툴팁이 실제로 떠있는 타겟 + const pendingRef = useRef(null) // delay 중이라 title 을 벗긴 타겟 (아직 툴팁 안 뜸) const tooltipRef = useRef(null) const timerRef = useRef(null) const titleMap = useRef(new WeakMap()) useEffect(() => { - function restoreTitle() { - const el = triggerRef.current + function stripTitle(el) { + const t = el.getAttribute('title') + if (t && !titleMap.current.has(el)) { + titleMap.current.set(el, t) + el.removeAttribute('title') + } + } + + function restoreTitle(el) { if (el && titleMap.current.has(el)) { - const prev = titleMap.current.get(el) - el.setAttribute('title', prev) + el.setAttribute('title', titleMap.current.get(el)) titleMap.current.delete(el) } } function hide() { clearTimeout(timerRef.current) - restoreTitle() + restoreTitle(pendingRef.current) + restoreTitle(triggerRef.current) + pendingRef.current = null triggerRef.current = null setState((s) => (s.open ? { ...s, open: false } : s)) } function showFor(target) { + // 이미 같은 타겟이 처리중이면 무시 + if (triggerRef.current === target || pendingRef.current === target) return + + // 다른 pending 이 있다면 먼저 정리 + if (pendingRef.current) { + clearTimeout(timerRef.current) + restoreTitle(pendingRef.current) + pendingRef.current = null + } + const nativeTitle = target.getAttribute('title') const dataTooltip = target.getAttribute('data-tooltip') const text = nativeTitle || dataTooltip if (!text) return - if (nativeTitle && !titleMap.current.has(target)) { - titleMap.current.set(target, nativeTitle) - target.removeAttribute('title') - } + + if (nativeTitle) stripTitle(target) + pendingRef.current = target + const placement = target.getAttribute('data-tooltip-placement') || 'top' const delay = Number(target.getAttribute('data-tooltip-delay')) || DELAY_DEFAULT clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { triggerRef.current = target + pendingRef.current = null setState({ open: true, text, placement, coords: null }) }, delay) } @@ -60,19 +80,22 @@ export default function GlobalTooltip() { function handleOver(e) { const target = e.target.closest?.('[title], [data-tooltip]') if (!target) return - if (triggerRef.current === target) return - // 다른 타겟으로 이동 시 기존 정리 + if (triggerRef.current === target || pendingRef.current === target) return + + // 다른 trigger 가 떠있으면 먼저 정리 if (triggerRef.current && triggerRef.current !== target) { - restoreTitle() + restoreTitle(triggerRef.current) triggerRef.current = null + setState((s) => (s.open ? { ...s, open: false } : s)) } showFor(target) } function handleOut(e) { - if (!triggerRef.current) return + const active = triggerRef.current || pendingRef.current + if (!active) return const rt = e.relatedTarget - if (rt && triggerRef.current.contains(rt)) return + if (rt && active.contains(rt)) return hide() } @@ -98,7 +121,8 @@ export default function GlobalTooltip() { window.removeEventListener('scroll', hide, true) window.removeEventListener('resize', hide) clearTimeout(timerRef.current) - restoreTitle() + restoreTitle(pendingRef.current) + restoreTitle(triggerRef.current) } }, [])