maplestory/frontend/src/components/common/GlobalTooltip.jsx

159 lines
5.5 KiB
React
Raw Normal View History

import { useEffect, useRef, useState, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
const DELAY_DEFAULT = 200
/**
* 루트에 마운트되는 전역 툴팁.
* document에 이벤트 위임으로 [title] 또는 [data-tooltip] 가진 요소를 감지.
*
* 사용 측은 그냥 `title="설명"` 붙이면 :
* <button title="삭제">×</button>
*
* 커스텀 옵션:
* data-tooltip="..." title 대신 사용 (native tooltip 피하고 싶을 )
* data-tooltip-placement="top" top | bottom | left | right (기본 top)
* data-tooltip-delay="300" ms (기본 200)
*/
export default function GlobalTooltip() {
const [state, setState] = useState({ open: false, text: '', placement: 'top', coords: null })
const triggerRef = useRef(null)
const tooltipRef = useRef(null)
const timerRef = useRef(null)
const titleMap = useRef(new WeakMap())
useEffect(() => {
function restoreTitle() {
const el = triggerRef.current
if (el && titleMap.current.has(el)) {
const prev = titleMap.current.get(el)
el.setAttribute('title', prev)
titleMap.current.delete(el)
}
}
function hide() {
clearTimeout(timerRef.current)
restoreTitle()
triggerRef.current = null
setState((s) => (s.open ? { ...s, open: false } : s))
}
function showFor(target) {
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')
}
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
setState({ open: true, text, placement, coords: null })
}, delay)
}
function handleOver(e) {
const target = e.target.closest?.('[title], [data-tooltip]')
if (!target) return
if (triggerRef.current === target) return
// 다른 타겟으로 이동 시 기존 정리
if (triggerRef.current && triggerRef.current !== target) {
restoreTitle()
triggerRef.current = null
}
showFor(target)
}
function handleOut(e) {
if (!triggerRef.current) return
const rt = e.relatedTarget
if (rt && triggerRef.current.contains(rt)) return
hide()
}
function handleDown() { hide() }
function handleKey(e) { if (e.key === 'Escape') hide() }
document.addEventListener('mouseover', handleOver, true)
document.addEventListener('mouseout', handleOut, true)
document.addEventListener('focusin', handleOver, true)
document.addEventListener('focusout', handleOut, true)
document.addEventListener('mousedown', handleDown, true)
document.addEventListener('keydown', handleKey, true)
window.addEventListener('scroll', hide, true)
window.addEventListener('resize', hide)
return () => {
document.removeEventListener('mouseover', handleOver, true)
document.removeEventListener('mouseout', handleOut, true)
document.removeEventListener('focusin', handleOver, true)
document.removeEventListener('focusout', handleOut, true)
document.removeEventListener('mousedown', handleDown, true)
document.removeEventListener('keydown', handleKey, true)
window.removeEventListener('scroll', hide, true)
window.removeEventListener('resize', hide)
clearTimeout(timerRef.current)
restoreTitle()
}
}, [])
// open 되면 위치 측정
useLayoutEffect(() => {
if (!state.open || !triggerRef.current || !tooltipRef.current) return
const trigger = triggerRef.current.getBoundingClientRect()
const tooltip = tooltipRef.current.getBoundingClientRect()
const gap = 6
let top, left
switch (state.placement) {
case 'bottom':
top = trigger.bottom + gap
left = trigger.left + trigger.width / 2 - tooltip.width / 2
break
case 'left':
top = trigger.top + trigger.height / 2 - tooltip.height / 2
left = trigger.left - tooltip.width - gap
break
case 'right':
top = trigger.top + trigger.height / 2 - tooltip.height / 2
left = trigger.right + gap
break
case 'top':
default:
top = trigger.top - tooltip.height - gap
left = trigger.left + trigger.width / 2 - tooltip.width / 2
}
const padding = 4
left = Math.max(padding, Math.min(left, window.innerWidth - tooltip.width - padding))
top = Math.max(padding, Math.min(top, window.innerHeight - tooltip.height - padding))
setState((s) => ({ ...s, coords: { top, left } }))
}, [state.open, state.text, state.placement])
if (!state.open) return null
return createPortal(
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: state.coords?.top ?? 0,
left: state.coords?.left ?? 0,
zIndex: 9999,
opacity: state.coords ? 1 : 0,
transition: 'opacity 120ms ease-out',
background: 'var(--tooltip-bg)',
color: 'var(--tooltip-text)',
borderColor: 'var(--tooltip-border)',
}}
className="pointer-events-none px-2 py-1 rounded-md border text-xs shadow-lg whitespace-nowrap"
>
{state.text}
</div>,
document.body,
)
}