Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -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());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 연결됨 — 연한 초록 배경 + 초록 테두리 · 글은 본문색 그대로(밝음 · 어두움 같은 토큰). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 # 돌아올 때
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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<ItemPickerResul
|
||||
// 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸)
|
||||
onCreated: (created) => 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<ItemPickerResul
|
||||
const hint = (text: string): HTMLElement =>
|
||||
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<ItemPickerResul
|
||||
onClick: () => {
|
||||
layer = one;
|
||||
paintTree();
|
||||
paintTable();
|
||||
},
|
||||
});
|
||||
button.setAttribute("aria-pressed", String(one === layer));
|
||||
@@ -297,6 +313,19 @@ export function openItemPicker(opts: ItemPickerOptions): Promise<ItemPickerResul
|
||||
treeHost.querySelector(".is-active")?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
/** 위 표에서 고른 열 → 그 항목을 나무에서 펼쳐 고름 · 카드도(같은 항목이면 그대로) */
|
||||
function pickColumn(colId: string): void {
|
||||
const base = tableRow();
|
||||
const row = base && rows.find((one) => 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) => {
|
||||
|
||||
@@ -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<void> {
|
||||
async function paint(
|
||||
layer: PickerLayer,
|
||||
owner: string | null,
|
||||
key: string,
|
||||
mine: number,
|
||||
): Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user