Files
Aislo/B09_Estimation/B09_Estimation_UI_Popup.ts
T

199 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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`;
}