diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts index e0a1027ca..1f7b9dda4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts @@ -1,9 +1,9 @@ /* ============================================================================= * B05_Profile_UI_Structures_Summary_Row.ts - * 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [고르기] · [해제] (PLAN 21-4). + * 구조물 폼의 「집계 항목」 줄 — 이름(없으면 「미지정」) · [해제] (PLAN 21-4) · 이름 칸을 누르면 고르기(280-1). * * B05 · B06 · 관 계열 하위 폼이 같은 줄을 쓴다(옵션 · 서브폼 아래 한 줄). 줄은 값만 들고 있고 - * 저장은 패널이 한다 — [고르기]가 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다. + * 저장은 패널이 한다 — 이름 칸(누름 · Enter · Space)이 모달(21-5 `openItemPicker`)을 부르고 돌려받은 참조를 `set` 한다. * 키(SS…)는 앞에 내세우지 않고 툴팁에만 둔다(PLAN 21-0). * ========================================================================== */ @@ -18,29 +18,26 @@ export interface SummaryRow { get: () => SummaryItem | null; /** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */ set: (item: SummaryItem | null) => void; - /** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */ + /** 고르기(이름 칸) · [해제] 배선 — 패널이 한 번 부른다. */ bind: (handlers: { pick: () => void; clear: () => void }) => void; } +const PICK_TIP = "눌러서 고르기"; + export function buildSummaryRow(): SummaryRow { - // 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][선택][해제] + // 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][해제] const root = document.createElement("div"); root.className = "b05-route__field b05-structure__summary"; root.dataset.b05Summary = "row"; const label = document.createElement("span"); label.className = "b05-structure__summary-label"; label.textContent = "구조물 조합"; - // 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 + // 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 · 누르면 고르기(280-1) const name = document.createElement("input"); name.type = "text"; name.readOnly = true; - name.tabIndex = -1; name.className = "b05-structure__summary-name"; - const pick = document.createElement("button"); - pick.type = "button"; - pick.className = "b05-route__irregular-btn"; - pick.textContent = "선택"; - pick.dataset.b05Summary = "pick"; + name.dataset.b05Summary = "pick"; const clear = document.createElement("button"); clear.type = "button"; clear.className = "b05-route__irregular-btn"; @@ -48,14 +45,14 @@ export function buildSummaryRow(): SummaryRow { clear.dataset.b05Summary = "clear"; const line = document.createElement("div"); line.className = "b05-route__irregular-actions"; - line.append(name, pick, clear); + line.append(name, clear); root.append(label, line); let current: SummaryItem | null = null; const set = (item: SummaryItem | null): void => { current = item; name.value = summaryLabel(item); - name.title = summaryTooltip(item); + name.title = `${summaryTooltip(item)} · ${PICK_TIP}`; name.classList.toggle("is-assigned", item !== null); clear.disabled = item === null; }; @@ -66,7 +63,12 @@ export function buildSummaryRow(): SummaryRow { get: () => current, set, bind(handlers) { - pick.addEventListener("click", () => handlers.pick()); + name.addEventListener("click", () => handlers.pick()); + name.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handlers.pick(); + }); clear.addEventListener("click", () => handlers.clear()); }, }; diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 5f96903d1..126ed3331 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -436,7 +436,8 @@ } /* 구조물 조합 잇기 줄(PLAN 21-4 · 54-17) — 옵션 칸 꼴(`b05-route__field`) 2행 · 아래 줄은 - * [이름 칸][선택][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. */ + * [이름 칸][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. + * 이름 칸 = 고르기 단추 노릇(280-1) — 손 커서 · 키보드 초점 테두리. */ .b05-structure__summary[hidden] { display: none; } @@ -445,6 +446,12 @@ flex: 1 1 auto; align-self: center; text-overflow: ellipsis; + cursor: pointer; +} + +.b05-structure__summary-name:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; } /* 연결됨 — 연한 초록 배경 + 초록 테두리 · 글은 본문색 그대로(밝음 · 어두움 같은 토큰). */ diff --git a/resources/tester/helper_224_1_picker_top_table.cjs b/resources/tester/helper_224_1_picker_top_table.cjs index a7b1e700a..724e246b3 100644 --- a/resources/tester/helper_224_1_picker_top_table.cjs +++ b/resources/tester/helper_224_1_picker_top_table.cjs @@ -24,7 +24,9 @@ const node = (props = {}) => ({ const STUBS = { "./ui_template_elements": { el: (_tag, props) => node(props) }, - "./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") }, + "./ui_template_locale": { + t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중"), + }, "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": { readTemplate: async (...args) => { log.push(["readTemplate", ...args]); @@ -77,6 +79,10 @@ function loadTs(file) { const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); const row = (key, 열, layer, owner) => ({ key, 열, layer, owner }); const names = () => log.map((entry) => entry[0]); +// 280-2 — 표는 층(+ 주인) · 열을 받음 +const at = (r) => (r ? { layer: r.layer, owner: r.owner } : null); +const show = (table, r) => table.show(at(r), r ? r.열 : ""); +const picked = []; const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]); (async () => { @@ -87,13 +93,13 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent const sameLayer = row("SS2", "rv2", "personal", "1/3"); const master = row("SS9", "rv9", "system", null); - const table = createComboTable(); + const table = createComboTable((colId) => picked.push(colId)); out.startHidden = table.root.hidden; - table.show(null); + show(table, null); out.nullHidden = table.root.hidden; out.nullCalls = names(); - table.show(mine); + show(table, mine); out.shownHidden = table.root.hidden; await flush(); out.firstReads = log.filter((e) => e[0] !== "createSheet"); @@ -111,12 +117,12 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent }; log.length = 0; - table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만 + show(table, sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만 await flush(); out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed }; log.length = 0; - table.show(master); // 다른 층 — 그 층 표로 바뀜 + show(table, master); // 다른 층 — 그 층 표로 바뀜 await flush(); out.otherLayer = { reads: log.filter((e) => e[0] === "readTemplate"), @@ -127,21 +133,21 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent }; log.length = 0; - table.show(null); + show(table, null); out.hideAgain = table.root.hidden; // 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐 log.length = 0; - table.show(mine); - table.show(master); + show(table, mine); + show(table, master); await flush(); out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag }; // 번호 읽기 실패 — 표는 그려짐 log.length = 0; state.numbersFail = true; - const lone = createComboTable(); - lone.show(mine); + const lone = createComboTable((colId) => picked.push(colId)); + show(lone, mine); await flush(); out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers }; state.numbersFail = false; @@ -149,8 +155,8 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent // 표 읽기 실패 — 까닭 글 · 표 없음 log.length = 0; state.tableFail = "그 층은 아직 만든 것이 없음"; - const broken = createComboTable(); - broken.show(mine); + const broken = createComboTable((colId) => picked.push(colId)); + show(broken, mine); await flush(); out.tableFail = { creates: creates().length, text: broken.root.children[0].text }; @@ -160,6 +166,24 @@ const creates = () => log.filter((entry) => entry[0] === "createSheet").map((ent ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")), mine: data.tableKey(mine), }; + // 280-2 — 표에서 고른 열은 부른 쪽에 넘김 · 열 없는 고름(colId null)은 안 넘김 + state.tableFail = null; + const fresh = createComboTable((colId) => picked.push(colId)); + log.length = 0; + show(fresh, mine); + await flush(); + const sheetOpts = creates()[0].opts; + sheetOpts.onSelect({ colId: "rv2" }); + sheetOpts.onSelect({ colId: null }); + out.onColumn = picked.slice(-1); + // 층만 바꿈(고른 열 없음) — 그 층 표를 그리고 열은 안 고름 + log.length = 0; + fresh.show({ layer: "system", owner: null }, ""); + await flush(); + out.layerOnly = { + reads: log.filter((e) => e[0] === "readTemplate"), + selected: creates()[0].selected, + }; console.log(JSON.stringify(out)); })().catch((error) => { console.error(error); diff --git a/resources/tester/helper_280_1_combo_pick.cjs b/resources/tester/helper_280_1_combo_pick.cjs new file mode 100644 index 000000000..af16257b4 --- /dev/null +++ b/resources/tester/helper_280_1_combo_pick.cjs @@ -0,0 +1,264 @@ +/* 구조물 조합 선택 모달(PLAN 280-2) 시험 헬퍼 — `ui_template_item_picker.ts` 를 트랜스파일해 + * 화면 부품(요소 · 모달 · 나무 · 카드 · 위 표)을 대역으로 갈아 끼우고 돌림 · 자료 쪽(`_data`)은 진짜. + * 위 표 고름 → 나무(펼쳐 고름) · 오른쪽 카드 · 층 단추 → 위 표 = 그 층 — 결과는 표준출력에 JSON 한 벌. + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const UI = path.join(ROOT, "ui_template"); + +// ── 화면 그릇 흉내 — 클래스 · 자식 · 이벤트 · 숨김만 ───────────────────── +class FakeNode { + constructor(props = {}) { + this._classes = new Set( + String(props.className ?? "") + .split(/\s+/) + .filter(Boolean), + ); + this.textContent = props.text ?? ""; + this.children = [...(props.children ?? [])]; + this.hidden = !!(props.attrs && "hidden" in props.attrs); + this.disabled = false; + this.dataset = {}; + this.attrs = {}; + this.listeners = {}; + this.value = ""; + this.style = { setProperty() {} }; + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + } + append(...kids) { + this.children.push(...kids); + } + replaceChildren(...kids) { + this.children = kids; + } + setAttribute(name, value) { + this.attrs[name] = value; + } + getAttribute(name) { + return this.attrs[name]; + } + addEventListener(type, handler) { + (this.listeners[type] ||= []).push(handler); + } + click() { + (this.listeners.click || []).forEach((h) => h({ target: this })); + } + scrollIntoView() { + scrolled.push(this.dataset.id ?? ""); + } + all() { + return this.children.flatMap((c) => (c instanceof FakeNode ? [c, ...c.all()] : [])); + } + querySelector(selector) { + const name = selector.replace(/^\./, ""); + return this.all().find((n) => n.classList.contains(name)) ?? null; + } +} +const scrolled = []; + +const tableShows = []; +const detailShows = []; +let onColumn = null; +let modal = null; + +const button = ({ label, variant, onClick }) => { + const node = new FakeNode({ className: `ui-btn ui-btn--${variant ?? "filled"}`, text: label }); + if (onClick) node.addEventListener("click", onClick); + return node; +}; + +const STUBS = { + "./ui_template_item_picker.css": {}, + "@config/config_frontend": { API_BASE_URL: "/api" }, + "./ui_template_elements": { + el: (_tag, props) => new FakeNode(props), + createButton: button, + createInputField: () => { + const input = new FakeNode({ className: "ui-input" }); + return { root: new FakeNode({ children: [input] }), input }; + }, + showToast: () => {}, + }, + "./ui_template_locale": { t: (key) => key }, + "./ui_template_modal": { + openModal: (opts) => { + const body = new FakeNode({ className: "ui-modal__body" }); + modal = { title: opts.title, body }; + opts.mount(body); + return { close: () => {} }; + }, + }, + // 나무 — 펼친(`open`) 갈래만 아래 줄을 그림 = 보이는 줄만 생김 + "./ui_template_tree": { + renderTree: function render(nodes, make, depth = 0) { + return nodes.flatMap((node) => { + if (!node.children?.length) return [make(node, "", depth, () => node.run?.())]; + const row = make(node, node.open ? "▾" : "▸", depth, () => {}); + return [row, ...(node.open ? render(node.children, make, depth + 1) : [])]; + }); + }, + createTreeRow: (node, _caret, _depth, onClick) => { + const row = new FakeNode({ className: "ui-tree__row", text: node.label }); + row.dataset.id = node.id; + row.addEventListener("click", onClick); + return row; + }, + openTreeMenu: () => {}, + }, + "./ui_template_item_picker_ask": { askFields: async () => null }, + "./ui_template_item_picker_project": { projectItems: () => ({}) }, + "./ui_template_item_picker_detail": { + createItemDetail: () => ({ + root: new FakeNode({ className: "detail" }), + show: (row) => detailShows.push(row ? row.key : null), + markEdited: () => {}, + reload: () => {}, + }), + }, + "./ui_template_item_picker_edit": { createEditView: () => ({}) }, + "./ui_template_item_picker_table": { + createComboTable: (cb) => { + onColumn = cb; + return { + root: new FakeNode({ className: "table" }), + show: (at, column) => + tableShows.push(at ? `${at.layer}|${at.owner ?? ""}|${column}` : null), + destroy: () => {}, + }; + }, + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request in STUBS) return STUBS[request]; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const item = (key, 열, 경로) => ({ key, 열, 경로 }); +const GROUPS = [ + { + layer: "system", + owner: null, + items: [ + item("SS1", "rvS1", ["돌기슭막이", "메쌓기", "1.2m"]), + item("SS2", "rvS2", ["돌기슭막이", "찰쌓기", "1.5m"]), + ], + }, + { layer: "company", owner: "c1", items: [item("SC1", "rvC1", ["배수관", "흄관"])] }, + { + layer: "personal", + owner: "u1", + items: [item("SP1", "rvP1", ["옹벽", "기초"]), item("SP2", "rvP2", ["옹벽", "벽체", "1m"])], + }, +]; +globalThis.fetch = async () => ({ ok: true, json: async () => GROUPS }); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); + +(async () => { + const { openItemPicker } = loadTs(path.join(UI, "ui_template_item_picker.ts")); + const out = {}; + void openItemPicker({ current: null, canCopy: false }); + await flush(); + const [, cols] = modal.body.children; + const [search, layerBar, treeHost] = cols.children[0].children; + const searchInput = search.children[0]; + const active = () => treeHost.all().find((n) => n.classList.contains("is-active"))?.dataset.id; + const leaves = () => + treeHost + .all() + .filter((n) => n.dataset.id && n.dataset.id.split("|").length === 3) + .map((n) => n.dataset.id); + const layerButton = (key) => layerBar.children.find((b) => b.textContent === key); + const pressed = () => + layerBar.children + .filter((b) => b.getAttribute("aria-pressed") === "true") + .map((b) => b.textContent); + + out.title = modal.title; + out.start = { table: tableShows.at(-1), layer: pressed(), detail: detailShows.at(-1) }; + + // ① 위 표에서 열 고름(개인 층) → 나무 고름 · 펼쳐 보임 · 굴림 · 오른쪽 카드 · 표 열 + scrolled.length = 0; + onColumn("rvP2"); + out.tablePick = { + active: active(), + visible: leaves().includes("personal|u1|SP2"), + scrolled: scrolled.includes("personal|u1|SP2"), + detail: detailShows.at(-1), + table: tableShows.at(-1), + }; + // 같은 열 다시 · 표에 없는 열 = 아무것도 안 바뀜(표 → 나무 → 표 되돌이 없음) + const before = { detail: detailShows.length, table: tableShows.length }; + onColumn("rvP2"); + onColumn("rvNone"); + out.sameAgain = { + detail: detailShows.length - before.detail, + table: tableShows.length - before.table, + }; + + // ② 층 단추 → 위 표 = 그 층 표(고른 항목은 다른 층이라 열 없음) · 나무 = 그 층 + layerButton("M02_PickerSystem").click(); + out.layerSystem = { table: tableShows.at(-1), layer: pressed(), detail: detailShows.at(-1) }; + layerButton("M02_PickerCompany").click(); + out.layerCompany = { table: tableShows.at(-1), layer: pressed() }; + // 고른 항목의 층으로 돌아오면 그 열이 다시 골라짐 + layerButton("M02_PickerPersonal").click(); + out.layerBack = tableShows.at(-1); + + // ③ 다른 층 표에서 고름 — 접힌 폴더가 펼쳐지고 그 항목 · 카드 · 표 열 + layerButton("M02_PickerSystem").click(); + out.systemLeavesBefore = leaves(); + onColumn("rvS2"); + out.systemPick = { + active: active(), + visible: leaves().includes("system||SS2"), + detail: detailShows.at(-1), + table: tableShows.at(-1), + }; + + // ④ 나무 고름 → 위 표 열(지금 길 그대로) + onColumn("rvS1"); + const leaf = treeHost.all().find((n) => n.dataset.id === "system||SS2"); + leaf.click(); + out.treePick = { active: active(), detail: detailShows.at(-1), table: tableShows.at(-1) }; + + // ⑤ 찾기에 가려진 항목을 표에서 고르면 찾기를 풂 + searchInput.value = "찰쌓기"; + (searchInput.listeners.input || []).forEach((h) => h()); + onColumn("rvS1"); + out.searchCleared = { value: searchInput.value, active: active() }; + + console.log(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 1d65a9cc3..f295049b1 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -215,14 +215,15 @@ const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); const [labelNode, line] = row.root.children; -const [nameNode, pickBtn, clearBtn] = line.children; +// 280-1 — [선택] 단추 없음 · 이름 칸이 고르기 +const [nameNode, clearBtn] = line.children; out.rowEmpty = { name: nameNode.value, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, - pick: pickBtn.textContent, + buttons: line.children.filter((c) => c.tagName === "button").map((c) => c.textContent), clear: clearBtn.textContent, }; row.set(A); @@ -234,8 +235,15 @@ out.rowSet = { key: row.get().key, nameShowsKey: nameNode.value.includes("SS000007"), }; -pickBtn.click(); +nameNode.click(); clearBtn.click(); +// 이름 칸 키 — Enter · Space = 고르기 · 다른 키는 그대로 +const keys = []; +for (const key of ["Enter", " ", "a"]) + (nameNode.listeners.keydown || []).forEach((h) => + h({ key, preventDefault: () => keys.push(`prevent:${key}`) }), + ); +out.keyClicks = { clicks: clicks.slice(2), prevented: keys }; out.clicks = clicks; row.set(null); out.rowCleared = { name: nameNode.value, clearDisabled: clearBtn.disabled }; diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py index e01ed490e..e9258b32c 100644 --- a/resources/tester/test_224_1_picker_top_table.py +++ b/resources/tester/test_224_1_picker_top_table.py @@ -115,8 +115,8 @@ def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙 def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): ts = _text(UI / "ui_template_item_picker.ts") - assert "createComboTable()" in ts - assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다 + assert "createComboTable((colId) => pickColumn(colId))" in ts # 280-2 표 고름 → 나무 + assert "paintTable();" in ts # 고른 항목 · 층이 바뀔 때마다(280-2) assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 diff --git a/resources/tester/test_280_1_combo_label_pick.py b/resources/tester/test_280_1_combo_label_pick.py new file mode 100644 index 000000000..884dc06f4 --- /dev/null +++ b/resources/tester/test_280_1_combo_label_pick.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""PLAN 280 — B05 · B06 구조물조합 칸 · 「구조물 조합 선택」 모달. + +280-1 이름 칸 = 고르기 단추 노릇(누름 · Enter · Space · 손 커서 · 풍선 · 초점 테두리) · [선택] 없음 · [해제] 그대로 · + 본체 집계 항목 · 그룹 상자 · 명세 컨테이너 잇기가 같은 줄 부품. +280-2 모달 제목 · 위 표 고름 → 나무(펼쳐 굴림) · 오른쪽 카드 · 층 단추 → 위 표 = 그 층(Node · 대역). +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +UI = ROOT / "ui_template" +B05 = ROOT / "B05_Profile" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _node(helper: str) -> dict: + proc = subprocess.run( + ["node", str(HERE / helper)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def row(): + return _node("helper_b05_structures_form_summary.cjs") + + +@pytest.fixture(scope="module") +def picker(): + return _node("helper_280_1_combo_pick.cjs") + + +@pytest.fixture(scope="module") +def table(): + return _node("helper_224_1_picker_top_table.cjs") + + +# ── 280-1 ──────────────────────────────────────────────────────────────── + + +def test_이름_칸_누름_Enter_Space_고르기_선택_단추_없음_해제_있음(row): + assert row["rowEmpty"]["buttons"] == ["해제"] + assert row["clicks"][:2] == ["pick", "clear"] + assert row["keyClicks"] == { + "clicks": ["pick", "pick"], + "prevented": ["prevent:Enter", "prevent: "], + } + # 고른 뒤 · 해제 뒤 이름 칸 글 바뀜 그대로 + assert row["rowSet"]["name"] == "돌기슭막이 메쌓기 1.2m" + assert row["rowCleared"] == {"name": "미지정", "clearDisabled": True} + + +def test_이름_칸_풍선_초점_손_커서(): + src = _text(B05 / "B05_Profile_UI_Structures_Summary_Row.ts") + assert 'const PICK_TIP = "눌러서 고르기";' in src + assert "name.title = `${summaryTooltip(item)} · ${PICK_TIP}`;" in src + assert "name.tabIndex = -1;" not in src # Tab 으로 초점이 감 + assert '"선택"' not in src + css = _text(B05 / "B05_Profile_UI_Style_Structures.css") + base = re.search(r"\.b05-structure__summary-name \{([^}]*)\}", css).group(1) + assert "cursor: pointer;" in base + focus = re.search(r"\.b05-structure__summary-name:focus-visible \{([^}]*)\}", css).group(1) + assert "outline:" in focus + + +def test_같은_꼴_줄_모두_한_부품(): + # 본체 집계 항목 · 그룹 상자 · 명세 컨테이너 잇기 = 같은 `buildSummaryRow` + for path in ( + B05 / "B05_Profile_UI_Structures_Form.ts", + B05 / "B05_Profile_UI_Structures_Groups.ts", + ROOT / "A00_Common" / "design" / "design_structure_form.ts", + ): + assert "buildSummaryRow()" in _text(path), path.name + + +# ── 280-2 ──────────────────────────────────────────────────────────────── + + +def test_모달_제목(): + locale = _text(UI / "ui_template_locale_m2.ts") + assert 'M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"],' in locale + assert 'title: t("M02_PickerTitle"),' in _text(UI / "ui_template_item_picker.ts") + + +def test_처음_위_표_보이는_층_표(picker): + assert picker["start"] == { + "table": "personal|u1|", + "layer": ["M02_PickerPersonal"], + "detail": None, + } + + +def test_위_표_고름_나무_펼쳐_고름_오른쪽_카드(picker): + assert picker["tablePick"] == { + "active": "personal|u1|SP2", + "visible": True, + "scrolled": True, + "detail": "SP2", + "table": "personal|u1|rvP2", + } + # 같은 열 · 표에 없는 열 = 안 바뀜(표 → 나무 → 표 되돌이 없음) + assert picker["sameAgain"] == {"detail": 0, "table": 0} + # 접힌 폴더 속 항목도 펼쳐서 고름 + assert picker["systemLeavesBefore"] == [] + assert picker["systemPick"] == { + "active": "system||SS2", + "visible": True, + "detail": "SS2", + "table": "system||rvS2", + } + + +def test_층_단추_위_표_그_층(picker): + assert picker["layerSystem"] == { + "table": "system||", + "layer": ["M02_PickerSystem"], + "detail": "SP2", # 고른 항목은 그대로 + } + assert picker["layerCompany"] == {"table": "company|c1|", "layer": ["M02_PickerCompany"]} + assert picker["layerBack"] == "personal|u1|rvP2" # 고른 항목 층으로 오면 그 열 + + +def test_나무_고름_위_표_열_지금_길(picker): + assert picker["treePick"] == { + "active": "system||SS2", + "detail": "SS2", + "table": "system||rvS2", + } + + +def test_찾기에_가려진_항목_표에서_고르면_찾기_풂(picker): + assert picker["searchCleared"] == {"value": "", "active": "system||SS1"} + + +def test_위_표_열_고름은_부른_쪽에_층만_바꾸면_열_없이(table): + assert table["onColumn"] == ["rv2"] + assert table["layerOnly"]["reads"] == [ + ["readTemplate", "system", "table", "구조물집계표", None, None] + ] diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 3fadfc00f..303fe0132 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -84,11 +84,16 @@ def test_집계_항목_줄(out): True, None, ) - assert (empty["label"], empty["pick"], empty["clear"]) == ("구조물 조합", "선택", "해제") + # 280-1 — [선택] 단추 없음(이름 칸이 고르기) · [해제] 그대로 + assert (empty["label"], empty["buttons"], empty["clear"]) == ("구조물 조합", ["해제"], "해제") row = out["rowSet"] assert row["assigned"] is True and row["clearDisabled"] is False assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"] - assert out["clicks"] == ["pick", "clear"] + assert out["clicks"][:2] == ["pick", "clear"] # 이름 칸 누름 = 고르기 + assert out["keyClicks"] == { + "clicks": ["pick", "pick"], + "prevented": ["prevent:Enter", "prevent: "], + } assert out["rowCleared"] == {"name": "미지정", "clearDisabled": True} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 6eb58469e..3d6461087 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -10,6 +10,7 @@ * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). * 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐). * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. + * 280-2 — 위 표 · 나무 · 오른쪽 카드 = 한 상태: 위 표 = 보이는 층 표 · 표에서 열을 고르면 나무(펼쳐 굴림) · 카드도 그 항목. * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -24,6 +25,7 @@ import { ownerOf, pathIds, rowId, + tableKey, toSummary, type PickNode, type PickerLayer, @@ -148,7 +150,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise void load(created.key), }); - const comboTable = createComboTable(); + const comboTable = createComboTable((colId) => pickColumn(colId)); const cols = el("div", { className: "ui-item-picker__cols", children: [ @@ -179,9 +181,22 @@ export function openItemPicker(opts: ItemPickerOptions): Promise el("p", { className: "ui-item-picker__hint", text }); + /** 위 표의 기준 줄 — 고른 항목이 보이는 층이면 그것 · 아니면 그 층 아무 줄(주인을 앎) */ + const tableRow = (): PickerRow | undefined => + chosen && layerOf(chosen) === layer ? chosen : rows.find((row) => layerOf(row) === layer); + + /** 위 표 = 보이는 층(+ 주인) 표 · 고른 항목이 그 층이면 그 열 — 목록을 읽기 전엔 숨김 */ + const paintTable = (): void => { + const row = tableRow(); + comboTable.show( + rows.length ? { layer, owner: row ? ownerOf(row) : null } : null, + row === chosen && row ? row.열 : "", + ); + }; + const paintDetail = (): void => { detailView.show(chosen); - comboTable.show(chosen); + paintTable(); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -232,6 +247,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { layer = one; paintTree(); + paintTable(); }, }); button.setAttribute("aria-pressed", String(one === layer)); @@ -297,6 +313,19 @@ export function openItemPicker(opts: ItemPickerOptions): Promise tableKey(one) === tableKey(base) && one.열 === colId); + if (!row || (chosen && rowId(row) === rowId(chosen))) return; + chosen = row; + for (const id of pathIds(row)) opened.add(id); + if (!filterRows([row], search.input.value).length) search.input.value = ""; // 찾기에 가려지면 풂 + paintTree(); + paintDetail(); + treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" }); + } + search.input.addEventListener("input", paintTree); // 빈 자리 오른쪽 클릭 = 고른 층 맨 위 메뉴(개인만 「새 항목」 · 줄 위는 줄 메뉴) treeHost.addEventListener("contextmenu", (event) => { diff --git a/ui_template/ui_template_item_picker_table.ts b/ui_template/ui_template_item_picker_table.ts index 5d742be8a..f98011e17 100644 --- a/ui_template/ui_template_item_picker_table.ts +++ b/ui_template/ui_template_item_picker_table.ts @@ -2,8 +2,8 @@ * ui_template_item_picker_table.ts * 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품** * (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임. - * 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐. - * 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜. + * 보기 전용(`readOnly` · 고침은 M02 에서) · 보이는 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐(280-2). + * 같은 층이면 표는 그대로 두고 열만 옮김 · 층이 바뀌면 그 층 표로 바뀜 · 표에서 열을 고르면 `onColumn`. * 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임). * ========================================================================== */ @@ -12,14 +12,14 @@ import { fetchStructureNumbersAt, readTemplate, } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data"; +import type { PickerLayer } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet"; export interface ComboTable { root: HTMLElement; - /** 고른 항목 — `null` 이면 표를 숨김 */ - show: (row: PickerRow | null) => void; + /** 보일 층(+ 주인) · 고를 열(빈 글 = 안 고름) — `null` 이면 표를 숨김 */ + show: (at: { layer: PickerLayer; owner: string | null } | null, column: string) => void; destroy: () => void; } @@ -29,7 +29,8 @@ const failed = (error: unknown): string => error instanceof Error ? error.message : String(error), ); -export function createComboTable(): ComboTable { +/** `onColumn` = 표에서 고른 열 id(누르기 · 방향키) */ +export function createComboTable(onColumn: (colId: string) => void): ComboTable { const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } }); let seq = 0; let shownKey: string | null = null; @@ -43,14 +44,17 @@ export function createComboTable(): ComboTable { shownKey = null; }; - async function paint(row: PickerRow, key: string, mine: number): Promise { + async function paint( + layer: PickerLayer, + owner: string | null, + key: string, + mine: number, + ): Promise { drop(); root.replaceChildren( el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }), ); try { - const layer = layerOf(row); - const owner = ownerOf(row); const [{ createSheet }, struct] = await Promise.all([ import("./sheet/ui_template_sheet"), import("../M02_MasterTemplete/M02_MasterTemplete_Structure"), @@ -65,6 +69,7 @@ export function createComboTable(): ComboTable { readOnly: true, lockHead: true, combo: {}, + onSelect: (sel) => sel.colId && onColumn(sel.colId), ...struct.numberLabels(() => numbers), }); shownKey = key; @@ -77,14 +82,14 @@ export function createComboTable(): ComboTable { return { root, - show: (row) => { + show: (at, col) => { const mine = ++seq; - root.hidden = !row; - if (!row) return; - column = row.열; - const key = tableKey(row); + root.hidden = !at; + if (!at) return; + column = col; + const key = `${at.layer}|${at.owner ?? ""}`; if (sheet && key === shownKey) return sheet.selectColumn(column); - void paint(row, key, mine); + void paint(at.layer, at.owner, key, mine); }, destroy: () => { seq += 1; diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 31fc267ea..c568a2181 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -70,7 +70,7 @@ export const ui_locales_m2 = { "Register a company first to manage structure templates", ], M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"], - M02_PickerTitle: ["항목 고르기", "Pick an item"], + M02_PickerTitle: ["구조물 조합 선택", "Select structure combo"], M02_PickerSearch: ["항목 찾기", "Search items"], M02_PickerSystem: ["마스터", "Master"], M02_PickerCompany: ["회사", "Company"],