Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/* =============================================================================
|
||||
* 호버 정보 상자 공용 모양(`ui_template_hover_info.ts` · PLAN 185-3) — 화면에 하나 · fixed ·
|
||||
* 색 = 테마 토큰(밝음 · 어두움 같이) · 마우스를 안 막음
|
||||
* ========================================================================== */
|
||||
|
||||
.ui-hover-info {
|
||||
position: fixed;
|
||||
z-index: var(--z-toast);
|
||||
max-width: min(320px, calc(100vw - 12px));
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text-body);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-hover-info[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/* =============================================================================
|
||||
* 호버 정보 — 프로그램 전역 공용 「마우스를 올리면 짧은 추가 정보」 상자(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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user