From e3266a4eaa7b9e694e480281cc0d780c55bdb440 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 20:35:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(table):=20B08=20=C2=B7=20B09=20=ED=91=9C?= =?UTF-8?q?=20=EC=88=98=EA=B8=B0=20=EC=9E=85=EB=A0=A5=20=3D=20=EB=8D=94?= =?UTF-8?q?=EB=B8=94=ED=81=B4=EB=A6=AD=20=C2=B7=20Enter=20=C2=B7=20F2=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EC=B9=A0=20=EC=B9=B8=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EB=8A=98=20=ED=91=9C=EC=8B=9C=20(243-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 표: 고칠 수 있는 칸(editOnDouble) td 에 is-can-edit 입력 칸 테두리 · 칸 누른 뒤 Enter · F2 = 그 칸 입력(keyEdit) - B09 Shell 표 만드는 길 한 곳 = editOnDouble 기본 · 설계내역서는 레벨 키 몫 - B08 자재 집계표 셋 = editOnDouble - 새 시험 test_243_1 · 235_1 Shell 배선 기대값 갱신 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- B08_Quantity/B08_Quantity_UI_Material.ts | 1 + B09_Estimation/B09_Estimation_UI_Shell.ts | 4 +- resources/tester/test_235_1_b09_bill_tree.py | 2 +- .../tester/test_243_1_double_click_tables.py | 136 ++++++++++++++++++ ui_template/ui_template_data_table.css | 6 + ui_template/ui_template_data_table.ts | 25 +++- 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_243_1_double_click_tables.py diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 819d20049..849d7b6ea 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -367,6 +367,7 @@ export async function renderMaterialSheet( caption: BASIS, fillViewport: true, editable: (i, key) => built.editable(i, key), + editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 onEdit: (i, key, text) => { const value = parse(text); if (value !== undefined) { diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1701a2517..798d2e40a 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -204,7 +204,9 @@ export function renderB09Estimation(root: HTMLElement): void { empty: "", caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, - editOnDouble: spec.editOnDouble, + // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) + editOnDouble: spec.editOnDouble ?? true, + keyEdit: !(spec as BillTable).menu, onEdit: spec.onEdit, rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py index a7e94117d..46b549af0 100644 --- a/resources/tester/test_235_1_b09_bill_tree.py +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -345,7 +345,7 @@ def test_고른_줄_상자_테두리_테마_토큰(): def test_Shell_배선(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "editOnDouble: spec.editOnDouble," in shell + assert "editOnDouble: spec.editOnDouble ?? true," in shell # 243-1 기본 = 더블클릭 assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell for name in [ "ui_template/ui_template_data_table.ts", diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py new file mode 100644 index 000000000..08ea4367a --- /dev/null +++ b/resources/tester/test_243_1_double_click_tables.py @@ -0,0 +1,136 @@ +"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 늘 보임. + +- 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자 +- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(입력 칸 테두리) · 못 고치는 칸 · 옛 방식 표 = 없음 +- B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble +- 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로 +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import _node # noqa: E402 +from test_235_1_b09_bill_tree import DOM_JS, TABLE # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +HARNESS = DOM_JS.split("const columns")[0] + +SCENE_JS = """ +const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }]; +const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }]; +const can = (html) => [...html.matchAll(/]*>([^<]*) m[1].includes("is-can-edit")).map((m) => m[2]); +const log = []; +const make = (extra = {}) => createDataTable({ columns, rows, editOnDouble: true, + editable: (i, k) => i === 1 && k === "qty", onCellClick: (i, k) => log.push(`click ${i}:${k}`), + onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`), ...extra }); +const key = (r, k, target, more = {}) => r.fire("keydown", { key: k, target, isComposing: false, ...more, + preventDefault() {}, stopPropagation() {} }); +const out = {}; +const t = make(); +const r = t.root; +out.can = can(r.html); +out.start = inputs(r); +r.fire("click", { target: target(r, 1, 1) }); +out.click = [inputs(r), [...log]]; +key(r, "F2", r); +out.f2 = inputs(r); +const typed = doc.activeElement; +doc.activeElement = r; // 초점 떠남 +r.fire("change", { target: typed }); +r.fire("focusout", { target: typed }); +const enter = () => { + r.fire("click", { target: target(r, 1, 1) }); + key(r, "Enter", r); +}; +setTimeout(() => { + out.afterEnd = inputs(r); + enter(); + out.enter = inputs(r); + const input = doc.activeElement; + key(r, "Enter", input, { isComposing: true }); + out.composing = doc.activeElement === input; + key(r, "Enter", input); + out.ends = doc.activeElement === r; + r.fire("focusout", { target: input }); + setTimeout(() => { + r.fire("click", { target: target(r, 0, 0) }); + key(r, "F2", r); + out.noEditCell = inputs(r); + const off = make({ keyEdit: false }); + off.root.fire("click", { target: target(off.root, 1, 1) }); + key(off.root, "F2", off.root); + out.keyEditOff = inputs(off.root); + off.root.fire("dblclick", { target: target(off.root, 1, 1) }); + out.keyEditOffDbl = inputs(off.root); + const old = createDataTable({ columns, rows, editable: (i, k) => i === 1 && k === "qty" }); + out.old = [inputs(old.root), can(old.root.html)]; + console.log(JSON.stringify(out)); + }, 5); +}, 5); +""" + + +def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리(): + out = json.loads(_node(TABLE + HARNESS + SCENE_JS)) + assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시 + assert out["start"] == [] # 처음 = 글 상자 없음 + assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로 + assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력 + assert out["afterEnd"] == [] # 초점 떠남 = 끝 + assert out["enter"] == ["1:qty"] # Enter = 누른 칸 입력 + assert out["composing"] is True # 한글 조합 중 Enter = 조합 끝(입력 유지) + assert out["ends"] is True # 조합 뒤 Enter = 입력 끝 + assert out["noEditCell"] == [] # 못 고치는 칸 = 안 열림 + assert out["keyEditOff"] == [] # keyEdit false = 표가 키를 안 받음 + assert out["keyEditOffDbl"] == ["1:qty"] # 더블클릭은 그대로 + assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음) + + +def test_고칠_칸_테두리_CSS_테마_변수만(): + css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8") + m = re.search(r"\.ui-data-table__table td\.is-can-edit \{([^}]*)\}", css) + assert m + body = m.group(1) + # 글 상자와 같은 색(accent 60% · canvas) — 줄 고름 box-shadow 와 안 겹치게 outline 안쪽 + assert ( + "outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));" + in body + ) + assert "outline-offset: -1px;" in body + assert "#" not in body + + +def _sources(*folders: str) -> list[Path]: + return [p for f in folders for p in sorted((ROOT / f).glob("*.ts"))] + + +def test_B09_표_만드는_길_한_곳_더블클릭_기본(): + calls = [ + p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") + ] + assert calls == ["B09_Estimation_UI_Shell.ts"] + shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in shell + assert "keyEdit: !(spec as BillTable).menu," in shell # 설계내역서 Enter · F2 = 레벨 키 몫 + + +def test_B08_고칠_칸_있는_표_모두_editOnDouble(): + seen = 0 + for p in _sources("B08_Quantity"): + text = p.read_text("utf-8") + for call in re.split(r"createDataTable\(\{", text)[1:]: + block = call.split("\n });")[0] + if "editable:" in block: + seen += 1 + assert "editOnDouble: true" in block, p.name + assert seen == 1 # 자재 집계표 셋이 한 표 만드는 길(Material)을 같이 씀 + + +def test_고르기_메뉴_이동_그대로(): + shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));" in shell + assert "spec.choices?.(Number(cell.parentElement?.dataset.i), column.key)" in shell diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 6cc109a0f..cc6dbc72d 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -176,6 +176,12 @@ text-align: right; } +/* 고칠 수 있는 칸(PLAN 243-1 · editOnDouble) — 글일 때도 같은 입력 칸 테두리(outline 안쪽 · 줄 고름 box-shadow 와 안 겹침) */ +.ui-data-table__table td.is-can-edit { + outline: 1px solid color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas)); + outline-offset: -1px; +} + .ui-data-table__input:focus { outline: 2px solid var(--color-focus-ring); outline-offset: -2px; diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 8cd212f94..ff90e7b12 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -17,6 +17,8 @@ * 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴) * 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 · * Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기 + * 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 243-1) · + * 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(keyEdit false = 표 밖이 맡음) * 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만 * 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c) * ========================================================================== */ @@ -84,6 +86,8 @@ export interface DataTableOptions { }; /** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */ editOnDouble?: boolean; + /** 칸 누른 뒤 표에서 Enter · F2 = 그 칸 입력 시작(editOnDouble · 기본 참 · 거짓 = 표 밖이 키를 맡음) */ + keyEdit?: boolean; /** 지금 글 상자인 칸(editOnDouble · 표가 정함) */ editing?: { index: number; key: string } | null; /** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */ @@ -180,7 +184,7 @@ function rowHtml( more: RowMore = {}, ): string { let tr = ""; - const { lv, spans } = more; + const { lv, spans, can } = more; for (let j = 0; j < cols.length; j++) { const key = cols[j].key; const span = spans?.[j] ?? 1; @@ -206,6 +210,7 @@ function rowHtml( frz && left[j + 1] == null && "is-edge", empty && "is-empty", cellClass?.(key), + can?.(key) && "is-can-edit", lvc.trim(), span > 1 && "is-merged", ] @@ -238,6 +243,8 @@ function rowHtml( interface RowMore { lv?: { key: string; n: number; node: boolean; tree?: string }; spans?: number[]; + /** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시) */ + can?: (key: string) => boolean; } /** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */ @@ -353,6 +360,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin { ...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}), ...(spans ? { spans: spans[i - from] } : {}), + ...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}), }, ); } @@ -478,10 +486,12 @@ export function createDataTable(opts: DataTableOptions): DataTable { return [Number(row.dataset.i), column?.key ?? ""]; }; let lastKey = ""; + let lastRow: number | null = null; // 마지막 누른 줄 — Enter · F2 입력 시작 자리 root.addEventListener("click", (event) => { const at = cellAt(event.target); if (!at) return; lastKey = at[1]; + lastRow = at[0]; if (at[1]) state.onCellClick?.(at[0], at[1]); state.onRowClick?.(at[0]); }); @@ -505,9 +515,18 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]); }); // Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지) + // 표에 초점이 있을 때 Enter · F2 = 누른 칸 입력 시작(keyEdit) root.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || event.isComposing) return; - if (!(event.target instanceof HTMLInputElement)) return; + if ((event.key !== "Enter" && event.key !== "F2") || event.isComposing) return; + if (!(event.target instanceof HTMLInputElement)) { + if (opts.keyEdit === false || event.target !== root || lastRow === null) return; + if (state.editable?.(lastRow, lastKey)) { + event.preventDefault(); + edit(lastRow, lastKey); + } + return; + } + if (event.key !== "Enter") return; event.preventDefault(); event.stopPropagation(); root.focus();