- 위 · 보조 탭 줄 = 부품 탭(fillTabs · 밑줄 · 알약) · B09 좌측 스위치 = 분할 고르기 - 연결 칩 · 검사 탭 이름 · 끌기 태그 = 배지 · 이정표 도구 · 머리 줄 · 저장 줄 = 도구줄 · 구간 고침 · 행 고름 창 = 떠 있는 판 - 안내 · 빈 줄 · 상태 글 = 보조 글 · 운반 칸 = 카드 · 인쇄 미리보기 좌측 줄 = B09 dock 한 벌 · 쪽 체크 = 라디오 · 체크 부품 - 시험 흉내 틀 · 문구 맞춤 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
193 lines
7.5 KiB
TypeScript
193 lines
7.5 KiB
TypeScript
/* =============================================================================
|
|
* 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 { createFloatPanel } from "@ui/ui_template_float_panel";
|
|
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 panel = createFloatPanel({ title: tabName(tab), onClose: () => closePopups(level) });
|
|
const box = panel.root;
|
|
box.classList.add("b09-estimation", "b09-popup", "ui-popup");
|
|
box.setAttribute("role", "dialog");
|
|
box.setAttribute("aria-label", tabName(tab));
|
|
box.dataset.b09Popup = tab;
|
|
const head = box.querySelector<HTMLElement>(".ui-float-panel__head") as HTMLElement;
|
|
head.classList.add("b09-popup__head");
|
|
const closeButton = head.querySelector<HTMLElement>(".ui-icon-btn") as HTMLElement;
|
|
closeButton.dataset.b09PopupClose = "";
|
|
const goButton = createButton({
|
|
label: "탭 이동",
|
|
variant: "ghost",
|
|
size: 24,
|
|
onClick: () => {
|
|
closePopups();
|
|
o.go(o.link);
|
|
},
|
|
});
|
|
goButton.dataset.b09PopupGo = "";
|
|
closeButton.before(goButton);
|
|
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;
|
|
panel.body.append(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`;
|
|
}
|