diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index aece8de..c55c5eb 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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
if (isTablet) return
return
}
+
+export default function App() {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/frontend/src/components/common/GlobalTooltip.jsx b/frontend/src/components/common/GlobalTooltip.jsx
new file mode 100644
index 0000000..a8f7b57
--- /dev/null
+++ b/frontend/src/components/common/GlobalTooltip.jsx
@@ -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="설명"` 만 붙이면 됨:
+ *
+ *
+ * 커스텀 옵션:
+ * 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(
+
+ {state.text}
+
,
+ document.body,
+ )
+}
diff --git a/frontend/src/components/common/Tooltip.jsx b/frontend/src/components/common/Tooltip.jsx
index f89b1a0..2dd376d 100644
--- a/frontend/src/components/common/Tooltip.jsx
+++ b/frontend/src/components/common/Tooltip.jsx
@@ -1,113 +1,22 @@
-import { useState, useRef, useEffect, useLayoutEffect } from 'react'
-import { createPortal } from 'react-dom'
-
/**
- * 커스텀 툴팁
- *
- *
- *
+ * 기존 호환용 래퍼. 실제 툴팁 표시는 GlobalTooltip 이 담당.
+ *
+ *
+ *
+ *
+ *
+ * 새 코드는 그냥 `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 (
- <>
-
- {children}
-
- {open && createPortal(
-
- {text}
-
,
- document.body
- )}
- >
+
+ {children}
+
)
}