From d6c85a5f28fbf45d30594aac33197f62bc7a90b3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 08:05:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui=5Ftemplate):=20=EB=82=98=EB=AC=B4=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=81=B4=EB=A6=AD=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EC=9E=90=EB=A6=AC(menu=20=C2=B7=20openTreeMenu)=20?= =?UTF-8?q?=C2=B7=20=ED=95=AD=EB=AA=A9=20=EA=B2=BD=EB=A1=9C=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4(=ED=8F=B4=EB=8D=94=20=EA=B9=8A=EC=9D=B4=20=EC=9E=90?= =?UTF-8?q?=EC=9C=A0)=20(PLAN=2026-3b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TreeNode.menu = 메뉴 항목을 밖에서 주는 자리 · openTreeMenu 공용 작은 메뉴 - PickerRow.경로 · pathOf · buildTree 가 경로 앞부분을 폴더로 · PickNode.folder{layer,path} Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- ui_template/ui_template_item_picker_data.ts | 99 ++++++++++++--------- ui_template/ui_template_tree.css | 35 ++++++++ ui_template/ui_template_tree.ts | 62 +++++++++++++ 3 files changed, 155 insertions(+), 41 deletions(-) diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 602adb86..771957a4 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -32,6 +32,8 @@ export interface PickerRow { 도번?: string; 면적번?: string; 재료번?: string; + /** 폴더 이름 배열(깊이 자유 · 26-1) — 없으면 종류 · 분류1~3 중 있는 것으로 이음 */ + 경로?: string[] | null; /** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */ names?: Partial>; layer?: PickerLayer; @@ -43,6 +45,8 @@ export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "pers export interface PickNode { id: string; + /** 어느 그룹 · 폴더 이름 배열 — 폴더 줄만(오른쪽 클릭 메뉴가 「누른 폴더」 를 앎) · 그룹 줄 = 빈 배열 */ + folder?: { layer: PickerLayer; path: string[] }; /** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */ label: string; /** 마우스를 올렸을 때 — 잎은 키 */ @@ -59,11 +63,15 @@ const PLACEHOLDER = /^새 분류\s*\d*$/; export const shown = (part?: string): string => part && !PLACEHOLDER.test(part.trim()) ? part : ""; -/** 보이는 항목 이름 — 종류 · 분류1~3 을 이음(자리표는 뺌 · 비면 열 id · 키) */ -export const itemName = (row: PickerRow): string => - [row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean).join(" ") || - row.열 || - row.key; +/** 폴더 이름 배열 — 서버가 준 `경로` · 없으면 종류 · 분류1~3 중 있는 것(자리표 · 빈 것은 뺌) */ +export function pathOf(row: PickerRow): string[] { + const own = (row.경로 ?? []).map(shown).filter(Boolean); + if (own.length) return own; + return [row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean); +} + +/** 보이는 항목 이름 — 경로를 이음(비면 열 id · 키) */ +export const itemName = (row: PickerRow): string => pathOf(row).join(" ") || row.열 || row.key; /** 마우스를 올렸을 때 — 층 이름 + 키 */ export const rowTip = (row: PickerRow, labels: TreeLabels): string => @@ -113,49 +121,58 @@ export interface TreeLabels { unclassified: string; } -/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */ +const folderId = (layer: PickerLayer, path: string[]): string => [layer, ...path].join("|"); + +/** 그룹 → 폴더(경로 앞부분 · 깊이 자유) → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */ export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] { return PICKER_LAYERS.map((layer) => { - const mine = rows.filter((row) => layerOf(row) === layer); - const kinds = new Map>(); - for (const row of mine) { - const kind = shown(row.종류) || labels.unclassified; - const cat = shown(row.분류1) || labels.unclassified; - const cats = kinds.get(kind) ?? new Map(); - cats.set(cat, [...(cats.get(cat) ?? []), row]); - kinds.set(kind, cats); + const root: PickNode = { + id: layer, + label: labels.layers[layer], + count: 0, + children: [], + folder: { layer, path: [] }, + }; + const folders = new Map([[layer, root]]); + for (const row of rows.filter((one) => layerOf(one) === layer)) { + let parent = root; + const path = pathOf(row).slice(0, -1); + path.forEach((name, index) => { + const here = path.slice(0, index + 1); + const id = folderId(layer, here); + let node = folders.get(id); + if (!node) { + node = { id, label: name, count: 0, children: [], folder: { layer, path: here } }; + folders.set(id, node); + parent.children?.push(node); + } + parent = node; + }); + parent.children?.push({ + id: rowId(row), + label: itemName(row), + tip: rowTip(row, labels), + count: null, + row, + }); + for (const id of ancestorIds(layer, path)) { + const node = folders.get(id); + if (node) node.count = (node.count ?? 0) + 1; + } } - const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => { - const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({ - id: `${layer}|${kind}|${cat}`, - label: cat, - count: leaves.length, - children: leaves.map((row) => ({ - id: rowId(row), - label: itemName(row), - tip: rowTip(row, labels), - count: null, - row, - })), - })); - return { - id: `${layer}|${kind}`, - label: kind, - count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0), - children: catNodes, - }; - }); - return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes }; + return root; }); } +/** 잎까지 가는 길의 나무 줄 id(그룹 · 폴더) */ +const ancestorIds = (layer: PickerLayer, path: string[]): string[] => [ + layer, + ...path.map((_, index) => folderId(layer, path.slice(0, index + 1))), +]; + /** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */ -export function pathIds(row: PickerRow, labels: TreeLabels): string[] { - const layer = layerOf(row); - const kind = shown(row.종류) || labels.unclassified; - const cat = shown(row.분류1) || labels.unclassified; - return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`]; -} +export const pathIds = (row: PickerRow, _labels?: TreeLabels): string[] => + ancestorIds(layerOf(row), pathOf(row).slice(0, -1)); export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice"; diff --git a/ui_template/ui_template_tree.css b/ui_template/ui_template_tree.css index a8a47253..18c80691 100644 --- a/ui_template/ui_template_tree.css +++ b/ui_template/ui_template_tree.css @@ -21,3 +21,38 @@ color: var(--color-text-secondary); font-size: var(--text-caption); } + +/* 오른쪽 클릭 메뉴 — 모달(z-index 1000) 위 */ +.ui-tree__menu { + position: fixed; + z-index: 1100; + display: flex; + flex-direction: column; + min-width: 10rem; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ui-tree__menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-md); + background: none; + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.ui-tree__menu-item:hover:not(:disabled), +.ui-tree__menu-item:focus-visible { + background: var(--color-surface-sunken); +} + +.ui-tree__menu-item:disabled { + color: var(--color-text-secondary); + cursor: not-allowed; +} diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts index b33cbc3b..42eb0c19 100644 --- a/ui_template/ui_template_tree.ts +++ b/ui_template/ui_template_tree.ts @@ -2,6 +2,8 @@ * ui_template_tree.ts * 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀. * 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`). + * 오른쪽 클릭 메뉴 = 줄마다 `menu()` 가 **메뉴 항목을 밖에서 줌**(고르기 모달 · 관리 화면 나무가 같은 자리를 씀 · + * 26-3) — 항목이 없으면 브라우저 기본 메뉴 그대로. * ========================================================================== */ import "./ui_template_tree.css"; @@ -19,6 +21,60 @@ export interface 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 은 부르는 쪽이 이음 */ @@ -72,5 +128,11 @@ export function createTreeRow( 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; }