전역 툴팁 매니저 도입 + 기존 Tooltip 간소화

- components/common/GlobalTooltip.jsx 신설
  * document 전역 이벤트 위임으로 [title] / [data-tooltip] 자동 감지
  * title 속성 일시 제거로 브라우저 기본 툴팁 억제, 포털 렌더
  * data-tooltip-placement / data-tooltip-delay 옵션 지원
  * scroll/resize/Escape/mousedown 시 자동 숨김
- Tooltip.jsx는 자식을 <span title=... data-...> 로 감싸는 단순 래퍼로 변경
- App.jsx 루트에 GlobalTooltip 마운트
- 이제 전 코드베이스의 title="..." 가 모두 커스텀 툴팁으로 렌더됨

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-19 17:23:32 +09:00
parent 7fc04cf371
commit 5368764f85
3 changed files with 184 additions and 107 deletions

View file

@ -2,9 +2,19 @@ import { isMobileOnly, isTablet } from 'react-device-detect'
import PCRoutes from './routes/pc'
import MobileRoutes from './routes/mobile'
import TabletRoutes from './routes/tablet'
import GlobalTooltip from './components/common/GlobalTooltip'
export default function App() {
function Routes() {
if (isMobileOnly) return <MobileRoutes />
if (isTablet) return <TabletRoutes />
return <PCRoutes />
}
export default function App() {
return (
<>
<Routes />
<GlobalTooltip />
</>
)
}

View file

@ -0,0 +1,158 @@
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,
)
}

View file

@ -1,113 +1,22 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
/**
* 커스텀 툴팁
* <Tooltip text="설명">
* <button>...</button>
* </Tooltip>
* 기존 호환용 래퍼. 실제 툴팁 표시는 GlobalTooltip 담당.
*
* <Tooltip text="설명" placement="top" delay={200}>
* <button>+</button>
* </Tooltip>
*
* 코드는 그냥 `title="..."` 직접 써도 .
*/
export default function Tooltip({ text, children, placement = 'top', delay = 200 }) {
const [open, setOpen] = useState(false)
const [coords, setCoords] = useState(null) // null ( )
const triggerRef = useRef(null)
const tooltipRef = useRef(null)
const timerRef = useRef(null)
const updatePosition = () => {
if (!triggerRef.current || !tooltipRef.current) return
const trigger = triggerRef.current.getBoundingClientRect()
const tooltip = tooltipRef.current.getBoundingClientRect()
const gap = 6
let top, left
switch (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))
setCoords({ top, left })
}
// open true (paint )
useLayoutEffect(() => {
if (open) updatePosition()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
useEffect(() => {
if (!open) return
const handler = () => updatePosition()
window.addEventListener('scroll', handler, true)
window.addEventListener('resize', handler)
return () => {
window.removeEventListener('scroll', handler, true)
window.removeEventListener('resize', handler)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleEnter = () => {
timerRef.current = setTimeout(() => setOpen(true), delay)
}
const handleLeave = () => {
clearTimeout(timerRef.current)
setOpen(false)
setCoords(null)
}
if (!text) return children
return (
<>
<span
ref={triggerRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onFocus={handleEnter}
onBlur={handleLeave}
className="inline-block"
>
{children}
</span>
{open && createPortal(
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
zIndex: 9999,
opacity: 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"
>
{text}
</div>,
document.body
)}
</>
<span
title={text}
data-tooltip-placement={placement}
data-tooltip-delay={delay}
className="inline-block"
>
{children}
</span>
)
}