/* ============================================================================= * ui_template_tree.ts * 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀. * 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`). * 오른쪽 클릭 메뉴 = 줄마다 `menu()` 가 **메뉴 항목을 밖에서 줌**(고르기 모달 · 관리 화면 나무가 같은 자리를 씀 · * 26-3) — 항목이 없으면 브라우저 기본 메뉴 그대로. * ========================================================================== */ import "./ui_template_tree.css"; import { el } from "./ui_template_elements"; export interface TreeNode { id: string; label: string; /** 줄 수 — null 이면 안 보임 */ count: number | null; /** 눌렀을 때 — 없으면 접기/펴기만 */ run?: () => void; children?: TreeNode[]; /** 처음부터 펴짐 */ open?: boolean; /** 마우스를 올렸을 때 글(기본 줄) */ tip?: string; /** 오른쪽 클릭 메뉴 항목 — 부를 때마다 새로 받음 · 빈 목록 · 없음 = 메뉴 없음(기본 줄) */ menu?: () => TreeMenuItem[]; } export interface TreeMenuItem { label: string; run: () => void; /** 못 누르는 까닭 — 있으면 흐리고 마우스를 올리면 까닭이 보임 */ disabledReason?: string; } /** 오른쪽 클릭 자리에 작은 메뉴 — 바깥 누르기 · Esc · 스크롤 = 닫힘 */ export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void { document.querySelector(".ui-tree__menu")?.remove(); const menu = el("div", { className: "ui-tree__menu", attrs: { role: "menu" } }); const close = (): void => { menu.remove(); document.removeEventListener("pointerdown", onOutside, true); document.removeEventListener("keydown", onKey, true); document.removeEventListener("scroll", close, true); }; const onOutside = (down: Event): void => { if (!menu.contains(down.target as Node)) close(); }; const onKey = (key: KeyboardEvent): void => { if (key.key === "Escape") { key.stopPropagation(); // 모달의 Esc(취소)까지 가지 않게 close(); } }; for (const item of items) { const button = el("button", { className: "ui-tree__menu-item", text: item.label, attrs: { type: "button", role: "menuitem" }, }); if (item.disabledReason) { button.disabled = true; button.title = item.disabledReason; } else { button.addEventListener("click", () => { close(); item.run(); }); } menu.append(button); } document.body.append(menu); const room = menu.getBoundingClientRect(); menu.style.left = `${Math.max(4, Math.min(event.clientX, innerWidth - room.width - 4))}px`; menu.style.top = `${Math.max(4, Math.min(event.clientY, innerHeight - room.height - 4))}px`; document.addEventListener("pointerdown", onOutside, true); document.addEventListener("keydown", onKey, true); document.addEventListener("scroll", close, true); } /** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */ export type MakeRow = ( node: TreeNode, caret: string, depth: number, onClick: () => void, ) => HTMLElement; export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] { return nodes.map((node) => { if (!node.children?.length) return make(node, "", depth, () => node.run?.()); const kids = el("div", { className: "ui-tree__kids", children: renderTree(node.children, make, depth + 1), }); kids.hidden = !node.open; const row = make(node, kids.hidden ? "▸" : "▾", depth, () => { kids.hidden = !kids.hidden; const caret = row.querySelector(".ui-tree__caret"); if (caret) caret.textContent = kids.hidden ? "▸" : "▾"; node.run?.(); }); return el("div", { className: "ui-tree__branch", children: [row, kids] }); }); } /** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */ export function createTreeRow( node: TreeNode, caret: string, depth: number, onClick: () => void, ): HTMLButtonElement { const button = el("button", { className: "ui-tree__row ui-sidebar-row", attrs: { type: "button" }, children: [ el("span", { children: [ el("span", { className: "ui-tree__caret", text: caret }), el("span", { text: node.label }), ], }), ...(node.count === null ? [] : [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]), ], }); button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; if (node.tip) button.title = node.tip; button.addEventListener("click", onClick); button.addEventListener("contextmenu", (event) => { const items = node.menu?.() ?? []; if (!items.length) return; event.preventDefault(); openTreeMenu(event, items); }); return button; }