Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
/* =============================================================================
|
|
* 호버 정보 — 프로그램 전역 공용 「마우스를 올리면 짧은 추가 정보」 상자(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<typeof setTimeout> | 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<HTMLElement>(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();
|
|
};
|
|
}
|