Files
Aislo/ui_template/ui_template_hover_info.ts
T
eomsangdonandClaude Opus 5.5 2c51bb53a6 feat(ui-kit): 기본 부품 입력 · 고르기 · 슬라이더 · 체크 · 켜기 · 분할 · 탭 · 배지 · 툴팁 · 보조 글 (PLAN 552-2)
- ui_template_input.ts · .css 새로 — createInputField · createSelectField 를 옮김(ui_template_elements 가 다시 내보냄 · 부르는 길 그대로) · size(data-size 단 높이) · unit(칸 안 단위) · multiline · createSlider(이름 | 막대 | 값 글 3열)
- 옛 compact 는 단을 안 붙임 — 옛 작은 칸 30 그대로(32 로 맞추면 페이지 칸이 바뀜 · 553 에서 단으로 옮김)
- 입력 · 고르기 상태 = 마우스 선 짙게 · 막힘 흐림 · 오류 빨강(aria-invalid 같이) · 고르기 화살표 = 보라 → 보조 글 색(밝음 · 어두움 따로)
- 체크 = 라디오 · 칩 꼴 더함(createCheckbox 부름 그대로) · 켜기(스위치 · 색 점 칩) · 분할 고르기(대시보드 .b01-sys-seg 꼴) · 탭(밑줄 · 보조 알약 = 옛 .ui-subtabs · 수 배지)
- 배지(알약 · 수 · 램프 ✓ ✗ · 점 · 옛 .ui-tag 같이) · 툴팁 제자리 상자(tooltipBox) · 보조 글(흐린 · 안내 · 오류 ✗ · 경고 ! · 성공 ✓)
- 부품 모음 기본 묶음 13 줄 모두 새 부품 견본 · 상태 다섯 칸 · 여덟 단 한 줄
- 시험 test_552_2_parts 새로(머리 · 옛 이름 · 크기 단 · 상태 · 날 색 없음 · 부품 모음 13)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:39:26 +09:00

158 lines
6.6 KiB
TypeScript

/* =============================================================================
* 호버 정보 — 프로그램 전역 공용 「마우스를 올리면 짧은 추가 정보」 상자(PLAN 185-3)
*
* attachHoverInfo(root, text, selector?) — root 안 selector 에 맞는 것(없으면 root 자신)에
* 마우스(짧은 지연) · 키보드 초점(바로)이 오면 상자 · 떠나면 · 누름 · Esc · 스크롤이면 닫힘 · 돌려준 함수 = 떼기
* text = 글 하나 또는 (대상) → 글(빈 값 = 안 띄움) · 표 칸 · 단추 어디나 같은 한 벌
* 상자 = 화면에 하나(새로 뜨면 앞 것 갈음) · 대상 아래가 기본 · 아래가 모자라면 위로 뒤집고
* 좌우는 화면 안으로 당김(`placeHoverInfo`) · 색 = 테마 토큰(밝음 · 어두움 같이)
* 옛 title 풍선은 그대로 둠 — 새 자리부터 이 양식(B09 칸 풍선 `tip` 이 첫 사용처)
*
* 부품 「툴팁」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`)
* 꼴 = 마우스 글(attachHoverInfo) · 값 풍선 · 그림 위 설명(tooltipBox — 제자리 상자)
* 크기 = 글 12 · 폭 최대 320 · 상태 = 없음
* 쓰기 = `attachHoverInfo(cell, "누르면 고침")` · `panel.append(tooltipBox("구간 1 · 높이 2.4 m"))`
* 대신 = `title=`(B06 52 · CAD 32 · B07 20) · `.ui-prov-card` · 시트 `.ss-errtip__balloon` · B05 `b05-structure__balloon` ·
* E03 `e03-tip` · E04 · E07 `*-map__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);
// 손가락 — hover 가 없으니 톡 하면 바로 뜸(기기대응 2-6 · 다음 누름에 닫힘)
const tap = (e: PointerEvent): void => {
if (e.pointerType === "touch") enter(e, 0);
};
root.addEventListener("pointerup", tap);
root.addEventListener("mouseover", over);
root.addEventListener("mouseout", leave);
root.addEventListener("focusin", focus);
root.addEventListener("focusout", leave);
return () => {
root.removeEventListener("pointerup", tap);
root.removeEventListener("mouseover", over);
root.removeEventListener("mouseout", leave);
root.removeEventListener("focusin", focus);
root.removeEventListener("focusout", leave);
if (owner && root.contains(owner)) hideHoverInfo();
};
}
/** 제자리 툴팁 상자 — 그림 위 설명 · 값 풍선(위치는 부르는 쪽 몫 · 마우스를 안 막음) */
export function tooltipBox(text: string): HTMLDivElement {
const tip = document.createElement("div");
tip.className = "ui-hover-info ui-hover-info--inline";
tip.setAttribute("role", "tooltip");
tip.textContent = text;
return tip;
}