Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-10 14:18:39 +09:00
4 changed files with 405 additions and 2 deletions
+57
View File
@@ -104,6 +104,63 @@
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
}
/* 200-2 설계내역서 레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸 1em) · 글자 크기 한 벌(굵기 = 묶음 · 제목) · 글 칸 왼쪽 맞춤 */
.b09-estimation [data-b09-table="bill"] tbody tr {
--b09-lv: 0;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv2 {
--b09-lv: 1;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv3 {
--b09-lv: 2;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv4 {
--b09-lv: 3;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv5 {
--b09-lv: 4;
}
.b09-estimation [data-b09-table="bill"] tbody tr.is-lv6 {
--b09-lv: 5;
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td {
font-size: inherit;
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td:first-child {
padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);
}
.b09-estimation [data-b09-table="bill"] tbody tr[class*="is-lv"] td.is-edit:first-child {
padding-left: 0;
}
.b09-estimation [data-b09-table="bill"] tbody td:first-child .ui-data-table__input {
padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);
}
.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) {
text-align: left;
}
/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */
.b09-estimation__level {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.b09-estimation__level .b09-estimation__hint {
margin: 0;
}
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
--pick-t: inset 0 0 0 0 transparent;
+6 -2
View File
@@ -52,6 +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 {
buildSumExpense,
buildSumLabor,
@@ -249,8 +250,11 @@ export function renderB09Estimation(root: HTMLElement): void {
...renderGroupPanel({ tab, spec, hint, focus, goto, makeTable, redraw }),
);
}
const table = makeTable();
panel.replaceChildren(...(hint ? [hint] : []), table.root);
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
const level = bill ? levelBar(table.root, bill) : null;
panel.replaceChildren(...(hint ? [hint] : []), ...(level ? [level] : []), table.root);
if (focus !== undefined) {
const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
@@ -0,0 +1,129 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Level.ts
* B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄.
*
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import { createButton } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */
const ACTS: [string, string][] = [
["→ 들여쓰기", "들여쓰기 →"],
["← 내어쓰기", "내어쓰기 ←"],
["↑ 위로", "위로 ↑"],
["↓ 아래로", "아래로 ↓"],
];
const GUIDE = "레벨 — 줄을 누르고 단추 · 키 Tab 들여 · Shift+Tab 내어 · Alt+↑↓ 옮김";
const GUIDE_MORE =
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
"들여쓰기 = 바로 위 묶음 줄 아래로 · 내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
let picked: string | null = null;
/** 지금 그린 도구 줄 — 키가 부름 */
let live: {
root: HTMLElement;
bar: HTMLElement;
run: (label: string) => void;
sync: () => void;
} | null = null;
let keysOn = false;
/** 고른 줄 class — Shell 줄 class 곁값 */
export const pickedClass = (row: DataRow | undefined): string | null =>
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */
export function menuActs(table: BillTable, index: number): Map<string, () => void> {
const items = index >= 0 && table.menu ? table.menu(index) : [];
return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x)));
}
/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */
export function keyAct(
e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean },
editing: boolean,
): string | null {
if (editing || e.ctrlKey) return null;
if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1];
if (!e.altKey || e.shiftKey) return null;
return e.key === "ArrowUp" ? ACTS[2][1] : e.key === "ArrowDown" ? ACTS[3][1] : null;
}
const editing = (at: Element | null): boolean =>
/^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") ||
!!(at as HTMLElement | null)?.isContentEditable;
function listenKeys(): void {
if (keysOn) return;
keysOn = true;
document.addEventListener("keydown", (event) => {
if (!live?.root.isConnected || picked === null) return;
const at = document.activeElement;
if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return;
// Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂
if (event.key === "Escape") {
if (!editing(at)) {
picked = null;
return live.sync();
}
(at as HTMLElement).blur();
return live.root.focus();
}
const label = keyAct(event, editing(at));
if (!label) return;
event.preventDefault();
live.run(label);
});
}
/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */
export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
const index = (): number =>
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
const run = (label: string): void => menuActs(table, index()).get(label)?.();
const buttons = ACTS.map(([text, label]) => {
const button = createButton({
label: text,
variant: "ghost",
size: "sm",
onClick: () => run(label),
});
button.dataset.b09Level = label;
return button;
});
const guide = document.createElement("span");
guide.className = "b09-estimation__hint";
guide.textContent = GUIDE;
attachHoverInfo(guide, GUIDE_MORE);
const bar = document.createElement("div");
bar.className = "b09-estimation__level";
bar.append(...buttons, guide);
const sync = (): void => {
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");
root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus");
};
root.addEventListener("click", (event) => {
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
const row = tr ? table.rows[Number(tr.dataset.i)] : undefined;
if (!row) return;
picked = String(row.key);
sync();
});
sync();
live = { root, bar, run, sync };
listenKeys();
return bar;
}
@@ -0,0 +1,213 @@
"""200-2 B09 설계내역서 레벨 글 · 레벨 도구 줄 — 글 칸 왼쪽 맞춤 · 레벨 = 공종 · 명칭 칸 padding 들여쓰기 ·
글자 크기 한 벌 · 단추 넷(고른 줄 · 못 하면 disabled) · 키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음.
- 단추 · 키 = 우클릭 메뉴(billMenu) 같은 항목을 부름 — 줄 꼴 · 저장 꼴은 184-3 시험 그대로
- 표 · 문서 = 가짜 DOM(줄 tr · 글 칸 input) · 다시 그림 = buildBill + levelBar 다시
"""
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
ROOT = Path(__file__).resolve().parents[2]
B09 = ROOT / "B09_Estimation"
BAR_JS = """
class FakeEl {
constructor(tag) {
this.tagName = tag.toUpperCase(); this.kids = []; this.dataset = {}; this.cls = new Set();
this.on = {}; this.parent = null; this.isConnected = true; this.disabled = false; this.textContent = "";
}
set className(v) { this.cls = new Set(v.split(" ").filter(Boolean)); }
get classList() { const s = this.cls; return { add: (c) => s.add(c), remove: (c) => s.delete(c) }; }
append(...kids) { for (const k of kids) { k.parent = this; this.kids.push(k); } }
addEventListener(type, f) { (this.on[type] ??= []).push(f); }
fire(type, ev) { for (const f of this.on[type] ?? []) f(ev); }
contains(x) { for (let n = x; n; n = n.parent) if (n === this) return true; return false; }
all() { return this.kids.flatMap((k) => [k, ...k.all()]); }
closest() { for (let n = this; n; n = n.parent) if (n.tagName === "TR") return n; return null; }
querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); }
querySelector(sel) { const i = sel.match(/data-i="(-?\\d+)"/)[1]; return this.all().find((n) => n.dataset.i === i) ?? null; }
focus() { doc.activeElement = this; }
blur() { doc.activeElement = doc.body; }
}
const doc = new FakeEl("#document");
doc.body = new FakeEl("body");
doc.activeElement = doc.body;
doc.createElement = (t) => new FakeEl(t);
globalThis.document = doc;
const hovers = [];
function createButton(o) {
const b = new FakeEl("button");
b.label = o.label; b.variant = o.variant;
b.addEventListener("click", o.onClick);
return b;
}
function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); }
let user = {};
let table, root, bar;
const draw = () => {
table = buildBill({ sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
get user() { return user; }, setUser: (u) => (user = u) });
if (root) root.isConnected = false;
if (root?.contains(doc.activeElement)) doc.activeElement = doc.body; // 지운 요소 초점 = body
root = new FakeEl("div");
table.rows.forEach((r, i) => {
const tr = new FakeEl("tr"); tr.dataset.i = String(i);
const td = new FakeEl("td"); const input = new FakeEl("input");
td.append(input); tr.append(td); root.append(tr);
});
bar = levelBar(root, table);
};
const at = (key) => table.rows.findIndex((r) => r.key === key);
const tr = (key) => root.kids[at(key)];
const pick = (key) => root.fire("click", { target: tr(key).kids[0] });
const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON");
const off = () => buttons().map((b) => b.disabled);
const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]);
const key = (k, more = {}) => {
let stopped = false;
doc.fire("keydown", { key: k, shiftKey: false, altKey: false, ...more, preventDefault: () => (stopped = true) });
return stopped;
};
const out = {};
draw();
out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]);
out.guide = bar.kids.at(-1).textContent;
out.hover = hovers.length;
out.none = off();
pick("earth:4");
out.earth4 = off();
out.focus = [tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")]),
pickedClass(table.rows[at("earth:5")])];
buttons()[3].fire("click");
out.down = order();
user = {}; draw();
out.kept = [off(), tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")])];
root.focus();
out.tabStop = key("Tab");
out.tabNoop = order();
out.shiftTab = [key("Tab", { shiftKey: true }), order()];
user = {}; draw();
pick("earth:5");
root.focus();
out.altUp = [key("ArrowUp", { altKey: true }), order()];
user = {}; draw(); pick("earth:4");
out.altDown = [key("ArrowDown", { altKey: true }), order()];
user = {}; draw(); pick("earth:4");
doc.activeElement = tr("earth:4").kids[0].kids[0];
out.editing = [key("Tab", { shiftKey: true }), key("ArrowDown", { altKey: true }), order()];
out.esc = [key("Escape"), doc.activeElement === root];
out.afterEsc = [key("Tab", { shiftKey: true }), order().length > 0];
user = {}; draw(); pick("earth:4");
doc.activeElement = new FakeEl("input");
out.outside = [key("Tab", { shiftKey: true }), order()];
root.focus();
key("Escape");
out.unpick = [off(), tr("earth:4").cls.has("is-focus"), key("Tab")];
out.keyAct = [
keyAct({ key: "Tab", shiftKey: false, altKey: false }, false),
keyAct({ key: "Tab", shiftKey: true, altKey: false }, false),
keyAct({ key: "ArrowUp", shiftKey: false, altKey: true }, false),
keyAct({ key: "ArrowDown", shiftKey: false, altKey: true }, false),
keyAct({ key: "Tab", shiftKey: false, altKey: false }, true),
keyAct({ key: "ArrowUp", shiftKey: false, altKey: false }, false),
keyAct({ key: "Tab", shiftKey: false, altKey: false, ctrlKey: true }, false),
];
console.log(JSON.stringify(out));
"""
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"]
IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"
def _bar():
body = BAR_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False))
return json.loads(_node(_ts(SRC) + body))
def _keys(order):
return [k for k, _ in order]
def test_단추_넷_고른_줄_못_하면_흐림():
out = _bar()
assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"],
["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip
assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"]
assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히
assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림
# earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음)
assert out["earth4"] == [True, False, True, False]
assert out["focus"] == [True, "is-focus", None]
# [↓ 아래로] = 메뉴 「아래로 ↓」 와 같은 결과
keys = _keys(out["down"])
assert keys.index("earth:5") + 1 == keys.index("earth:4")
# 다시 그려도 고른 줄 · 단추 상태 남음
assert out["kept"] == [[True, False, True, False], True, "is-focus"]
def test_키_셋_편집_중엔_안_먹음():
out = _bar()
assert out["tabStop"] is True and out["tabNoop"] == [] # 못 하는 들여쓰기 = 바뀜 없음
stopped, order = out["shiftTab"]
assert stopped and ("earth:4", "sec:1") in [tuple(x) for x in order] # 내어쓰기
for name in ("altUp", "altDown"):
stopped, order = out[name]
keys = _keys(order)
assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name
# 글 칸 편집 중 = 키 안 먹음(기본 동작 그대로) · Esc = 편집 끝(표에 초점) → 키 먹음
assert out["editing"] == [False, False, []]
assert out["esc"] == [False, True]
assert out["afterEsc"] == [True, True]
# 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작
assert out["outside"] == [False, []]
assert out["unpick"] == [[True] * 4, False, False]
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None]
def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
bill = '.b09-estimation [data-b09-table="bill"]'
# 레벨마다 한 칸(1em) — 변수로 공종 · 명칭 칸(글 칸이면 글 상자) 왼쪽 padding
for lv in range(2, 7):
assert f"{bill} tbody tr.is-lv{lv} {{\n --b09-lv: {lv - 1};" in css.replace("\r", "")
pad = "padding-left: calc(var(--spacing-8) + var(--b09-lv) * 1em);"
assert re.search(
re.escape(f'{bill} tbody tr[class*="is-lv"] td:first-child {{') + r"\s*" + re.escape(pad),
css,
)
assert re.search(
re.escape(f"{bill} tbody td:first-child .ui-data-table__input {{")
+ r"\s*"
+ re.escape(pad),
css,
)
assert re.search(
re.escape(f'{bill} tbody tr[class*="is-lv"] td.is-edit:first-child {{')
+ r"\s*padding-left: 0;",
css,
)
# 글 칸 왼쪽 맞춤(수량만 오른쪽) · 글자 크기 = 표 한 벌(182 16/14/13px 덮음 · 굵기만)
assert re.search(
re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;",
css,
)
assert re.search(
re.escape(f'{bill} tbody tr[class*="is-lv"] td {{') + r"\s*font-size: inherit;", css
)
assert css.index("200-2 설계내역서 레벨") > css.index("182 설계내역서 레벨") # 뒤에 와서 덮음
assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css)
def test_셸_배선():
src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src
assert "...(level ? [level] : []), table.root" in src
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수