feat(B09): 설계내역서 입력 칸 테두리 되돌림 · 우클릭 「항목 추가」 하나 · 연결 이름 셋 · 「줄 삭제」 · 하위 메뉴 옆 판 (246-1 · 246-2)

- 공용 표 입력 칸 = 241 앞 모양(accent 5% 채움 · td is-can-edit 표시 지움 · 더블클릭 입력 그대로)
- 우클릭 더함 넷 → 「항목 추가」 — 항목 = 바로 아래 같은 부모 · 폴더 = 첫 하위 · 1단 = ← 내어쓰기(직접비 · 관급자재대 앞 · 자재 줄 막음)
- 연결 이름 = 구조물 조합 · 일위대가 사전조합 · 단가산출 · 들여오기 알림 글 쉬운 말 · 「줄 지우기」 → 「줄 삭제」 · 안내 글
- 공용 우클릭 메뉴 children = 「글 ▸」 한 줄 + 오른쪽 옆 판(올림 · 누름 · → 열기 · ← 닫기 · 화면 끝 = 왼쪽)
- 시험 기대값 고침(241_1 · 243_1 · 166_2 · 182 · 184_3 · 204_2) · 새 시험 246_1 · 246_2

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 21:28:09 +09:00
co-authored by Claude Opus 5.5
parent 2402497284
commit 8d50aa0582
15 changed files with 446 additions and 196 deletions
@@ -64,7 +64,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
total: { name: "순공사비 계(관급 제외)", ...direct },
frozen: 1,
hint:
"줄을 우클릭하면 추가 · 지움 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 「연결 ▾」로 일위대가 · 단가산출 연결" +
(direct.blank
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
: ""),
@@ -5,12 +5,14 @@
* 저장 = 사용자 값 `user.bill` v2(캐시 → [저장] 때 `user_values.json`) — 셈은 짝 `Calc_Bill` 이 바로 다시
* {v: 2, rows: [표 차례 {key · parent(없음 = 맨 위) · 고친 칸만}], gone: [지운 자동 줄 키]}
* 자동 줄 = 키 · 부모 + 고친 칸(name · spec · unit · qty · ref · null = 일부러 빈칸 · 없음 = 원천)
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위 묶음 · cost direct
* 새 줄 = hand:… · 모든 칸 + kind group(묶음) | 없음 = 항목 · 새 공종 = 맨 위로 내어 쓴 줄(묶음 · cost direct)
* 첫 손질 = 보이는 줄의 키 · 부모 · 차례만 v2 로 굳힘(옛 꼴 qty · 손 줄 칸은 옮김 · 자동 값은 안 옮김)
* 계약 = `Calc_Bill.py` 머리 — 셈 출력 줄 parent · auto(기본 로직 줄) · auto_{칸}(고친 칸의 자동 값) · level ·
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
* 바뀌는 옮김 · 자재 줄 삭제(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 삭제
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
* 더함(246) = 「항목 추가」 하나 — 누른 줄이 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위 ·
* 맨 위 1단(새 공종) = 레벨 단추 · ← 로 내어 씀(관급자재대 앞)
* 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 ·
* 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`)
* ========================================================================== */
@@ -33,7 +35,6 @@ export type BillTable = TabTable & {
};
const NEW_NAME = "새 줄";
const NEW_SECTION = "새 공종";
/** 고칠 칸 — 자동 줄은 덮개 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
/** 깊이 한도 — 1 = 맨 위 공종 */
@@ -242,26 +243,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
if (run) parts[part].push([label, run]);
};
// 더함 — 위 · 아래(같은 부모) · 하위(묶음) · 새 공종(맨 위 · 관급자재대 앞)
add(0, "위에 줄 추가", (w, e) => {
if (!e.parent) return false;
const at = find(w.bill.rows, key);
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, newItem(e.parent));
});
add(0, "아래에 줄 추가", (w, e) => {
if (!e.parent || key === "fee") return false;
w.bill.rows.splice(find(w.bill.rows, key) + 1, 0, newItem(e.parent));
});
add(0, "아래에 하위 줄 추가", (w) => {
if (!isGroup(w, key)) return false;
w.bill.rows.push(newItem(key));
});
add(0, "새 공종 추가(1단)", (w, e) => {
// 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모(조달수수료 = 관급 묶음 끝 · 셈이 그 뒤에 둠)
add(0, "항목 추가", (w, e) => {
const list = w.bill.rows;
const from = find(list, key) < 0 ? String(e.parent) : key;
const top = chain(list, from).at(-1)!;
const at = find(list, top.key) + (costOf(w, list, from) === "supply" ? 0 : 1);
list.splice(at, 0, { key: newKey(), kind: "group", cost: "direct", name: NEW_SECTION });
const at = find(list, key);
if (isGroup(w, key)) list.splice(at + 1, 0, newItem(key));
else if (!e.parent) return false;
else list.splice(at < 0 ? list.length : at + 1, 0, newItem(e.parent));
});
if (linkable(row)) {
const [picks, more] = linkFor(ctx, rows, i);
@@ -292,10 +280,18 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
});
add(2, "내어쓰기 ←", (w, e) => {
const up = e.parent ? entryOf(w, e.parent) : undefined;
if (!up?.parent) return false; // 맨 위로는 안 냄(새 공종 = 메뉴)
e.parent = up.parent;
if (!up || (!up.parent && !canHold(key))) return false; // 자재 줄 = 1단 안 됨
pull(w.bill.rows, key);
w.bill.rows.splice(find(w.bill.rows, up.key) + 1, 0, e);
if (up.parent) e.parent = up.parent;
else {
// 새 공종(1단) = 직접비 맨 위 · 손 줄은 묶음(자재 줄 돈 갈래 바뀜 = fits 가 막음)
delete e.parent;
e.cost = "direct";
if (!row.auto) Object.assign(e, { kind: "group", qty: null, ref: null });
}
// 맨 위로 = 관급자재대 앞 · 그 밖 = 옛 부모 바로 뒤
const top = !up.parent && costOf(w, w.bill.rows, up.key) === "supply";
w.bill.rows.splice(find(w.bill.rows, up.key) + (top ? 0 : 1), 0, e);
});
}
if (!row.auto && row.kind === "item")
@@ -315,7 +311,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
});
// 지움 — 자식은 지운 줄 부모로 · 자동 줄 = gone · 자식 있는 맨 위 묶음은 못 지움
if (!fixed(key) && !key.startsWith("mat:"))
add(3, "줄 지우기", (w, e) => {
add(3, "줄 삭제", (w, e) => {
const list = w.bill.rows;
if (!e.parent && list.some((c) => c.parent === key)) return false;
for (const c of list) if (c.parent === key) c.parent = e.parent;
@@ -100,7 +100,7 @@ async function importLib(
): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) return;
showToast("서고에서 들여오는 중…", "info");
showToast("가져오는 중…", "info");
try {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
method: "POST",
@@ -119,11 +119,11 @@ async function importLib(
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
showToast(
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함",
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "가져온 호표로 연결함",
"success",
);
} catch (error) {
showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error");
showToast(`못 가져옴 — ${error instanceof Error ? error.message : error}`, "error");
}
}
@@ -157,12 +157,9 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)],
["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)],
[
"단가산출 로직(M01 · 새 산근)",
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
],
["구조물 조합", () => pickCombo(ctx, apply)],
["일위대가 사전조합", () => pickPrecombo(ctx, apply)],
["단가산출", () => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름))],
];
return [picks, more];
}
+14 -14
View File
@@ -194,7 +194,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path):
assert qty["LB000033"] >= round(10 * 0.195, 2) # 석공 = 손 줄 수량 × 0.195(+ 낙석망)
def test_우클릭_메뉴_줄_추가_지우기_연결_TS():
def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS():
# 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘
src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"])
rows = [
@@ -219,24 +219,24 @@ const run = (i, label) => {{
return user.bill;
}};
const out = {{ title: labels(1), auto: labels(2), hand: labels(3) }};
out.above = run(2, "위에 줄 추가");
out.below = run(2, "항목 추가");
out.reset = run(2, "자동값으로 되돌리기");
out.left = run(3, "줄 지우기");
out.left = run(3, "줄 삭제");
console.log(JSON.stringify(out));
"""
out = json.loads(_node(src + body))
add = ["위에 줄 추가", "아래에 줄 추가"]
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
# 246-1 더함 = 「항목 추가」 하나 · 지움 = 「줄 삭제」 · 2단 제목 ← = 1단으로
assert out["title"] == ["항목 추가", "-", "내어쓰기 ←", "-", "줄 삭제"]
assert out["auto"] == ["항목 추가", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
"-", "자동값으로 되돌리기", "줄 삭제"] # fmt: skip
# 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음)
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
"들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
above = out["above"]
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
new, earth, hand = above["rows"][2:]
assert (new["name"], new["parent"], above["v"]) == ("새 줄", "sec:1.2", 2)
assert out["hand"] == ["항목 추가", "-", "연결", "연결 풀기", "-", "위로 ↑",
"들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip
# 항목 추가(항목 줄) = 바로 아래 같은 부모 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
below = out["below"]
assert [e["key"][:5] for e in below["rows"]] == ["sec:1", "sec:1", "earth", "hand:", "hand:"]
earth, new, hand = below["rows"][2:]
assert (new["name"], new["parent"], below["v"]) == ("새 줄", "sec:1.2", 2)
assert earth == {"key": "earth:4", "parent": "sec:1.2", "qty": 100}
assert (hand["key"], hand["qty"], hand["ref"]) == ("hand:b", 4, HAND[1]["ref"])
assert "qty" not in out["reset"]["rows"][2] # 자동값으로 = 수량 덮개 지움
+13 -12
View File
@@ -189,15 +189,15 @@ const out = {
g1: labels("hand:g1"), a: labels("hand:a"), g2: labels("hand:g2"),
b: labels("hand:b"), x: labels("hand:x"), auto: labels("earth:4"),
};
out.child = order(run("hand:g2", "아래에 하위 줄 추가"));
out.below = order(run("hand:g1", "아래에 줄 추가"));
out.belowB = order(run("hand:b", "아래에 줄 추가"));
out.child = order(run("hand:g2", "항목 추가"));
out.below = order(run("hand:g1", "항목 추가"));
out.belowB = order(run("hand:b", "항목 추가"));
out.indentBill = run("hand:x", "들여쓰기 →");
out.indent = order(out.indentBill);
out.outdent = order(run("hand:g2", "내어쓰기 ←"));
out.toItem = run("hand:g2", "항목으로 바꾸기").rows.filter((r) => ["hand:g2", "hand:b"].includes(r.key));
out.toGroup = run("hand:x", "묶음 줄로 바꾸기").rows.find((r) => r.key === "hand:x");
out.removed = order(run("hand:g1", "줄 지우기"));
out.removed = order(run("hand:g1", "줄 삭제"));
console.log(JSON.stringify(out));
"""
@@ -221,15 +221,16 @@ def test_우클릭_레벨_메뉴_TS():
out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body))
level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"}
pick = lambda key: [x for x in out[key] if x in level] # noqa: E731
assert out["g1"][:3] == ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가"]
assert [x for x in out["g1"] if "추가" in x] == ["항목 추가"] # 246-1 더함 하나
assert "연결" not in out["g1"] and pick("g1") == ["내어쓰기 ←", "항목으로 바꾸기"]
assert pick("a") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 없음 → 들여쓰기 없음
assert pick("g2") == ["내어쓰기 ←", "항목으로 바꾸기"] # 위 형제 a = 항목
assert pick("b") == ["내어쓰기 ←", "묶음 줄로 바꾸기"] # 레벨 5 묶음까지
assert pick("x") == ["들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기"] # 위 형제 g1
assert pick("auto") == ["내어쓰기 ←"] and "줄 지우기" in out["auto"] # 자동 줄도 지움
assert out["child"] == _put(BASE, 6, ["NEW", "hand:g2"]) # g2 마지막 자식 뒤
assert out["below"] == _put(BASE, 6, ["NEW", "sec:1.2"]) # g1 자손 끝 뒤 · 같은 부모
assert pick("auto") == ["내어쓰기 ←"] and "줄 삭제" in out["auto"] # 자동 줄도 지움
# 246-1 항목 추가 — 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모
assert out["child"] == _put(BASE, 4, ["NEW", "hand:g2"])
assert out["below"] == _put(BASE, 2, ["NEW", "hand:g1"])
assert out["belowB"] == _put(BASE, 5, ["NEW", "hand:g2"])
assert out["indent"] == [*BASE[:6], ["hand:x", "hand:g1"], BASE[7]] # g1 마지막 자식
assert out["outdent"] == [*BASE[:3], ["hand:g2", "sec:1.2"], *BASE[4:]] # g1 뒤
@@ -320,10 +321,10 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
picks = [
"일위대가 호표",
"단가산출 호표",
"서고 구조물조합(마스터 · 회사 · 개인)",
"서고 일위대가 사전조합(마스터 · 회사 · 개인)",
"단가산출 로직(M01 · 새 산근)",
] # 204-2
"구조물 조합",
"일위대가 사전조합",
"단가산출",
] # 204-2 · 246-1 이름 셋
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
+43 -48
View File
@@ -1,4 +1,4 @@
"""184-3 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 지움 → gone · 옮김 · 새 공종 ·
"""184-3 · 246-1 B09 설계내역서 자유 편집 화면 — 모든 줄 우클릭 메뉴 · 칸 고침 → 덮개 · 삭제 → gone · 옮김 · 새 공종(←) ·
자동값으로 · 관급 옮김 막힘 · 깊이 5 · 고친 칸 강조 · [내역 초기화] 배선.
- 셈 출력 줄 = 계약 꼴(`Calc_Bill.py` 머리 · parent · auto · auto_{칸} · level · no · cost) 가짜 줄
@@ -119,26 +119,26 @@ ORDER = [(r["key"], r["parent"]) for r in ROWS if r["key"] != "fee"]
def test_메뉴_모든_줄():
out = _menu({}, labels=["sec:1", "earth:4", "earth:5", "hand:g", "hand:a", "fee", "mat:M2",
"mat:M1", "sec:G"])["labels"] # fmt: skip
add = ["위에 줄 추가", "아래에 줄 추가"]
# 맨 위 제목 = 하위 줄 · 새 공종 · 아래로(자식 있는 맨 위 = 못 지움)
assert out["sec:1"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "아래로 ↓"]
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
add = ["항목 추가"] # 246-1 더함 하나
# 맨 위 제목 = 항목 추가(첫 하위) · 아래로(자식 있는 맨 위 = 못 지움 · 맨 위 = 내어쓰기 없음)
assert out["sec:1"] == [*add, "-", "아래로 ↓"]
# 자동 항목 = 더함 · 연결 · 옮김 · 삭제(고친 칸 없음 = 자동값으로 없음)
assert out["earth:4"] == [*add, "-", "연결", "연결 풀기", "-",
"아래로 ↓", "내어쓰기 ←", "-", "줄 삭제"] # fmt: skip
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
assert out["earth:5"] == [*add, "-", "연결", "-", "위로 ↑", "아래로 ↓",
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
"줄 지우기"] # fmt: skip
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
"아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-",
"줄 지우기"] # fmt: skip
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
assert out["fee"] == ["위에 줄 추가", "새 공종 추가(1단)"] # 조달수수료 = 관급자재대 끝 고정
assert out["mat:M2"] == [*add, "새 공종 추가(1단)"]
assert out["mat:M1"] == [*add, "새 공종 추가(1단)"]
assert out["sec:G"] == ["아래에 하위 줄 추가", "새 공종 추가(1단)"]
"줄 삭제"] # fmt: skip
assert out["hand:g"] == [*add, "-", "위로 ↑", "아래로 ↓", "들여쓰기 →", "내어쓰기 ←",
"항목으로 바꾸기", "-", "줄 삭제"] # fmt: skip
assert out["hand:a"] == [*add, "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
"묶음 줄로 바꾸기", "-", "줄 삭제"] # fmt: skip
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 삭제 없음 · 관급자재대 제목 = 옮김 · 삭제 없음 ·
# 자재 줄 = 1단으로 못 냄(별도 자재 mat:M1 도)
assert out["fee"] == add # 조달수수료 = 관급자재대 끝 고정
assert out["mat:M2"] == add
assert out["mat:M1"] == add
assert out["sec:G"] == add
def test_칸_고침_덮개_옛_꼴_굳힘():
@@ -166,38 +166,34 @@ V2 = {"bill": {"v": 2, "rows": [{"key": k, **({"parent": p} if p else {})} for k
if k != "hand:b"], "gone": []}} # fmt: skip
def test_지움_옮김_새_공종_자동값으로():
def test_삭제_옮김_새_공종_자동값으로():
user = {"bill": {**V2["bill"], "rows": [
{**e, **({"qty": 9} if e["key"] == "earth:5" else {})} for e in V2["bill"]["rows"]]}} # fmt: skip
runs = [("earth:4", "줄 지우기"), ("hand:g", "줄 지우기"), ("earth:5", "위로 ↑"),
("earth:4", "아래로 ↓"), ("earth:4", "새 공종 추가(1단)"),
("mat:M2", "새 공종 추가(1단)"), ("earth:5", "자동값으로 되돌리기"),
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "아래에 하위 줄 추가"),
("earth:4", "위에 줄 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "위에 줄 추가"),
("fee", "새 공종 추가(1단)")] # fmt: skip
runs = [("earth:4", "줄 삭제"), ("hand:g", "줄 삭제"), ("earth:5", "위로 ↑"),
("earth:4", "아래로 ↓"), ("sec:1.2", "내어쓰기 ←"),
("mat:M2", "내어쓰기 ←"), ("earth:5", "자동값으로 되돌리기"),
("hand:b", "들여쓰기 →"), ("hand:a", "내어쓰기 ←"), ("sec:1.2", "항목 추가"),
("earth:4", "항목 추가"), ("hand:a", "묶음 줄로 바꾸기"), ("hand:g", "항목으로 바꾸기"),
("earth:4", "연결 풀기"), ("earth:4", "내어쓰기 ←"), ("fee", "항목 추가")] # fmt: skip
out = _menu(user, runs=runs)["runs"]
keys = lambda b: [k for k, _ in _order(b)] # noqa: E731
# 덮개에 없는 보이는 줄(hand:b · 새 자동 줄) = 보이는 앞 줄 뒤에 굳힘
base = keys(out["earth:5|위로 ↑"])
assert "hand:b" in base and base.index("hand:b") == base.index("hand:a") + 1
# 지움 — 자동 줄 = gone · 새 줄 = 자식은 지운 줄 부모로
b = out["earth:4|줄 지우기"]
b = out["earth:4|줄 삭제"]
assert b["gone"] == ["earth:4"] and "earth:4" not in keys(b)
b = out["hand:g|줄 지우기"]
b = out["hand:g|줄 삭제"]
assert b["gone"] == [] and ("hand:a", "sec:1.2") in _order(b) and "hand:g" not in keys(b)
# 위로 · 아래로 = 같은 부모 형제와 자리 바꿈
for k in ("earth:5|위로 ↑", "earth:4|아래로 ↓"):
got = keys(out[k])
assert got[got.index("earth:5") + 1] == "earth:4"
# 새 공종 = 맨 위 묶음 · cost direct · 그 맨 위 공종 뒤 / 관급자재대 앞
b = out["earth:4|새 공종 추가(1단)"]
new = [e for e in b["rows"] if e["key"].startswith("hand:") and e.get("cost")][0]
assert (new["kind"], new["cost"], new["name"], "parent" in new) == (
"group", "direct", "새 공종", False) # fmt: skip
assert keys(b)[keys(b).index(new["key"]) + 1] == "sec:4"
b = out["mat:M2|새 공종 추가(1단)"]
assert keys(b)[keys(b).index("sec:G") - 1].startswith("hand:")
# 246-1 새 공종 = 2단을 ← 로 맨 위(cost direct · 옛 부모 바로 뒤) · 관급 자재 = 못 냄
b = out["sec:1.2|내어쓰기 ←"]
assert [k for k, p in _order(b) if p is None] == ["sec:1", "sec:1.2", "sec:4", "sec:G"]
assert {e["key"]: e for e in b["rows"]}["sec:1.2"]["cost"] == "direct"
assert out["mat:M2|내어쓰기 ←"] is None
# 자동값으로 = 고친 칸 지움(차례 · 부모 그대로)
b = out["earth:5|자동값으로 되돌리기"]
assert {e["key"]: e for e in b["rows"]}["earth:5"] == {"key": "earth:5", "parent": "sec:1.2"}
@@ -207,23 +203,22 @@ def test_지움_옮김_새_공종_자동값으로():
assert got[got.index(("hand:g", "sec:1.2")) + 1] == ("hand:a", "sec:1.2")
got = _order(out["earth:4|내어쓰기 ←"]) # 1.1 → 1 아래(제목 뒤)
assert got[got.index(("sec:1.2", "sec:1")) + 1 :].index(("earth:4", "sec:1")) >= 0
# 하위 줄 = 마지막 자식 뒤 · 위에 줄 = 같은 부모 앞 · 새 줄 = kind 없음(항목)
got = out["sec:1.2|아래에 하위 줄 추가"]["rows"]
# 246-1 항목 추가 — 폴더 = 첫 하위 · 항목 = 바로 아래 같은 부모 · 새 줄 = kind 없음(항목)
got = out["sec:1.2|항목 추가"]["rows"]
kids = [e for e in got if e.get("parent") == "sec:1.2"]
assert kids[-1]["name"] == "새 줄" and "kind" not in kids[-1]
got = out["earth:4|위에 줄 추가"]["rows"]
assert kids[0]["name"] == "새 줄" and "kind" not in kids[0]
got = out["earth:4|항목 추가"]["rows"]
at = [e["key"] for e in got].index("earth:4")
assert (got[at - 1]["name"], got[at - 1]["parent"]) == ("새 줄", "sec:1.2")
assert (got[at + 1]["name"], got[at + 1]["parent"]) == ("새 줄", "sec:1.2")
a = {e["key"]: e for e in out["hand:a|묶음 줄로 바꾸기"]["rows"]}["hand:a"]
assert (a["kind"], a["qty"], a["ref"]) == ("group", None, None)
got = {e["key"]: e for e in out["hand:g|항목으로 바꾸기"]["rows"]}
assert "kind" not in got["hand:g"] and got["hand:a"]["parent"] == "sec:1.2"
# 자동 줄 연결 풀기 = ref 덮개 null
assert {e["key"]: e for e in out["earth:4|연결 풀기"]["rows"]}["earth:4"]["ref"] is None
# 조달수수료 위 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠) · 새 공종 = 관급자재대 앞
got = out["fee|위에 줄 추가"]["rows"]
# 조달수수료 항목 추가 = 관급자재대 마지막 자식(조달수수료는 셈이 끝에 둠)
got = out["fee|항목 추가"]["rows"]
assert (got[-1]["name"], got[-1]["parent"], got[-2]["key"]) == ("새 줄", "sec:G", "mat:M2")
assert keys(out["fee|새 공종 추가(1단)"])[-3].startswith("hand:") # … 새 공종 · sec:G · mat:M2
def test_관급_옮김_막힘_깊이_5():
@@ -237,15 +232,15 @@ def test_관급_옮김_막힘_깊이_5():
out = _menu(user, labels=["sec:4", "hand:n"], rows=rows)["labels"]
assert "들여쓰기 →" not in out["sec:4"] # 별도 자재가 관급자재대 안으로 → 막음
assert "들여쓰기 →" in out["hand:n"] # 자재 없는 묶음 = 들여쓰기 됨(위 형제 4 자재대)
# 깊이 5 — 레벨 5 묶음에 하위 줄 · 레벨 5 줄 들여쓰기 = 막음
# 깊이 5 — 레벨 5 묶음에 항목 추가(첫 하위) · 레벨 5 줄 들여쓰기 = 막음
deep = [
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
] # fmt: skip
out = _menu({}, labels=["hand:g3", "hand:z", "hand:g2"], rows=deep)["labels"]
assert "아래에 하위 줄 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"]
assert "아래에 하위 줄 추가" in out["hand:g2"]
assert "항목 추가" not in out["hand:g3"] and "들여쓰기 →" not in out["hand:z"]
assert "항목 추가" in out["hand:g2"]
TABLE_JS = """
@@ -202,9 +202,9 @@ const applied = [];
const apply = (ref, from) => applied.push([ref, from && from.name]);
const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply);
const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) };
picks.find((x) => x[0].startsWith("서고 일위대가"))[1]();
picks.find((x) => x[0] === "일위대가 사전조합")[1]();
await new Promise((r) => setTimeout(r, 0));
picks.find((x) => x[0].startsWith("서고 구조물"))[1]();
picks.find((x) => x[0] === "구조물 조합")[1]();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
Object.assign(out, { sent, applied, payload: ctx.payload, toasts });
@@ -226,10 +226,10 @@ def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS():
assert out["picks"] == [
"일위대가 호표",
"단가산출 호표",
"서고 구조물조합(마스터 · 회사 · 개인)",
"서고 일위대가 사전조합(마스터 · 회사 · 개인)",
"단가산출 로직(M01 · 새 산근)",
]
"구조물 조합",
"일위대가 사전조합",
"단가산출",
] # 246-1 이름 셋(서고 · 층 · M01 글 없음)
assert out["more"] == ["연결 풀기"]
pre, (url, body) = out["sent"][0], out["sent"][1]
assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋
@@ -1,4 +1,4 @@
"""PLAN 241-1 · 123 — B08 · B09 표 입력 칸 = 채움 색 대신 칸 안쪽 테두리."""
"""PLAN 241-1 · 123 → 246-1 · 128 — B08 · B09 표 입력 칸 = 테두리 되돌림(241 앞 채움 색)."""
import re
from pathlib import Path
@@ -13,17 +13,13 @@ def _rule(css: str, selector: str) -> str:
return m.group(1)
def test_입력_칸_채움_색_없고_안쪽_테두리():
def test_입력_칸_채움_색_테두리_없음():
body = _rule(CSS.read_text(encoding="utf-8"), ".ui-data-table__input")
assert "background: transparent;" in body
assert "background: color-mix" not in body # 옛 accent 5% 채움 없음
# 241-2 두 테마 — 진한 accent 60% + 캔버스(밝음 · 어두움 모두 캔버스 대비 3:1 넘음 · 초점 2px 와 갈림)
assert (
"box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));"
in body
)
# 246-1 — 241 앞 모양(accent 5% 채움) · 안쪽 테두리 없음
assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body
assert "box-shadow" not in body
assert "#" not in body # 하드코딩 색 없음 — 테마 변수만
assert "border: 0;" in body # 테두리는 inset — 칸 크기 · 줄 높이 불변
assert "border: 0;" in body
assert "height: var(--dt-row-h);" in body
@@ -1,7 +1,7 @@
"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 늘 보임.
"""243-1 · 126 — B08 · B09 값 고칠 수 있는 표 전부 더블클릭 입력 · 고칠 칸 테두리 = 246-1 에서 지움.
- 공용 표 editOnDouble = 한 번 누름 = 글 상자 안 열림 · 더블클릭 · 칸 누른 뒤 Enter · F2 = 그 칸 글 상자
- 고칠 수 있는 칸(td) = 글일 때도 `is-can-edit`(입력 칸 테두리) · 못 고치는 칸 · 옛 방식 표 = 없음
- 고칠 수 있는 칸(td) 표시 `is-can-edit` 없음(246-1 · 241 앞 모양)
- B09 = 표 만드는 길 한 곳(Shell) · B08 = 고칠 칸 있는 표(자재 집계표 셋) 모두 editOnDouble
- 칸 누름으로 여는 메뉴 · 고르기 · 이동(onCellClick · choices · link)은 그대로
"""
@@ -74,9 +74,9 @@ setTimeout(() => {
"""
def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리():
def test_공용_표_더블클릭_Enter_F2():
out = json.loads(_node(TABLE + HARNESS + SCENE_JS))
assert out["can"] == ["3"] # 고칠 수 있는 칸만 · 글일 때도 표시
assert out["can"] == [] # 246-1 고칠 칸 td 표시 없음
assert out["start"] == [] # 처음 = 글 상자 없음
assert out["click"] == [[], ["click 1:qty"]] # 한 번 누름 = 안 열림 · 누름 알림은 그대로
assert out["f2"] == ["1:qty"] # F2 = 누른 칸 입력
@@ -90,18 +90,10 @@ def test_공용_표_더블클릭_Enter_F2_고칠_칸_테두리():
assert out["old"] == [["1:qty"], []] # editOnDouble 없는 표 = 예전대로(테두리 class 없음)
def test_고칠_칸_테두리_CSS_테마_변수만():
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
ts = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
assert "is-can-edit" not in css and "is-can-edit" not in ts # 246-1 되돌림
def _sources(*folders: str) -> list[Path]:
@@ -0,0 +1,82 @@
"""246-1 · 128 — B09 설계내역서 우클릭 메뉴 줄임 · 입력 칸 테두리 되돌림.
- 입력 칸 = 241 앞 모양(글 상자 accent 5% 채움 · td 표시 없음)
- 더함 = 「항목 추가」 하나 — 항목 = 바로 아래 같은 부모 · 폴더 = 그 폴더 첫 하위
- 맨 위 1단(새 공종) = ← 내어쓰기 — 직접비 · 손 줄은 묶음 · 관급자재대 앞 · 자재 줄은 못 냄
- 연결 이름 = 「구조물 조합」 · 「일위대가 사전조합」 · 「단가산출」 · 지움 = 「줄 삭제」
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_184_3_b09_bill_menu import DG, ROWS, _menu, _order, _row # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
OLD_ADDS = ["위에 줄 추가", "아래에 줄 추가", "아래에 하위 줄 추가", "새 공종 추가(1단)"]
def test_입력_칸_테두리_없음():
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
body = css.split(".ui-data-table__input {")[1].split("}")[0]
assert "background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));" in body
assert "box-shadow" not in body and "is-can-edit" not in css
def test_더함_하나_줄_삭제_연결_이름_셋():
keys = ["sec:1", "earth:4", "hand:g", "hand:a", "fee", "sec:G"]
out = _menu({}, labels=keys)["labels"]
for key in keys:
assert [x for x in out[key] if "추가" in x] == ["항목 추가"], key
assert not set(OLD_ADDS) & set(out[key]) and "줄 지우기" not in out[key]
assert "줄 삭제" in out["earth:4"] and "줄 삭제" in out["hand:a"]
link = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8")
picks = link.split("const picks: Leaf[] = [")[1].split("];")[0]
for name in ["일위대가 호표", "단가산출 호표", "구조물 조합", "일위대가 사전조합", "단가산출"]:
assert f'["{name}",' in picks
for word in ["서고", "마스터", "회사", "개인", "M01", "새 산근", "로직"]:
assert word not in picks
toasts = [part.split(",")[0] for part in link.split("showToast(")[1:]]
assert toasts and not any("서고" in t for t in toasts) # 알림 글 쉬운 말
bill = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8")
assert "항목 추가 · 줄 삭제" in bill # 표 아래 안내 글
#: 관급자재대 안 손 묶음(hand:s · 자재 없음) · 손 항목(hand:t)
SUPPLY = [*ROWS[:-1], _row("hand:s", "sec:G", 2, "group", cost="supply"),
_row("hand:t", "hand:s", 3, cost="supply", qty=1, ref=DG), ROWS[-1]] # fmt: skip
def test_자리_규칙():
runs = [("hand:g", "항목 추가"), ("hand:a", "항목 추가"), ("hand:b", "항목 추가"),
("hand:a", "내어쓰기 ←"), ("hand:g", "내어쓰기 ←"), ("hand:s", "내어쓰기 ←"),
("hand:t", "내어쓰기 ←"), ("mat:M1", "내어쓰기 ←")] # fmt: skip
out = _menu({}, runs=runs, rows=SUPPLY)["runs"]
new = lambda b: [(e.get("parent"), e.get("name", e["key"])) for e in b["rows"]] # noqa: E731
# 폴더 = 그 폴더 첫 하위 · 항목 = 바로 아래 같은 부모
got = new(out["hand:g|항목 추가"])
assert got[got.index(("sec:1.2", "hand:g")) + 1] == ("hand:g", "새 줄")
got = [e["key"] for e in out["hand:a|항목 추가"]["rows"]]
row = out["hand:a|항목 추가"]["rows"][got.index("hand:a") + 1]
assert (row["name"], row["parent"]) == ("새 줄", "hand:g")
got = out["hand:b|항목 추가"]["rows"]
row = got[[e["key"] for e in got].index("hand:b") + 1]
assert (row["name"], row["parent"], "kind" in row) == ("새 줄", "sec:1.2", False)
# ← = 한 단씩 — 레벨 4 → 3(sec:1.2 아래) · 레벨 3 → 2(sec:1 아래)
assert ("hand:a", "sec:1.2") in _order(out["hand:a|내어쓰기 ←"])
assert ("hand:g", "sec:1") in _order(out["hand:g|내어쓰기 ←"])
# 관급 안 손 묶음 ← = 새 공종(직접비) · 관급자재대 앞
b = out["hand:s|내어쓰기 ←"]
tops = [k for k, p in _order(b) if p is None]
assert tops == ["sec:1", "sec:4", "hand:s", "sec:G"]
s = {e["key"]: e for e in b["rows"]}["hand:s"]
assert (s["kind"], s["cost"]) == ("group", "direct")
# 손 항목 ← 관급 묶음 밖(sec:G 아래) · 자재 줄 = 1단 안 됨
assert ("hand:t", "sec:G") in _order(out["hand:t|내어쓰기 ←"])
assert out["mat:M1|내어쓰기 ←"] is None
# 1단 손 항목 → 묶음(수량 · 연결 지움) — 2단 손 항목을 두 번 냄
one = _menu({"bill": out["hand:t|내어쓰기 ←"]}, runs=[("hand:t", "내어쓰기 ←")],
rows=SUPPLY)["runs"]["hand:t|내어쓰기 ←"] # fmt: skip
t = {e["key"]: e for e in one["rows"]}["hand:t"]
assert ("parent" in t, t["kind"], t["qty"], t["ref"], t["cost"]) == (
False, "group", None, None, "direct") # fmt: skip
@@ -0,0 +1,168 @@
"""246-2 · 129 — 공용 우클릭 메뉴 하위 메뉴(children) = 「글 ▸」 한 줄 + 오른쪽 옆 새 판.
- 올림 · 누름 · → = 옆 판(머리 줄 높이 · 메뉴 오른쪽) · 다른 줄 올림 · ← = 닫힘(← 는 머리로 초점)
- 화면 오른쪽 끝이면 메뉴 왼쪽 · 옆 판 고름 = 메뉴 닫고 동작 · 바깥 누름 · Esc = 모두 닫힘
- 옛 아코디언(같은 판 안 펼침 · ▾) 없음 · B09 「연결」 = children 그대로(옆 판으로 감)
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
DOM_JS = """
const docOn = {};
class El {
constructor(tag) { Object.assign(this, { tag, kids: [], parent: null, on: {}, style: {},
className: "", hidden: false, textContent: "", attrs: {} }); }
get classList() {
const el = this;
const list = () => el.className.split(" ").filter(Boolean);
return { add: (c) => { if (!list().includes(c)) el.className = [...list(), c].join(" "); },
remove: (c) => (el.className = list().filter((x) => x !== c).join(" ")),
contains: (c) => list().includes(c) };
}
get ownerDocument() { return { defaultView: globalThis.view }; }
get firstElementChild() { return this.kids[0] ?? null; }
get lastElementChild() { return this.kids.at(-1) ?? null; }
setAttribute(k, v) { this.attrs[k] = v; }
append(...xs) { for (const x of xs) { x.parent = this; this.kids.push(x); } }
replaceChildren() { this.kids = []; }
remove() { if (this.parent) this.parent.kids = this.parent.kids.filter((x) => x !== this); }
contains(x) { for (let at = x; at; at = at.parent) if (at === this) return true; return false; }
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
fire(t, ev = {}) { for (const f of this.on[t] ?? []) f({ target: this, preventDefault() {},
stopPropagation() {}, ...ev }); }
focus() { doc.activeElement = this; }
all() { return this.kids.flatMap((k) => [k, ...k.all()]); }
match(sel) { return sel.startsWith(".") ? sel.slice(1).split(".").every((c) => this.classList.contains(c))
: this.tag === sel; }
querySelectorAll(sel) {
let found = [this];
for (const part of sel.split(" ")) found = found.flatMap((el) => el.all().filter((k) => k.match(part)));
return found;
}
querySelector(sel) { return this.querySelectorAll(sel)[0] ?? null; }
getBoundingClientRect() {
if (this.style.left !== undefined) {
const side = this.classList.contains("ui-ctx-menu__side");
const left = parseFloat(this.style.left), top = parseFloat(this.style.top);
const width = side ? 150 : 160;
const height = 8 + 30 * this.kids.filter((k) => k.tag === "button").length;
return { left, top, width, height, right: left + width, bottom: top + height };
}
const box = this.parent.getBoundingClientRect();
const top = box.top + 4 + 30 * this.parent.kids.filter((k) => k.tag === "button").indexOf(this);
return { left: box.left + 4, top, width: box.width - 8, height: 30, right: box.right - 4, bottom: top + 30 };
}
}
const doc = { activeElement: null, createElement: (t) => new El(t),
addEventListener: (t, f) => (docOn[t] ??= []).push(f) };
globalThis.document = doc;
globalThis.Node = El;
globalThis.view = { innerWidth: 1000, innerHeight: 800 };
globalThis.window = globalThis.view;
"""
SCENE_JS = """
const log = [];
const menu = createMapContextMenu("b09-estimation");
const items = [["A", () => log.push("A")],
{ label: "연결", children: [["x", () => log.push("x")], ["y", () => log.push("y")]] }, "-",
["B", () => log.push("B")]];
const el = menu.element;
const btns = () => el.kids.filter((k) => k.tag === "button");
const side = () => el.querySelector(".ui-ctx-menu__side");
const sideText = () => side()?.kids.map((k) => k.textContent) ?? null;
const at = () => [parseFloat(side().style.left), parseFloat(side().style.top), side().style.position];
const out = {};
menu.open(100, 100, items);
const head = btns()[1];
out.open = [el.kids.map((k) => k.textContent || k.className), sideText(), head.attrs["aria-haspopup"]];
head.fire("pointerenter");
out.hover = [sideText(), at(), head.classList.contains("is-open"), side().className];
out.firstRow = side().kids[0].getBoundingClientRect().top === head.getBoundingClientRect().top;
out.contains = menu.contains(side().kids[0]);
btns()[0].fire("pointerenter");
out.leave = [sideText(), head.classList.contains("is-open")];
head.fire("click");
out.click = [sideText(), doc.activeElement?.textContent];
side().fire("keydown", { key: "ArrowLeft" });
out.left = [sideText(), doc.activeElement === head];
head.fire("keydown", { key: "ArrowRight" });
out.right = [sideText(), doc.activeElement?.textContent];
side().kids[1].fire("click");
out.pick = [el.hidden, el.kids.length, [...log]];
// 화면 오른쪽 끝 — 메뉴는 안쪽으로 당김 · 옆 판 = 메뉴 왼쪽
view.innerWidth = 400;
menu.open(300, 100, items);
btns()[1].fire("pointerenter");
out.edge = [parseFloat(el.style.left), at()[0]];
// 아래 끝 — 옆 판 올림
view.innerWidth = 1000; view.innerHeight = 100;
menu.open(100, 10, items);
btns()[1].fire("pointerenter");
out.bottom = at()[1];
view.innerHeight = 800;
// Esc · 바깥 누름 = 모두 닫힘
menu.open(100, 100, items);
btns()[1].fire("pointerenter");
for (const f of docOn.keydown) f({ key: "Escape", stopPropagation() {}, preventDefault() {} });
out.esc = [el.hidden, side()];
menu.open(100, 100, items);
btns()[1].fire("pointerenter");
const inside = side().kids[0];
for (const f of docOn.pointerdown) f({ target: inside });
out.insideDown = el.hidden;
for (const f of docOn.pointerdown) f({ target: new El("div") });
out.outsideDown = el.hidden;
console.log(JSON.stringify(out));
"""
def _node(source: str) -> str:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript"],
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return result.stdout
def _src() -> str:
return (ROOT / "ui_template" / "ui_template_context_menu.ts").read_text("utf-8")
def test_옆_판_열기_닫기_자리():
out = json.loads(_node(DOM_JS + IMPORTS.sub("", _src()) + SCENE_JS))
# 「연결 ▸」 한 줄 · 처음엔 옆 판 없음(같은 판 안 펼침 없음)
assert out["open"] == [["A", "연결 ▸", "ui-ctx-menu__sep", "B"], None, "menu"]
# 올림 = 옆 판 다섯 자리 — 메뉴 오른쪽(100 + 160) · 첫 줄 = 머리 줄 높이(134 - 4) · fixed
names = "b09-estimation__context-menu-sub ui-ctx-menu ui-ctx-menu__side"
assert out["hover"] == [["x", "y"], [260, 130, "fixed"], True, names]
assert out["firstRow"] is True and out["contains"] is True
assert out["leave"] == [None, False] # 다른 줄 올림 = 닫힘
assert out["click"] == [["x", "y"], "x"] # 누름 = 열고 첫 줄 초점
assert out["left"] == [None, True] # ← = 닫고 머리 줄로
assert out["right"] == [["x", "y"], "x"] # → = 열기
assert out["pick"] == [True, 0, ["y"]] # 옆 판 고름 = 메뉴 닫고 동작
# 오른쪽 끝 — 메뉴 = 400 - 4 - 160 · 옆 판 = 메뉴 왼쪽(236 - 150)
assert out["edge"] == [236, 86]
# 아래 끝(높이 100) — 옆 판(68) 바닥 = 100 - 4
assert out["bottom"] == 100 - 4 - 68
assert out["esc"] == [True, None]
assert out["insideDown"] is False and out["outsideDown"] is True
def test_옛_아코디언_없음_공용_꼴():
src = _src()
assert "ui-ctx-menu__sub" not in src and "▾" not in src and "is-sub" not in src
css = (ROOT / "ui_template" / "ui_template_context_menu.css").read_text("utf-8")
assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css
# B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로)
edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8")
assert 'parts[1].push({ label: "연결", children: picks }, ...more);' in edit
+3 -12
View File
@@ -47,22 +47,13 @@
cursor: not-allowed;
}
/* 하위 메뉴(아코디언) — 머리글 굵게 · 종류는 들여쓰기 */
/* 하위 메뉴(246-2) — 머리 줄 굵게 · 연 동안 머리 줄 칠함 · 옆 판 = 같은 메뉴 꼴(자리 = fixed · 스크립트) */
.ui-ctx-menu__item.is-group {
font-weight: var(--font-weight-medium);
}
.ui-ctx-menu__sub {
display: flex;
flex-direction: column;
}
.ui-ctx-menu__sub[hidden] {
display: none;
}
.ui-ctx-menu__item.is-sub {
padding-left: var(--spacing-24);
.ui-ctx-menu__item.is-open {
background: var(--color-surface-sunken, var(--color-mist-violet));
}
/* 구분선(`"-"` 항목) */
+71 -27
View File
@@ -13,8 +13,8 @@
import "./ui_template_context_menu.css";
/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로
* 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화).
/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면
* 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2).
* 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */
export type MapContextMenuItem =
| [label: string, action: () => void]
@@ -104,7 +104,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
const entry = document.createElement("button");
entry.type = "button";
entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`;
if (sub) entry.classList.add("is-sub");
if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘
entry.textContent = label;
entry.addEventListener("click", () => {
close();
@@ -113,7 +113,72 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
return entry;
}
/** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 · 하위 메뉴를 펼칠 때 */
/** 옆 판(하위 메뉴) 닫음 — 머리 줄 표시도 풂 */
function closeSide(): void {
element.querySelector(".ui-ctx-menu__side")?.remove();
element
.querySelectorAll<HTMLElement>(".ui-ctx-menu__item.is-open")
.forEach((head) => head.classList.remove("is-open"));
}
/** 옆 판 자리 — 머리 줄 오른쪽(넘치면 메뉴 왼쪽) · 아래 끝은 올림 · fixed(메뉴 overflow 에 안 잘림) ·
* 변환된 그릇 안이면 실제 자리와 어긋난 만큼 바로잡음 */
function placeSide(side: HTMLElement, head: HTMLElement): void {
const view = element.ownerDocument.defaultView ?? window;
const menu = element.getBoundingClientRect();
let x = menu.right;
let y = head.getBoundingClientRect().top;
side.style.left = `${x}px`;
side.style.top = `${y}px`;
const box = side.getBoundingClientRect();
const [ox, oy] = [box.left - x, box.top - y];
y -= (side.firstElementChild?.getBoundingClientRect().top ?? box.top) - box.top; // 첫 줄 = 머리 줄 높이
if (x + box.width > view.innerWidth - 4) x = Math.max(4, menu.left - box.width);
if (y + box.height > view.innerHeight - 4) y = Math.max(4, view.innerHeight - 4 - box.height);
side.style.left = `${x - ox}px`;
side.style.top = `${y - oy}px`;
}
/** 하위 메뉴 머리 줄 — 올림 · 누름 · → = 옆 판 · 옆 판 ← = 닫고 머리로 */
function buildGroup(label: string, children: Array<[string, () => void]>): HTMLButtonElement {
const head = document.createElement("button");
head.type = "button";
head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`;
head.setAttribute("aria-haspopup", "menu");
head.textContent = `${label} ▸`;
const show = (focus: boolean): void => {
if (!head.classList.contains("is-open")) {
closeSide();
const side = document.createElement("div");
side.className = `${blockClass}__context-menu-sub ui-ctx-menu ui-ctx-menu__side`;
side.style.position = "fixed";
side.setAttribute("role", "menu");
children.forEach(([text, action]) => side.append(buildLeaf(text, action, true)));
side.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft") return;
event.preventDefault();
event.stopPropagation();
closeSide();
head.focus();
});
element.append(side);
head.classList.add("is-open");
placeSide(side, head);
}
if (focus) element.querySelector<HTMLElement>(".ui-ctx-menu__side button")?.focus();
};
head.addEventListener("pointerenter", () => show(false));
head.addEventListener("click", () => show(true));
head.addEventListener("keydown", (event) => {
if (event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
show(true);
});
return head;
}
/** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 */
function fit(): void {
const x = parseFloat(element.style.left);
const y = parseFloat(element.style.top);
@@ -144,29 +209,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
element.append(buildLeaf(item[0], item[1]));
return;
}
// 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다.
// 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘).
const head = document.createElement("button");
head.type = "button";
head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`;
head.textContent = `${item.label} ▸`;
const body = document.createElement("div");
body.className = `${blockClass}__context-menu-sub ui-ctx-menu__sub`;
body.hidden = true;
item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true)));
head.addEventListener("click", () => {
const willOpen = body.hidden;
element
.querySelectorAll<HTMLElement>(`.${blockClass}__context-menu-sub`)
.forEach((other) => (other.hidden = true));
element
.querySelectorAll<HTMLElement>(`.${blockClass}__context-menu-item.is-group`)
.forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸")));
body.hidden = !willOpen;
head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`;
fit(); // 펼친 하위가 화면 아래로 넘치면 메뉴를 올림
});
element.append(head, body);
// 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나)
element.append(buildGroup(item.label, item.children));
});
if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep"))
element.lastElementChild.remove();
+2 -9
View File
@@ -158,7 +158,7 @@
box-shadow: inset 0 -2px 0 var(--color-accent);
}
/* 고칠 칸(PLAN 121 · 241-1) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 · 입력 칸 표시 = 채움 색 없이 칸 안쪽 테두리(칸 크기 불변) */
/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */
.ui-data-table__table td.is-edit {
padding: 0;
}
@@ -168,20 +168,13 @@
height: var(--dt-row-h);
padding: 0 var(--spacing-8);
border: 0;
background: transparent;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 60%, var(--color-canvas));
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
color: inherit;
font: inherit;
font-variant-numeric: tabular-nums;
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;
+2 -7
View File
@@ -17,8 +17,7 @@
* 나무(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 = 표 밖이 맡음)
* 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음)
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
* ========================================================================== */
@@ -184,7 +183,7 @@ function rowHtml(
more: RowMore = {},
): string {
let tr = "";
const { lv, spans, can } = more;
const { lv, spans } = more;
for (let j = 0; j < cols.length; j++) {
const key = cols[j].key;
const span = spans?.[j] ?? 1;
@@ -210,7 +209,6 @@ 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",
]
@@ -243,8 +241,6 @@ 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 */
@@ -360,7 +356,6 @@ 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) } : {}),
},
);
}