Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
176 lines
6.7 KiB
TypeScript
176 lines
6.7 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_Popup.ts
|
||
* B09 행 고름 팝업(PLAN 303-2) — 링크 있는 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(옮기지 않음).
|
||
*
|
||
* 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션)
|
||
* 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음)
|
||
* 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤
|
||
* 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두
|
||
* 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음)
|
||
* ========================================================================== */
|
||
|
||
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;
|
||
|
||
/** 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(stack.length - 1);
|
||
},
|
||
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`;
|
||
}
|