/* ============================================================================= * 호버 정보 — 프로그램 전역 공용 「마우스를 올리면 짧은 추가 정보」 상자(PLAN 185-3) * * attachHoverInfo(root, text, selector?) — root 안 selector 에 맞는 것(없으면 root 자신)에 * 마우스(짧은 지연) · 키보드 초점(바로)이 오면 상자 · 떠나면 · 누름 · Esc · 스크롤이면 닫힘 · 돌려준 함수 = 떼기 * text = 글 하나 또는 (대상) → 글(빈 값 = 안 띄움) · 표 칸 · 단추 어디나 같은 한 벌 * 상자 = 화면에 하나(새로 뜨면 앞 것 갈음) · 대상 아래가 기본 · 아래가 모자라면 위로 뒤집고 * 좌우는 화면 안으로 당김(`placeHoverInfo`) · 색 = 테마 토큰(밝음 · 어두움 같이) * 옛 title 풍선은 그대로 둠 — 새 자리부터 이 양식(B09 칸 풍선 `tip` 이 첫 사용처) * ========================================================================== */ import "./ui_template_hover_info.css"; export type HoverText = string | ((target: HTMLElement) => string | null | undefined); /** 뜨기까지 지연(ms) — 지나가는 마우스엔 안 뜸 */ export const HOVER_DELAY_MS = 350; /** 대상과 상자 사이 · 화면 끝 여백(px) */ const GAP = 6; interface Box { left: number; top: number; right: number; bottom: number; } /** 상자 자리 — 대상 아래(왼쪽 맞춤)가 기본 · 아래가 모자라고 위가 넉넉하면 위로 · 좌우 화면 안 */ export function placeHoverInfo( anchor: Box, size: { width: number; height: number }, view: { width: number; height: number }, ): { x: number; y: number; above: boolean } { const below = anchor.bottom + GAP; const above = below + size.height > view.height - GAP && anchor.top - GAP - size.height >= GAP; const y = above ? anchor.top - GAP - size.height : below; const x = Math.max(GAP, Math.min(anchor.left, view.width - GAP - size.width)); return { x, y, above }; } let box: HTMLDivElement | null = null; let owner: HTMLElement | null = null; let timer: ReturnType | null = null; /** 지연 중인 대상 — 같은 대상 안에서 움직여도 지연을 새로 안 셈 */ let pending: HTMLElement | null = null; let globalBound = false; function shared(): HTMLDivElement { if (box?.isConnected) return box; box = document.createElement("div"); box.className = "ui-hover-info"; box.id = "ui-hover-info"; box.setAttribute("role", "tooltip"); box.hidden = true; document.body.appendChild(box); return box; } /** 떠 있는 상자를 닫음(대상이 주면 그 대상 것만) */ export function hideHoverInfo(target?: HTMLElement): void { if (target && target !== owner) return; if (timer) clearTimeout(timer); timer = null; pending = null; owner?.removeAttribute("aria-describedby"); owner = null; if (box) box.hidden = true; } function show(target: HTMLElement, text: string): void { const el = shared(); owner?.removeAttribute("aria-describedby"); owner = target; el.textContent = text; el.hidden = false; const r = target.getBoundingClientRect(); const at = placeHoverInfo( r, { width: el.offsetWidth, height: el.offsetHeight }, { width: window.innerWidth, height: window.innerHeight }, ); el.style.left = `${at.x}px`; el.style.top = `${at.y}px`; el.classList.toggle("is-above", at.above); target.setAttribute("aria-describedby", el.id); } function bindGlobal(): void { if (globalBound) return; globalBound = true; document.addEventListener("keydown", (e) => e.key === "Escape" && hideHoverInfo()); // 누름 = 닫음(누름 뒤 다시 그려져 대상이 사라져도 상자가 안 남음) document.addEventListener("pointerdown", () => hideHoverInfo(), true); document.addEventListener("scroll", () => hideHoverInfo(), true); } export function attachHoverInfo(root: HTMLElement, text: HoverText, selector?: string): () => void { bindGlobal(); const find = (node: EventTarget | null): HTMLElement | null => { if (!(node instanceof Element)) return null; const hit = selector ? node.closest(selector) : root; return hit && root.contains(hit) ? hit : null; }; const enter = (event: Event, delay: number): void => { const target = find(event.target); if (!target || target === owner || (timer && target === pending)) return; if (timer) clearTimeout(timer); pending = target; timer = setTimeout(() => { timer = null; const value = typeof text === "function" ? text(target) : text; if (value) show(target, value); else hideHoverInfo(); }, delay); }; const leave = (event: Event): void => { const target = find(event.target); const next = (event as MouseEvent | FocusEvent).relatedTarget; if (!target || (next instanceof Node && target.contains(next))) return; if (target === owner || target === pending) hideHoverInfo(); }; const over = (e: Event): void => enter(e, HOVER_DELAY_MS); const focus = (e: Event): void => enter(e, 0); root.addEventListener("mouseover", over); root.addEventListener("mouseout", leave); root.addEventListener("focusin", focus); root.addEventListener("focusout", leave); return () => { root.removeEventListener("mouseover", over); root.removeEventListener("mouseout", leave); root.removeEventListener("focusin", focus); root.removeEventListener("focusout", leave); if (owner && root.contains(owner)) hideHoverInfo(); }; }