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} + ) }