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 af6d31de2..78682d64f 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 99c6fde91..edc1d0bbb 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();
|