- 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>
158 lines
6.6 KiB
TypeScript
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;
|
|
}
|