Files
Aislo/B09_Estimation/B09_Estimation_UI_Popup.ts
T
eomsangdonandClaude Sonnet 5.5 361ce51659 refactor(B08·B09): 자체 변종 → 부품 상자 — 탭 줄 · 분할 고르기 · 배지 칩 · 도구줄 · 떠 있는 판 · 보조 글 (PLAN 553-3)
- 위 · 보조 탭 줄 = 부품 탭(fillTabs · 밑줄 · 알약) · B09 좌측 스위치 = 분할 고르기
- 연결 칩 · 검사 탭 이름 · 끌기 태그 = 배지 · 이정표 도구 · 머리 줄 · 저장 줄 = 도구줄 · 구간 고침 · 행 고름 창 = 떠 있는 판
- 안내 · 빈 줄 · 상태 글 = 보조 글 · 운반 칸 = 카드 · 인쇄 미리보기 좌측 줄 = B09 dock 한 벌 · 쪽 체크 = 라디오 · 체크 부품
- 시험 흉내 틀 · 문구 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:33:35 +09:00

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`;
}