Files
Aislo/ui_template/ui_template_tree.ts
T
eomsangdonandClaude Sonnet 5.5 d6c85a5f28 feat(ui_template): 나무 오른쪽 클릭 메뉴 자리(menu · openTreeMenu) · 항목 경로 나무(폴더 깊이 자유) (PLAN 26-3b)
- TreeNode.menu = 메뉴 항목을 밖에서 주는 자리 · openTreeMenu 공용 작은 메뉴
- PickerRow.경로 · pathOf · buildTree 가 경로 앞부분을 폴더로 · PickNode.folder{layer,path}

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
2026-09-30 08:05:12 +09:00

139 lines
5.0 KiB
TypeScript

/* =============================================================================
* 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;
}