전역 툴팁 매니저 도입 + 기존 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:
parent
7fc04cf371
commit
5368764f85
3 changed files with 184 additions and 107 deletions
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
158
frontend/src/components/common/GlobalTooltip.jsx
Normal file
158
frontend/src/components/common/GlobalTooltip.jsx
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue