feat(ui): 공용 호버 정보 양식 · B09 칸 풍선을 새 호버로(185-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
@@ -227,16 +228,16 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const box = cell.getBoundingClientRect();
|
||||
openMenuAt(box.left, box.bottom, items);
|
||||
});
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 title
|
||||
// 157-3 칸 풍선 — 공용 표는 그대로 · 올린 칸에 공용 호버 정보(185-3)
|
||||
if (spec.tip)
|
||||
made.root.addEventListener("mouseover", (event) => {
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
|
||||
const row = cell?.parentElement;
|
||||
const column = cell ? spec.columns[cell.cellIndex] : undefined;
|
||||
if (!cell || !row || !column) return;
|
||||
const tip = spec.tip?.(Number(row.dataset.i), column.key);
|
||||
if (tip) cell.title = tip;
|
||||
});
|
||||
attachHoverInfo(
|
||||
made.root,
|
||||
(cell) => {
|
||||
const column = spec.columns[(cell as HTMLTableCellElement).cellIndex];
|
||||
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
},
|
||||
"tr[data-i] td",
|
||||
);
|
||||
return made;
|
||||
};
|
||||
const hint = spec.hint ? note(spec.hint) : null;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""185-3 공용 호버 정보 양식(`ui_template_hover_info.ts`) — 지연 · 뒤집기 · 초점 · 한 번에 하나 · 토큰.
|
||||
|
||||
Node 로 모듈을 그대로 돌림(작은 가짜 DOM · 타이머 손으로 돌림) · 자리 셈 `placeHoverInfo` 는 함수만.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS = ROOT / "config" / "node_modules" / "typescript"
|
||||
MOD = ROOT / "ui_template" / "ui_template_hover_info.ts"
|
||||
CSS = ROOT / "ui_template" / "ui_template_hover_info.css"
|
||||
|
||||
PRELUDE = f"""
|
||||
const fs = require("fs");
|
||||
const ts = require({json.dumps(str(TS))});
|
||||
const js = ts.transpileModule(fs.readFileSync({json.dumps(str(MOD))}, "utf8"), {{
|
||||
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
|
||||
}}).outputText;
|
||||
class Node {{}}
|
||||
class Element extends Node {{
|
||||
constructor(tag, sel) {{
|
||||
super(); this.tag = tag; this.sel = sel; this.parentElement = null; this.children = [];
|
||||
this.attrs = {{}}; this.on = {{}}; this.style = {{}}; this.hidden = false; this.isConnected = true;
|
||||
this.offsetWidth = 200; this.offsetHeight = 40; this.textContent = "";
|
||||
this.rect = {{ left: 100, top: 100, right: 180, bottom: 120 }};
|
||||
const set = new Set();
|
||||
this.classList = {{ set, toggle: (c, on) => (on ? set.add(c) : set.delete(c)), contains: (c) => set.has(c) }};
|
||||
}}
|
||||
appendChild(c) {{ c.parentElement = this; this.children.push(c); return c; }}
|
||||
contains(n) {{ for (let x = n; x; x = x.parentElement) if (x === this) return true; return false; }}
|
||||
closest(sel) {{ for (let x = this; x; x = x.parentElement) if (x.sel === sel) return x; return null; }}
|
||||
setAttribute(k, v) {{ this.attrs[k] = String(v); }}
|
||||
getAttribute(k) {{ return this.attrs[k] ?? null; }}
|
||||
removeAttribute(k) {{ delete this.attrs[k]; }}
|
||||
addEventListener(t, f) {{ (this.on[t] ||= []).push(f); }}
|
||||
removeEventListener(t, f) {{ this.on[t] = (this.on[t] || []).filter((g) => g !== f); }}
|
||||
getBoundingClientRect() {{ return this.rect; }}
|
||||
fire(type, extra = {{}}) {{
|
||||
const e = {{ type, target: this, ...extra }};
|
||||
for (let x = this; x; x = x.parentElement) (x.on[type] || []).forEach((f) => f(e));
|
||||
}}
|
||||
}}
|
||||
const document = new Element("#document");
|
||||
document.body = document.appendChild(new Element("body"));
|
||||
document.createElement = (t) => new Element(t);
|
||||
const window = {{ innerWidth: 1000, innerHeight: 800 }};
|
||||
let timers = [];
|
||||
let seq = 0;
|
||||
const fakeSet = (f, ms) => (timers.push({{ id: ++seq, f, ms }}), seq);
|
||||
const fakeClear = (id) => (timers = timers.filter((t) => t.id !== id));
|
||||
const tick = () => {{ const now = timers; timers = []; now.forEach((t) => t.f()); return now.map((t) => t.ms); }};
|
||||
const mod = {{ exports: {{}} }};
|
||||
new Function("exports", "module", "require", "document", "window", "Element", "Node",
|
||||
"setTimeout", "clearTimeout", js)(mod.exports, mod, () => ({{}}), document, window, Element, Node,
|
||||
fakeSet, fakeClear);
|
||||
const H = mod.exports;
|
||||
const box = () => document.body.children.filter((c) => c.attrs.role === "tooltip");
|
||||
const out = {{}};
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str):
|
||||
script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));"
|
||||
got = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def test_자리_아래가_기본_아래_모자라면_위로_뒤집기_좌우는_화면_안():
|
||||
got = _node(
|
||||
"""
|
||||
const view = { width: 1000, height: 800 };
|
||||
const size = { width: 200, height: 40 };
|
||||
const at = (l, t, r, b) => H.placeHoverInfo({ left: l, top: t, right: r, bottom: b }, size, view);
|
||||
out.below = at(100, 100, 180, 120);
|
||||
out.bottom = at(100, 760, 180, 780);
|
||||
out.right = at(950, 100, 990, 120);
|
||||
out.left = at(-30, 100, 20, 120);
|
||||
out.tight = at(100, 10, 180, 790);
|
||||
"""
|
||||
)
|
||||
assert got["below"] == {"x": 100, "y": 126, "above": False}
|
||||
assert got["bottom"] == {"x": 100, "y": 714, "above": True} # 760 - 6 - 40
|
||||
assert got["right"]["x"] == 1000 - 6 - 200 and not got["right"]["above"]
|
||||
assert got["left"]["x"] == 6
|
||||
assert got["tight"]["above"] is False # 위도 모자라면 아래 그대로
|
||||
|
||||
|
||||
def test_마우스_짧은_지연_뒤_뜸_떠나면_닫힘_aria():
|
||||
got = _node(
|
||||
"""
|
||||
const root = document.body.appendChild(new Element("table"));
|
||||
const cell = root.appendChild(new Element("td", "td"));
|
||||
const inner = cell.appendChild(new Element("span"));
|
||||
H.attachHoverInfo(root, (t) => (t === cell ? "물가정보 · 2026-07 · 670쪽" : null), "td");
|
||||
inner.fire("mouseover");
|
||||
out.beforeTick = box().length ? !box()[0].hidden : false;
|
||||
out.delays = tick();
|
||||
out.text = box()[0].textContent;
|
||||
out.shown = !box()[0].hidden;
|
||||
out.aria = cell.attrs["aria-describedby"] === box()[0].id;
|
||||
out.pos = [box()[0].style.left, box()[0].style.top];
|
||||
inner.fire("mouseout", { relatedTarget: cell }); // 칸 안에서 움직임 = 그대로
|
||||
out.stay = !box()[0].hidden;
|
||||
cell.fire("mouseout", { relatedTarget: root });
|
||||
out.gone = box()[0].hidden && !("aria-describedby" in cell.attrs);
|
||||
"""
|
||||
)
|
||||
assert got["beforeTick"] is False and got["delays"] == [350]
|
||||
assert got["text"] == "물가정보 · 2026-07 · 670쪽" and got["shown"] and got["aria"]
|
||||
assert got["pos"] == ["100px", "126px"]
|
||||
assert got["stay"] and got["gone"]
|
||||
|
||||
|
||||
def test_키보드_초점이면_지연_없이_뜸_화면_끝이면_뒤집힘():
|
||||
got = _node(
|
||||
"""
|
||||
const root = document.body.appendChild(new Element("div"));
|
||||
const btn = root.appendChild(new Element("button"));
|
||||
btn.rect = { left: 900, top: 770, right: 990, bottom: 790 };
|
||||
H.attachHoverInfo(btn, "단추 설명");
|
||||
btn.fire("focusin");
|
||||
out.delays = tick();
|
||||
const b = box()[0];
|
||||
out.text = b.textContent;
|
||||
out.above = b.classList.contains("is-above");
|
||||
out.pos = [b.style.left, b.style.top];
|
||||
btn.fire("focusout", { relatedTarget: null });
|
||||
out.gone = b.hidden;
|
||||
"""
|
||||
)
|
||||
assert got["delays"] == [0] and got["text"] == "단추 설명"
|
||||
assert got["above"] and got["pos"] == ["794px", "724px"]
|
||||
assert got["gone"]
|
||||
|
||||
|
||||
def test_한_번에_하나_빈_글은_안_뜸_Esc_누름_떼기():
|
||||
got = _node(
|
||||
"""
|
||||
const root = document.body.appendChild(new Element("div"));
|
||||
const a = root.appendChild(new Element("td", "td"));
|
||||
const b = root.appendChild(new Element("td", "td"));
|
||||
const c = root.appendChild(new Element("td", "td"));
|
||||
const stop = H.attachHoverInfo(root, (t) => (t === a ? "A" : t === b ? "B" : ""), "td");
|
||||
a.fire("focusin"); tick();
|
||||
b.fire("focusin"); tick();
|
||||
out.count = box().length;
|
||||
out.text = box()[0].textContent;
|
||||
out.aOff = !("aria-describedby" in a.attrs) && "aria-describedby" in b.attrs;
|
||||
c.fire("focusin"); tick();
|
||||
out.emptyHidden = box()[0].hidden;
|
||||
a.fire("focusin"); tick();
|
||||
document.fire("keydown", { key: "Escape" });
|
||||
out.esc = box()[0].hidden;
|
||||
a.fire("focusin"); tick(); a.fire("focusout", { relatedTarget: null });
|
||||
a.fire("mouseover"); tick();
|
||||
document.fire("pointerdown");
|
||||
out.press = box()[0].hidden;
|
||||
stop();
|
||||
a.fire("mouseover");
|
||||
out.afterStop = timers.length;
|
||||
"""
|
||||
)
|
||||
assert got["count"] == 1 and got["text"] == "B" and got["aOff"]
|
||||
assert got["emptyHidden"] and got["esc"] and got["press"]
|
||||
assert got["afterStop"] == 0
|
||||
|
||||
|
||||
def test_모양은_테마_토큰_밝음_어두움_같이_마우스_안_막음():
|
||||
css = CSS.read_text(encoding="utf-8")
|
||||
rule = css[css.index(".ui-hover-info {") :]
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", rule) # 색 글자 박지 않음
|
||||
for token in ("--color-surface-raised", "--color-border", "--color-text-body", "--z-toast"):
|
||||
assert f"var({token})" in rule
|
||||
assert "pointer-events: none" in rule and "position: fixed" in rule
|
||||
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8")
|
||||
dark = theme[theme.index(':root[data-theme="dark"]') :]
|
||||
assert "--color-canvas:" in dark and "--color-mist:" in dark # 어두움 = 토큰 바탕이 바뀜
|
||||
src = MOD.read_text(encoding="utf-8")
|
||||
assert (
|
||||
'import "./ui_template_hover_info.css";' in src and 'setAttribute("role", "tooltip")' in src
|
||||
)
|
||||
@@ -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