fix(b09): 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나 · 줄 누름 · 방향키 · Esc = 빨강 풂 (242-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-10 20:31:43 +09:00
co-authored by Claude Opus 5.5
parent 4027005193
commit 5c1035a5e8
6 changed files with 179 additions and 17 deletions
+5 -7
View File
@@ -52,7 +52,7 @@ import {
} from "./B09_Estimation_UI_Tabs_Base";
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
import {
buildSumExpense,
buildSumLabor,
@@ -262,19 +262,17 @@ export function renderB09Estimation(root: HTMLElement): void {
);
}
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
// 239-1 이동해 온 줄 = 빨강 외곽선(목록표 → 탭 길 is-pick · 고른 줄 상자와 따로)
// 239-1 · 242-1 이동해 온 줄 = 그 줄 + 자손 빨강 외곽선(is-pick) · 고른 줄 상자와 한때 하나(오면 고름 풂)
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
const jump: [number, number] | null = at >= 0 ? [at, at] : null;
if (bill) jumpTo(bill.rows, at);
const table = makeTable(
bill
? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)].filter(Boolean).join(" ") || null
: undefined,
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined,
);
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
if (level) bars.setTools(level.tools);
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강)
};
const apply = (next: Row, fromServer: boolean): void => {
@@ -10,6 +10,8 @@
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
* 242-1 이동해 온 범위(총괄 등 → 그 줄 + 자손 · 빨강 is-pick) = 고름과 한때 하나 — 오면 고름 풂 ·
* 줄 누름 · 방향키 · Esc · 끌어 놓음 = 빨강 풂
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -29,6 +31,8 @@ const ACTS: [string, string][] = [
const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력";
let picked: string | null = null;
/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */
let jump: [number, number] | null = null;
/** 지금 그린 도구 줄 — 키가 부름 */
let live: {
root: HTMLElement;
@@ -44,6 +48,23 @@ let keysOn = false;
export const pickedClass = (row: DataRow | undefined): string | null =>
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
/** 그 줄 + 자손(뒤 줄 중 레벨이 더 깊은 연속 줄) 끝 줄 번호 */
export function subtreeEnd(rows: DataRow[], at: number): number {
const lv = Number(rows[at]?.level ?? 1);
let end = at;
while (end + 1 < rows.length && Number(rows[end + 1]?.level ?? 1) > lv) end++;
return end;
}
/** 242-1 이동해 온 줄(-1 = 없음) — 범위 = 그 줄 + 자손 · 오면 고름 풂(Shell 이 표 그리기 전에) */
export function jumpTo(rows: DataRow[], at: number): void {
jump = at >= 0 ? [at, subtreeEnd(rows, at)] : null;
if (jump) picked = null;
}
/** 지금 빨강 범위 — Shell 줄 class 곁값(pickClass) */
export const jumpRange = (): [number, number] | null => jump;
/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */
export function menuActs(table: BillTable, index: number): Map<string, () => void> {
const items = index >= 0 && table.menu ? table.menu(index) : [];
@@ -72,7 +93,7 @@ function listenKeys(): void {
if (keysOn) return;
keysOn = true;
document.addEventListener("keydown", (event) => {
if (!live?.root.isConnected || picked === null) return;
if (!live?.root.isConnected || (picked === null && !jump)) return;
const at = document.activeElement;
if (
at &&
@@ -81,6 +102,12 @@ function listenKeys(): void {
!live.tools.includes(at as HTMLElement)
)
return;
// 242-1 빨강 중 = 방향키 · Esc 가 빨강만 풂(고른 줄 없음)
if (picked === null) {
if (event.key !== "Escape" && !keyAct(event, editing(at))) return;
jump = null;
return live.sync();
}
// Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂
if (event.key === "Escape") {
if (!editing(at)) {
@@ -130,6 +157,9 @@ export function levelBar(
const acts = menuActs(table, index());
ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label)));
for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus");
if (!jump)
for (const tr of root.querySelectorAll("tr.is-pick"))
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end");
root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus");
};
root.addEventListener("click", (event) => {
@@ -137,9 +167,13 @@ export function levelBar(
const row = tr ? table.rows[Number(tr.dataset.i)] : undefined;
if (!row) return;
picked = String(row.key);
jump = null;
sync();
});
attachDrag(root, table, (key) => (picked = key));
attachDrag(root, table, (key) => {
picked = key;
jump = null;
});
sync();
live = { root, tools: buttons, run, sync, edit, index };
listenKeys();
@@ -197,8 +197,8 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표():
def test_셸_배선():
src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert (
"? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src
) # 239-1 이동 줄 빨강 곁
"bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
@@ -107,8 +107,8 @@ def test_누름_설계내역서_그_줄_빨강():
assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로)
assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None]
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell
assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell
assert "if (bill) jumpTo(bill.rows, at);" in shell # 242-1 범위 = 그 줄 + 자손
assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell
def test_설계내역서_없으면_원가계산_줄_그대로():
@@ -0,0 +1,130 @@
"""242-1 B09 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나.
- 이동해 온 줄(Shell show focus) = jumpTo — 범위 = 그 줄 + 뒤 줄 중 레벨이 더 깊은 연속 줄 ·
줄 class = is-pick(첫 줄 is-pick-top · 끝 줄 is-pick-end) · 바깥 테두리 한 벌(181-2 CSS)
- 오면 고른 줄(is-focus) 풂 · 공용 표 focusRow(at, false) = 스크롤만(공용 강조 안 붙임)
- 설계내역서에서 줄 누름(범위 안 · 밖) · 방향키 · Esc · 끌어 놓음 = 빨강 풂 · 누른 줄만 고름 상자
- 표 · 문서 = 가짜 DOM(200-2 시험 틀) · 줄 class = Shell 과 같은 곁값(pickedClass ?? pickClass)
"""
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 ROWS, _node, _ts # noqa: E402
from test_200_2_b09_bill_level_bar import BAR_JS, SRC # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
B09 = ROOT / "B09_Estimation"
GROUPS = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8")
PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0)
SETUP = (
"let focusAt = null;\n"
+ BAR_JS.split("const out = {};")[0]
# 가짜 DOM 보강 — class 여럿 지움 · tr.<class> 고르기
.replace(
"remove: (c) => s.delete(c) }",
"remove: (...c) => c.forEach((x) => s.delete(x)) }",
)
.replace(
'querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); }',
"querySelectorAll(sel) { const c = sel.match(/tr\\.([\\w-]+)/)[1];"
' return this.all().filter((n) => n.tagName === "TR" && n.cls.has(c)); }',
)
# Shell 차례 — jumpTo → 줄 그림(줄 class 곁값) → levelBar
.replace(
' root = new FakeEl("div");',
" jumpTo(table.rows, focusAt ? table.rows.findIndex((r) => r.key === focusAt) : -1);\n"
' root = new FakeEl("div");',
)
.replace(
"td.append(input); tr.append(td); root.append(tr);",
"td.append(input); tr.append(td); root.append(tr);"
" const c = pickedClass(r) ?? pickClass(jumpRange(), i); if (c) tr.className = c;",
)
)
for patch in ["remove: (...c)", "querySelectorAll(sel)", "jumpTo(table.rows", "jumpRange(), i)"]:
assert patch in SETUP, patch # 200-2 틀이 바뀌면 여기서 멈춤
RUN_JS = """
const marks = () => Object.fromEntries(root.kids.flatMap((tr, i) => {
const c = [...tr.cls].filter((x) => x.startsWith("is-")).sort().join(" ");
return c ? [[table.rows[i].key, c]] : [];
}));
const out = {};
const go = (k) => { focusAt = k; user = {}; draw(); focusAt = null; };
go("sec:1.2");
out.range = [jumpRange(), marks()];
out.ends = ["sec:1", "earth:4", "hand:g", "fee"].map((k) => subtreeEnd(table.rows, at(k)));
pick("earth:5");
out.inside = [jumpRange(), marks()];
go(null); pick("earth:4");
out.before = marks();
go("sec:1");
out.came = [jumpRange(), marks(), table.rows.map((r) => pickedClass(r)).filter(Boolean)];
pick("mat:M1");
out.outside = [jumpRange(), marks()];
go(null);
out.redraw = marks();
go("sec:1.2"); root.focus();
out.arrow = [key("ArrowDown"), jumpRange(), marks(), order()];
go("sec:4"); root.focus();
out.esc = [key("Escape"), jumpRange(), marks()];
go("sec:4"); root.focus();
out.other = [key("a"), jumpRange()];
console.log(JSON.stringify(out));
"""
PICKED = {"sec:1.2": "is-pick is-pick-top", "earth:4": "is-pick", "earth:5": "is-pick",
"hand:g": "is-pick", "hand:a": "is-pick", "hand:b": "is-pick is-pick-end"} # fmt: skip
def _run():
body = (SETUP + RUN_JS).replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False))
return json.loads(_node(_ts(SRC) + PICK + body))
def test_범위_그_줄_자손_전부_바깥_테두리_한_벌():
out = _run()
assert out["range"] == [[1, 6], PICKED]
# sec:1 = 레벨 1 아래 전부 · 항목 = 자기만 · 묶음 = 손 줄까지 · 끝 줄 = 자기
assert out["ends"] == [6, 2, 5, 11]
def test_표시_하나_총괄에서_오면_고름_풂():
out = _run()
assert out["before"] == {"earth:4": "is-focus"}
rng, marks, picked = out["came"]
assert rng == [0, 6] and picked == []
assert all("is-focus" not in c for c in marks.values())
assert marks["sec:1"] == "is-pick is-pick-top" and marks["hand:b"] == "is-pick is-pick-end"
def test_다른_줄_누름_빨강_풂_그_줄만_고름():
out = _run()
assert out["inside"] == [None, {"earth:5": "is-focus"}] # 범위 안 줄
assert out["outside"] == [None, {"mat:M1": "is-focus"}] # 범위 밖 줄
assert out["redraw"] == {"mat:M1": "is-focus"} # 다시 그려도 고름만
def test_방향키_Esc_빨강만_풂_다른_키_그대로():
out = _run()
assert out["arrow"] == [False, None, {}, []] # 줄 안 옮김(고른 줄 없음)
assert out["esc"] == [False, None, {}]
assert out["other"] == [False, [7, 8]]
def test_Shell_배선_공용_표_스크롤만():
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "if (bill) jumpTo(bill.rows, at);" in shell
assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell
assert "table.focusRow(at, !bill)" in shell
common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
assert "focusRow: (index: number, mark?: boolean) => void;" in common
assert "state.focus = mark ? index : null;" in common
level = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert 'tr.classList.remove("is-pick", "is-pick-top", "is-pick-end")' in level
+4 -4
View File
@@ -93,8 +93,8 @@ export interface DataTableOptions {
export interface DataTable {
root: HTMLElement;
setRows: (rows: DataRow[], total?: DataRow | null) => void;
/** 그 줄로 스크롤하고 강조 */
focusRow: (index: number) => void;
/** 그 줄로 스크롤하고 강조(mark = false → 스크롤만 · 강조 풂) */
focusRow: (index: number, mark?: boolean) => void;
/** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */
edit: (index: number, key?: string) => boolean;
}
@@ -534,8 +534,8 @@ export function createDataTable(opts: DataTableOptions): DataTable {
if (total !== undefined) state.total = total;
draw(true);
},
focusRow(index) {
state.focus = index;
focusRow(index, mark = true) {
state.focus = mark ? index : null;
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
draw(true);
},