/* ============================================================================= * B09_Estimation_UI_Popup.ts * B09 행 고름 팝업(PLAN 303-2) — 링크 있는 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(옮기지 않음). * * 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션) * 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음) * 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤 * 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · × = 그 창부터 위로 · 탭 바꿈 · 주소 바뀜 = 모두 * Esc · 맨 위 창 밖 누름(아래 창 · 빈 곳 · 톡) = 모두(308) — 그 누름은 닫기만(아래 화면에 안 넘김) * 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음) * ========================================================================== */ import { createDataTable, type DataTableOptions } from "@ui/ui_template_data_table"; import { createButton } from "@ui/ui_template_elements"; import { placeHoverInfo } from "@ui/ui_template_hover_info"; import { tableOptionsOf } from "./B09_Estimation_UI_Builders"; import { rowIndex, tabName, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { subtreeEnd } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { isGroupTab, pickClass, pickRange } from "./B09_Estimation_UI_Tabs_Groups"; const EDGE = 8; /** 강조 범위 [첫 줄, 끝 줄] — 대상 줄 없음 = null */ export function popupRange(tab: TabId, spec: TabTable, key: string): [number, number] | null { if (isGroupTab(tab)) return pickRange(spec.rows, key); const at = rowIndex(spec.rows, key); if (at < 0) return null; return (spec as BillTable).menu ? [at, subtreeEnd(spec.rows, at)] : [at, at]; } /** 창 표 옵션 — 범위 줄만 · 줄 번호 곁값은 원래 줄 번호로 · 빨강 테두리 · 합계 · 고칠 칸 없음 */ export function popupOptions( spec: TabTable, tab: TabId, range: [number, number], ): DataTableOptions { const [from, to] = range; const at = (i: number): number => i + from; const level = spec.level; return { ...tableOptionsOf(spec, tab), rows: spec.rows.slice(from, to + 1), total: null, editable: undefined, onEdit: undefined, rowClass: (i) => [spec.rowClass?.(at(i)), pickClass(range, at(i))].filter(Boolean).join(" ") || null, cellClass: spec.cellClass && ((i, key) => spec.cellClass?.(at(i), key)), level: level && { ...level, of: (i) => level.of(at(i)), group: level.group && ((i) => level.group?.(at(i)) ?? false), }, }; } /** 창 자리 — 누른 줄 아래(모자라면 위) · 화면 밖으로 안 나감 */ export function popupPlace( anchor: { left: number; top: number; right: number; bottom: number }, size: { width: number; height: number }, view: { width: number; height: number }, ): { x: number; y: number } { const { x, y } = placeHoverInfo(anchor, size, view); return { x, y: Math.max(EDGE, Math.min(y, view.height - EDGE - size.height)) }; } /** 열린 창(아래 → 위) */ const stack: HTMLElement[] = []; let listening = false; /** 바깥 누름으로 닫은 뒤 따라오는 click · 우클릭 메뉴를 먹음(308) */ let eat = false; /** from 번째 창부터 위로 모두 닫음(0 = 모두) */ export function closePopups(from = 0): void { for (const box of stack.splice(from)) box.remove(); } export const popupCount = (): number => stack.length; function listen(): void { if (listening) return; listening = true; // Esc = 모두(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔) window.addEventListener( "keydown", (event) => { if (event.key !== "Escape" || !stack.length) return; event.preventDefault(); event.stopPropagation(); closePopups(); }, true, ); // 맨 위 창 밖 누름 = 모두 닫음 · 그 누름은 아래로 안 넘김(308) window.addEventListener( "pointerdown", (event) => { eat = !!stack.length && !stack[stack.length - 1].contains(event.target as Node); if (!eat) return; event.preventDefault(); event.stopPropagation(); closePopups(); }, true, ); const swallow = (event: Event): void => { if (!eat) return; eat = false; event.preventDefault(); event.stopPropagation(); }; window.addEventListener("click", swallow, true); window.addEventListener("contextmenu", swallow, true); window.addEventListener("hashchange", () => closePopups()); } export interface PopupOpen { link: Link; /** 누른 줄 · 카드 줄(자리 기준) — 없으면 화면 가운데 위 */ anchor: Element | null; /** 대상 탭 표(화면과 같은 표 짜기) — 없으면 null */ specOf: (tab: TabId) => TabTable | null; /** 하이퍼링크처럼 옮김(대상 줄 없음 · [탭으로]) */ go: (link: Link) => void; /** 참 = 창 안 누름(이어진 창) · 거짓 = 화면 누름(있던 창 모두 닫고 새로) */ chain?: boolean; } export function openRowPopup(o: PopupOpen): void { if (!o.chain) closePopups(); const { tab, key } = o.link; const spec = o.specOf(tab); const range = spec && popupRange(tab, spec, key); if (!spec || !range) return o.go(o.link); // 보일 줄 없음 = 옮김 listen(); const level = stack.length; const box = document.createElement("div"); box.className = "b09-estimation b09-popup ui-popup"; box.setAttribute("role", "dialog"); box.setAttribute("aria-label", tabName(tab)); box.dataset.b09Popup = tab; const title = document.createElement("span"); title.className = "b09-popup__title"; title.textContent = tabName(tab); const goButton = createButton({ label: "탭으로", variant: "ghost", size: "sm", onClick: () => { closePopups(); o.go(o.link); }, }); goButton.dataset.b09PopupGo = ""; const closeButton = createButton({ label: "×", variant: "ghost", size: "sm", onClick: () => closePopups(level), }); closeButton.dataset.b09PopupClose = ""; closeButton.setAttribute("aria-label", "닫기"); const head = document.createElement("div"); head.className = "b09-popup__head"; head.append(title, goButton, closeButton); const from = range[0]; const table = createDataTable({ ...popupOptions(spec, tab, range), // 창 안 링크 줄 = 이어진 창(고칠 칸 · 「단가연결」 칸은 화면처럼 안 엶) onCellClick: (i, cellKey) => { if (spec.editable?.(i + from, cellKey)) return; const next = spec.link?.(i + from, cellKey); if (next) openRowPopup({ ...o, link: next, anchor: table.root.querySelector(`tr[data-i="${i}"]`), chain: true, }); }, }); table.root.dataset.b09Table = tab; box.append(head, table.root); document.body.append(box); stack.push(box); const view = { width: window.innerWidth, height: window.innerHeight }; const size = box.getBoundingClientRect(); const anchor = o.anchor?.getBoundingClientRect() ?? { left: (view.width - size.width) / 2, right: (view.width + size.width) / 2, top: view.height / 4, bottom: view.height / 4, }; const { x, y } = popupPlace(anchor, size, view); box.style.left = `${x}px`; box.style.top = `${y}px`; }