Files
Aislo/ui_template/ui_template_hover_info.ts
T

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();
};
}