Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -81,6 +81,35 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 235-1 설계내역서 고른 줄 — 줄 전체 상자 테두리(공용 is-focus 밑줄 · 배경 대신) */
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td {
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--color-accent),
|
||||
inset 0 -2px 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:first-child {
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--color-accent),
|
||||
inset 0 -2px 0 var(--color-accent),
|
||||
inset 2px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td:last-child {
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--color-accent),
|
||||
inset 0 -2px 0 var(--color-accent),
|
||||
inset -2px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus:not(:hover) td:not(.is-edited) {
|
||||
background-color: var(--color-canvas);
|
||||
}
|
||||
|
||||
.b09-estimation [data-b09-table="bill"] tbody tr.is-focus.is-alt:not(:hover) td:not(.is-edited) {
|
||||
background-color: var(--dt-zebra);
|
||||
}
|
||||
|
||||
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
|
||||
--pick-t: inset 0 0 0 0 transparent;
|
||||
|
||||
@@ -204,6 +204,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
empty: "",
|
||||
caption: TABS.find(([id]) => id === tab)?.[1],
|
||||
editable: spec.editable,
|
||||
editOnDouble: spec.editOnDouble,
|
||||
onEdit: spec.onEdit,
|
||||
rowClass: extra
|
||||
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
|
||||
@@ -260,10 +261,10 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 200-2 설계내역서 = 고른 줄 강조 · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래
|
||||
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
|
||||
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;
|
||||
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 (focus !== undefined) {
|
||||
|
||||
@@ -99,6 +99,8 @@ export interface TabTable {
|
||||
/** 칸 누름 → 갈 줄(호표 칸) */
|
||||
link?: (index: number, key: string) => Link | null;
|
||||
editable?: (index: number, key: string) => boolean;
|
||||
/** 고칠 칸 = 더블클릭 · Enter · F2 때만 글 상자(235-1 · 공용 표 editOnDouble) */
|
||||
editOnDouble?: boolean;
|
||||
onEdit?: (index: number, key: string, text: string) => void;
|
||||
/** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */
|
||||
onCell?: (index: number, key: string) => void;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* 접히지 않는 나무(235-1) = 폴더(묶음 · 자식 있는 줄) = 이름만 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용) ·
|
||||
* 파일(항목) = 이름 · 규격 · 단위 · 수량 + 연결 · 안내선 · 아이콘 = 공용 표 level.tree · 고칠 칸 = 더블클릭 입력
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -24,6 +26,8 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
const TEXT_KEYS = ["name", "spec", "unit"];
|
||||
/** 폴더 줄 수식 칸 — 자손 합(셈) */
|
||||
const SUM_KEYS = ["sum", "labor", "material", "expense"];
|
||||
|
||||
export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const { sheets, user, setUser } = ctx;
|
||||
@@ -61,9 +65,11 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
frozen: 1,
|
||||
hint:
|
||||
"기본 로직이 짠 내역 = 초기값 · 모든 줄 고침 · 더함 · 지움 · 옮김 자유(우클릭 · 고친 칸 = 색 칸 · 풍선 = 자동 값) · " +
|
||||
"칸 누름 = 줄 고름 · 입력 = 더블클릭 · Enter · F2 · 폴더 줄 = 이름만(금액 = 아래 합) · 파일 줄 = 값 입력 · 연결 · " +
|
||||
"금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 묶음 금액 = 아래 항목 합 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재(관급자재대 밖으로 못 옮김) · " +
|
||||
"조달수수료 율(%)은 수량 칸 · 「연결 ▾」 = 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · 처음으로 = 왼쪽 [내역 초기화] · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editOnDouble: true,
|
||||
editable: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (!r) return false;
|
||||
@@ -86,6 +92,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
cellClass: (i, key) => (edited(rows[i], key) ? "is-edited" : null),
|
||||
tip: (i, key) => {
|
||||
const r = rows[i];
|
||||
if (r && r.kind !== "item" && SUM_KEYS.includes(key)) return "수식 = 아래 항목 합(읽기 전용)";
|
||||
if (!r || !edited(r, key)) return null;
|
||||
const auto = key === "connect" ? (r.auto_ref as Row | null)?.key : r[`auto_${key}`];
|
||||
return `자동 값 ${auto ?? "빈칸"} — 우클릭 [자동값으로 되돌리기]`;
|
||||
@@ -101,6 +108,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
key: "name",
|
||||
of: (i) => rows[i]?.level as number | undefined,
|
||||
group: (i) => rows[i]?.kind !== "item",
|
||||
tree: true,
|
||||
},
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*
|
||||
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) · 안내 글 = 표 아래(202-1)
|
||||
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로) ·
|
||||
* 꼴 = 줄 전체 상자 테두리(235-1 · 페이지 CSS) · 글 칸도 한 번 누름 = 고름 · 더블클릭 · Enter · F2 = 입력(공용 표)
|
||||
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
|
||||
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
|
||||
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
|
||||
@@ -26,7 +27,8 @@ const ACTS: [string, string][] = [
|
||||
];
|
||||
|
||||
const GUIDE =
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기 · " +
|
||||
"입력 = 더블클릭 · Enter · F2(Esc · Enter = 끝)";
|
||||
const GUIDE_MORE =
|
||||
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
|
||||
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
|
||||
@@ -43,6 +45,8 @@ let live: {
|
||||
tools: HTMLElement[];
|
||||
run: (label: string) => void;
|
||||
sync: () => void;
|
||||
edit: (index: number) => void;
|
||||
index: () => number;
|
||||
} | null = null;
|
||||
let keysOn = false;
|
||||
|
||||
@@ -96,6 +100,12 @@ function listenKeys(): void {
|
||||
(at as HTMLElement).blur();
|
||||
return live.root.focus();
|
||||
}
|
||||
// Enter · F2 = 고른 줄 입력 시작(235-1) — 도구 단추 위 Enter 는 단추 몫
|
||||
if ((event.key === "Enter" || event.key === "F2") && !editing(at)) {
|
||||
if (live.tools.includes(at as HTMLElement) || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
return live.edit(live.index());
|
||||
}
|
||||
const label = keyAct(event, editing(at));
|
||||
if (!label) return;
|
||||
event.preventDefault();
|
||||
@@ -107,6 +117,7 @@ function listenKeys(): void {
|
||||
export function levelBar(
|
||||
root: HTMLElement,
|
||||
table: BillTable,
|
||||
edit: (index: number) => void = () => {},
|
||||
): { tools: HTMLElement[]; guide: HTMLElement } {
|
||||
const index = (): number =>
|
||||
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
|
||||
@@ -140,7 +151,7 @@ export function levelBar(
|
||||
});
|
||||
attachDrag(root, table, (key) => (picked = key));
|
||||
sync();
|
||||
live = { root, tools: buttons, run, sync };
|
||||
live = { root, tools: buttons, run, sync, edit, index };
|
||||
listenKeys();
|
||||
return { tools: buttons, guide };
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ def test_CSS_레벨_글꼴_규칙():
|
||||
assert rule(".is-lv5") == "font-size: var(--dt-lv5-size);"
|
||||
# 항목 Lv1 · 2 · Lv3 = 표 기본 14px(크기 규칙 없음) · 굵게 = 묶음만
|
||||
assert not rule(".is-lv1") and not rule(".is-lv2") and not rule(".is-lv3")
|
||||
assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 2 and "* 1em" not in css
|
||||
# 들여쓰기 = 글 칸 · 글 상자 · 나무 글 상자(235-1 · 아이콘 자리 더함)
|
||||
assert css.count("var(--dt-lv, 0) * var(--dt-indent)") == 3 and "* 1em" not in css
|
||||
# 줄 높이 28px 안 — 가장 큰 글자(16px × 줄 간격 1.3)
|
||||
assert "--dt-row-h: 28px;" in css and 16 * 1.3 < 28
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""235-1 B09 설계내역서 = 접히지 않는 트리 · 더블클릭 입력 · 고른 줄 상자.
|
||||
|
||||
- 폴더 줄(자식 있는 줄 · 제목 · 묶음) = 이름만 고침 · 수량 · 단가 빈칸 · 금액 = 자손 합 수식(읽기 전용 · 연결 없음)
|
||||
- 파일 줄(항목) = 이름 · 규격 · 단위 · 수량 입력 + 「연결 ▾」
|
||||
- 트리 안내선(├ is-tee · └ is-end · │ is-bar) · 폴더 · 파일 아이콘(공용 나무 꼴) = 공용 표 level.tree
|
||||
- 공용 표 editOnDouble = 고칠 칸도 글 · 한 번 누름 = 줄 고름 · 더블클릭 · edit() = 그 칸만 글 상자 · Enter · 초점 떠남 = 끝
|
||||
- 레벨 키 = 고른 줄에서 Enter · F2 = 입력 시작(편집 중 · 도구 단추 · 고른 줄 없음 = 안 먹음)
|
||||
- 고른 줄 = 줄 전체 상자 테두리(테마 토큰 · 배경 대신)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_182_b09_bill_level import BOOKS, INPUTS, USER # noqa: E402
|
||||
from test_184_3_b09_bill_menu import _node, _ts # noqa: E402
|
||||
from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402
|
||||
from test_184_3_b09_bill_menu import ROWS as FAKE_ROWS # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8"))
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]
|
||||
SUMS = ["sum", "labor", "material", "expense"]
|
||||
FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6"
|
||||
|
||||
|
||||
def _rows():
|
||||
return compute_estimation(INPUTS, USER, None, BOOKS)["bill"]["rows"]
|
||||
|
||||
|
||||
# --- 설계내역서 칸: 폴더 = 수식 · 파일 = 입력 ------------------------------------------------
|
||||
|
||||
SPEC_JS = """
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} };
|
||||
const spec = buildBill(ctx);
|
||||
const keys = ["name", "spec", "unit", "qty", "price_sum", "sum", "labor", "material", "expense", "connect"];
|
||||
console.log(JSON.stringify({
|
||||
double: spec.editOnDouble, tree: spec.level.tree,
|
||||
rows: spec.rows.map((r, i) => ({ key: r.key, kind: r.kind, connect: r.connect,
|
||||
edit: keys.filter((k) => spec.editable(i, k)),
|
||||
tips: Object.fromEntries(keys.map((k) => [k, spec.tip(i, k) ?? null])) })),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _spec(rows):
|
||||
return json.loads(
|
||||
_node(_ts(SRC) + SPEC_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)))
|
||||
)
|
||||
|
||||
|
||||
def test_폴더_줄_이름만_수량_단가_빈칸_금액_자손_합():
|
||||
rows = _rows()
|
||||
by = {r["key"]: r for r in rows}
|
||||
folders = [r for r in rows if r["kind"] != "item"]
|
||||
assert {"sec:1", "sec:1.1", "hand:p", "hand:q"} <= {r["key"] for r in folders}
|
||||
for f in folders:
|
||||
assert f.get("qty") is None and all(f.get(f"price_{p}") is None for p in [*SUMS[1:], "sum"])
|
||||
kids = [r for r in rows if r["kind"] == "item" and _under(by, r, f["key"])]
|
||||
for p in SUMS: # 금액 = 자손 항목 합(셈이 냄)
|
||||
assert f[p] == sum(r[p] or 0 for r in kids), (f["key"], p)
|
||||
out = _spec(rows)
|
||||
assert out["double"] is True and out["tree"] is True
|
||||
for r in out["rows"]:
|
||||
if r["kind"] == "item":
|
||||
continue
|
||||
assert r["edit"] == ["name"], r["key"] # 이름만
|
||||
assert r["connect"] == "" # 연결 없음
|
||||
for p in SUMS:
|
||||
assert r["tips"][p] == "수식 = 아래 항목 합(읽기 전용)"
|
||||
assert r["tips"]["qty"] is None and r["tips"]["name"] is None
|
||||
|
||||
|
||||
def _under(by, row, key):
|
||||
at = by.get(row.get("parent") or "")
|
||||
while at:
|
||||
if at["key"] == key:
|
||||
return True
|
||||
at = by.get(at.get("parent") or "")
|
||||
return False
|
||||
|
||||
|
||||
def test_파일_줄_입력_연결():
|
||||
out = _spec(_rows())
|
||||
items = [r for r in out["rows"] if r["kind"] == "item" and r["key"] != "fee"]
|
||||
assert items
|
||||
for r in items:
|
||||
assert r["edit"] == ["name", "spec", "unit", "qty"], r["key"]
|
||||
assert all(r["tips"][p] is None for p in SUMS)
|
||||
linked = [r for r in items if not r["key"].startswith("mat:")]
|
||||
assert linked and all(r["connect"].endswith("▾") for r in linked)
|
||||
|
||||
|
||||
# --- 트리 안내선 · 아이콘 ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _guides_py(levels):
|
||||
"""거울 — 아래에 같은 레벨 형제(더 얕은 줄 전까지)가 있으면 ├ · 없으면 └ · 윗대 형제가 있으면 │."""
|
||||
|
||||
def more(i, d):
|
||||
for lv in levels[i + 1 :]:
|
||||
if lv < d:
|
||||
return False
|
||||
if lv == d:
|
||||
return True
|
||||
return False
|
||||
|
||||
out = []
|
||||
for i, lv in enumerate(levels):
|
||||
g = [("is-bar", d - 2) for d in range(2, lv) if more(i, d)]
|
||||
if lv >= 2:
|
||||
g.append(("is-tee" if more(i, lv) else "is-end", lv - 2))
|
||||
out.append(g)
|
||||
return out
|
||||
|
||||
|
||||
GUIDE_JS = """
|
||||
const parse = (h) => [...h.matchAll(/ui-data-table__guide (is-\\w+)" style="--g:(\\d+)"/g)].map((m) => [m[1], Number(m[2])]);
|
||||
const levels = __LEVELS__;
|
||||
const unit = treeGuides(levels).map(parse);
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {}, user: {}, setUser: () => {} };
|
||||
const spec = buildBill(ctx);
|
||||
const html = renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: spec.level,
|
||||
editable: spec.editable, editOnDouble: true });
|
||||
const trs = html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
|
||||
const lvCell = (tr) => (tr.match(/<td class="[^"]*ui-data-table__lv[^"]*"[^>]*>(.*?)<\\/td>/) || [])[1] ?? "";
|
||||
console.log(JSON.stringify({
|
||||
unit,
|
||||
bill: trs.map((tr) => parse(lvCell(tr))),
|
||||
icons: trs.map((tr) => lvCell(tr).includes("__FOLDER__") ? "folder" : lvCell(tr).includes("__LEAF__") ? "leaf" : null),
|
||||
tree: trs.map((tr) => /has-tree/.test(tr)),
|
||||
levels: spec.rows.map((r) => r.level), kinds: spec.rows.map((r) => r.kind),
|
||||
plain: renderDataTableHtml({ columns: spec.columns, rows: spec.rows, level: { ...spec.level, tree: false } })
|
||||
.includes("ui-data-table__guide"),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _guides(levels, rows):
|
||||
body = (
|
||||
GUIDE_JS.replace("__LEVELS__", json.dumps(levels))
|
||||
.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__FOLDER__", FOLDER_SVG)
|
||||
.replace("__LEAF__", LEAF_SVG)
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + TABLE + body))
|
||||
|
||||
|
||||
def test_트리_안내선_한_칸씩():
|
||||
levels = [1, 2, 3, 3, 2, 3, 1, 2]
|
||||
out = _guides(levels, _rows())
|
||||
assert out["unit"] == [
|
||||
[],
|
||||
[["is-tee", 0]],
|
||||
[["is-bar", 0], ["is-tee", 1]],
|
||||
[["is-bar", 0], ["is-end", 1]],
|
||||
[["is-end", 0]],
|
||||
[["is-end", 1]],
|
||||
[],
|
||||
[["is-end", 0]],
|
||||
]
|
||||
assert out["unit"] == [[list(g) for g in row] for row in _guides_py(levels)]
|
||||
|
||||
|
||||
def test_설계내역서_안내선_아이콘_공용_나무_꼴():
|
||||
out = _guides([1], _rows())
|
||||
assert out["bill"] == [[list(g) for g in row] for row in _guides_py(out["levels"])]
|
||||
assert any(out["bill"]) # 실제로 선이 그려짐
|
||||
assert out["icons"] == ["leaf" if k == "item" else "folder" for k in out["kinds"]]
|
||||
assert all(out["tree"]) and out["plain"] is False # 나무 옵션 없으면 안내선 없음
|
||||
table = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
|
||||
tree = (ROOT / "ui_template" / "ui_template_tree.ts").read_text("utf-8")
|
||||
assert FOLDER_SVG in table and LEAF_SVG in table # 아이콘 한 벌 — 공용 나무가 가져다 씀
|
||||
assert 'import { TREE_ICON } from "./ui_template_data_table";' in tree
|
||||
assert "TREE_ICON.folder : TREE_ICON.leaf" in tree and FOLDER_SVG not in tree
|
||||
|
||||
|
||||
def test_안내선_CSS_테마_토큰():
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
block = css.split(".ui-data-table__guide::before {")[1].split("}")[0]
|
||||
assert "border-left: 1px solid var(--color-border);" in block
|
||||
assert ".ui-data-table__guide.is-end::before {\n bottom: 50%;" in css
|
||||
assert ".ui-data-table__guide.is-tee::after,\n.ui-data-table__guide.is-end::after {" in css
|
||||
assert "left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));" in css
|
||||
assert ".ui-data-table__table td.has-tree:not(.is-frozen) {\n position: relative;" in css
|
||||
|
||||
|
||||
# --- 공용 표 더블클릭 입력 ---------------------------------------------------------------------
|
||||
|
||||
DOM_JS = """
|
||||
const doc = { activeElement: null };
|
||||
globalThis.document = doc;
|
||||
globalThis.CSS = { escape: (s) => s };
|
||||
class FakeInput {
|
||||
constructor(owner, i, k) { this.owner = owner; this.dataset = { i, k }; this.value = "v"; }
|
||||
focus() { doc.activeElement = this; }
|
||||
select() { this.selected = true; }
|
||||
}
|
||||
globalThis.HTMLInputElement = FakeInput;
|
||||
class Root {
|
||||
constructor() { this.on = {}; this.html = ""; this.clientHeight = 600; this.scrollTop = 0; this.dataset = {}; }
|
||||
set innerHTML(h) { this.html = h; }
|
||||
get innerHTML() { return this.html; }
|
||||
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
|
||||
fire(t, ev) { for (const f of this.on[t] ?? []) f(ev); }
|
||||
contains(x) { return x === this || x?.owner === this; }
|
||||
focus() { doc.activeElement = this; }
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/^input\\[data-i="(\\d+)"\\]\\[data-k="(\\w+)"\\]$/);
|
||||
if (!m || !this.html.includes(`data-i="${m[1]}" data-k="${m[2]}"`)) return null;
|
||||
return new FakeInput(this, m[1], m[2]);
|
||||
}
|
||||
}
|
||||
doc.createElement = () => new Root();
|
||||
const target = (root, i, c) => {
|
||||
const td = { dataset: {}, cellIndex: c };
|
||||
const tr = { dataset: { i: String(i) }, owner: root };
|
||||
return { closest: (s) => (s.startsWith("tr") ? tr : td) };
|
||||
};
|
||||
const inputs = (root) => [...root.html.matchAll(/<input[^>]*data-i="(\\d+)" data-k="(\\w+)"/g)].map((m) => `${m[1]}:${m[2]}`);
|
||||
const columns = [{ key: "name", label: "명칭" }, { key: "qty", label: "수량", kind: "number" }];
|
||||
const rows = [{ name: "폴더", qty: null }, { name: "파일", qty: 3 }];
|
||||
const log = [];
|
||||
const t = createDataTable({ columns, rows, editOnDouble: true, editable: (i) => i === 1,
|
||||
onCellClick: (i, k) => log.push(`click ${i}:${k}`), onEdit: (i, k, v) => log.push(`edit ${i}:${k}=${v}`) });
|
||||
const r = t.root;
|
||||
const out = { start: inputs(r) };
|
||||
r.fire("click", { target: target(r, 1, 0) });
|
||||
out.click = [inputs(r), [...log]];
|
||||
r.fire("dblclick", { target: target(r, 0, 0) });
|
||||
out.folderDbl = inputs(r);
|
||||
r.fire("dblclick", { target: target(r, 1, 1) });
|
||||
const input = doc.activeElement;
|
||||
out.dbl = [inputs(r), input?.dataset ?? null, !!input?.selected];
|
||||
let stopped = 0;
|
||||
const key = (k, more = {}) => r.fire("keydown", { key: k, target: input, isComposing: false, ...more,
|
||||
preventDefault() {}, stopPropagation() { stopped += 1; } });
|
||||
key("Enter", { isComposing: true });
|
||||
out.composing = [doc.activeElement === input, stopped];
|
||||
key("Enter");
|
||||
out.enter = [doc.activeElement === r, stopped];
|
||||
r.fire("change", { target: input });
|
||||
r.fire("focusout", { target: input });
|
||||
setTimeout(() => {
|
||||
out.after = [inputs(r), log.at(-1)];
|
||||
out.editName = [t.edit(1), inputs(r)]; // 마지막 누른 열 = 명칭
|
||||
out.editQty = [t.edit(1, "qty"), inputs(r)];
|
||||
out.editFolder = t.edit(0);
|
||||
const plain = createDataTable({ columns, rows, editable: (i) => i === 1 });
|
||||
out.plain = [inputs(plain.root), plain.edit(1)];
|
||||
console.log(JSON.stringify(out));
|
||||
}, 5);
|
||||
"""
|
||||
|
||||
|
||||
def test_한_번_누름_고름_더블클릭_입력_Enter_끝():
|
||||
out = json.loads(_node(TABLE + DOM_JS))
|
||||
assert out["start"] == [] # 고칠 칸도 처음엔 글
|
||||
assert out["click"] == [[], ["click 1:name"]] # 한 번 누름 = 줄 고름(글 상자 안 생김)
|
||||
assert out["folderDbl"] == [] # 못 고치는 칸 더블클릭 = 그대로
|
||||
assert out["dbl"] == [["1:qty"], {"i": "1", "k": "qty"}, True] # 그 칸만 · 초점 · 글 전체 고름
|
||||
assert out["composing"] == [True, 0] # 한글 조합 중 Enter = 조합 끝
|
||||
assert out["enter"] == [True, 1] # Enter = 표에 초점 · 문서로 안 감
|
||||
assert out["after"] == [[], "edit 1:qty=v"] # 초점 떠남 = 입력 끝 · onEdit
|
||||
assert out["editName"] == [True, ["1:name"]]
|
||||
assert out["editQty"] == [True, ["1:qty"]]
|
||||
assert out["editFolder"] is False
|
||||
assert out["plain"] == [["1:name", "1:qty"], False] # 옵션 없는 표 = 예전대로 바로 글 상자
|
||||
|
||||
|
||||
# --- 레벨 키: Enter · F2 = 입력 시작 --------------------------------------------------------
|
||||
|
||||
KEYS_JS = """
|
||||
const edits = [];
|
||||
const out = {};
|
||||
draw();
|
||||
out.none = [key("Enter"), edits.length];
|
||||
pick("earth:5");
|
||||
root.focus();
|
||||
out.enter = [key("Enter"), [...edits]];
|
||||
out.f2 = [key("F2"), [...edits]];
|
||||
doc.activeElement = tr("earth:5").kids[0].kids[0];
|
||||
out.editing = [key("Enter"), edits.length];
|
||||
doc.activeElement = bar.tools[0];
|
||||
out.tool = [key("Enter"), edits.length];
|
||||
root.focus();
|
||||
out.composing = [key("Enter", { isComposing: true }), edits.length];
|
||||
out.order = (user.bill?.rows ?? []).length;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_고른_줄_Enter_F2_입력_시작():
|
||||
setup = BAR_JS.split("const out = {};")[0].replace(
|
||||
"bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));"
|
||||
)
|
||||
body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False))
|
||||
names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill",
|
||||
"UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip
|
||||
out = json.loads(_node(_ts(names) + body))
|
||||
at = [r["key"] for r in FAKE_ROWS].index("earth:5")
|
||||
assert out["none"] == [False, 0] # 고른 줄 없음
|
||||
assert out["enter"] == [True, [at]]
|
||||
assert out["f2"] == [True, [at, at]]
|
||||
assert out["editing"] == [False, 2] # 글 상자 안 Enter = 공용 표 몫(입력 끝)
|
||||
assert out["tool"] == [False, 2] # 도구 단추 Enter = 단추 몫
|
||||
assert out["composing"] == [False, 2]
|
||||
assert out["order"] == 0 # 줄 차례 · 레벨은 그대로
|
||||
|
||||
|
||||
# --- 고른 줄 상자 · 배선 -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_고른_줄_상자_테두리_테마_토큰():
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
sel = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus td'
|
||||
top = "inset 0 2px 0 var(--color-accent),\n inset 0 -2px 0 var(--color-accent)"
|
||||
assert f"{sel} {{\n box-shadow:\n {top};" in css
|
||||
assert (
|
||||
f"{sel}:first-child {{\n box-shadow:\n {top},\n inset 2px 0 0 var(--color-accent);"
|
||||
in css
|
||||
)
|
||||
assert (
|
||||
f"{sel}:last-child {{\n box-shadow:\n {top},\n inset -2px 0 0 var(--color-accent);"
|
||||
in css
|
||||
)
|
||||
# 배경 대신 — 고른 줄 배경 = 보통 줄 배경(고친 칸 색은 그대로)
|
||||
bill = '.b09-estimation [data-b09-table="bill"] tbody tr.is-focus'
|
||||
assert (
|
||||
f"{bill}:not(:hover) td:not(.is-edited) {{\n background-color: var(--color-canvas);" in css
|
||||
)
|
||||
assert (
|
||||
f"{bill}.is-alt:not(:hover) td:not(.is-edited) {{\n background-color: var(--dt-zebra);"
|
||||
in css
|
||||
)
|
||||
|
||||
|
||||
def test_Shell_배선():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "editOnDouble: spec.editOnDouble," in shell
|
||||
assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell
|
||||
for name in [
|
||||
"ui_template/ui_template_data_table.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts",
|
||||
]:
|
||||
assert len((ROOT / name).read_text("utf-8").splitlines()) <= 700, name
|
||||
@@ -195,6 +195,60 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 나무(PLAN 235-1 · level.tree) — 접히지 않는 나무 안내선 · 폴더 · 파일 아이콘(공용 나무 ui_template_tree 꼴)
|
||||
* 안내선 칸 한 단 = 들여쓰기 한 칸 · 선 = 칸 가운데(윗대 아이콘 가운데) · 고정 열 칸은 sticky 가 자리 기준 */
|
||||
.ui-data-table__table td.has-tree:not(.is-frozen) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-data-table__guide {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(var(--spacing-8) + var(--g, 0) * var(--dt-indent));
|
||||
width: var(--dt-indent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-data-table__guide::before,
|
||||
.ui-data-table__guide::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
/* │ 윗대 형제가 아래에 더 있음 · ├ 형제가 아래에 더 있음 · └ 끝 형제 */
|
||||
.ui-data-table__guide::before {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-data-table__guide.is-end::before {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.ui-data-table__guide.is-tee::after,
|
||||
.ui-data-table__guide.is-end::after {
|
||||
top: 50%;
|
||||
width: 50%;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-data-table__icon {
|
||||
display: inline-flex;
|
||||
margin-right: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
vertical-align: -2px;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.has-tree .ui-data-table__input {
|
||||
padding-left: calc(
|
||||
var(--spacing-8) + var(--dt-lv, 0) * var(--dt-indent) + 14px + var(--spacing-4)
|
||||
);
|
||||
}
|
||||
|
||||
/* 레벨 글꼴(PLAN 228-1) — 묶음 줄(is-node) 굵게 · 크기는 레벨 · 항목 줄은 14px 보다 크지 않게(Lv1 · 2 항목 = 기본) */
|
||||
.ui-data-table__table td.ui-data-table__lv.is-node {
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
|
||||
* 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게
|
||||
* 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게
|
||||
* 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴)
|
||||
* 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 ·
|
||||
* Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기
|
||||
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
|
||||
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
|
||||
* ========================================================================== */
|
||||
@@ -76,7 +79,13 @@ export interface DataTableOptions {
|
||||
key: string;
|
||||
of: (index: number) => number | null | undefined;
|
||||
group?: (index: number) => boolean;
|
||||
/** 접히지 않는 나무 안내선 · 폴더(묶음) · 파일 아이콘(235-1) */
|
||||
tree?: boolean;
|
||||
};
|
||||
/** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */
|
||||
editOnDouble?: boolean;
|
||||
/** 지금 글 상자인 칸(editOnDouble · 표가 정함) */
|
||||
editing?: { index: number; key: string } | null;
|
||||
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
@@ -86,8 +95,17 @@ export interface DataTable {
|
||||
setRows: (rows: DataRow[], total?: DataRow | null) => void;
|
||||
/** 그 줄로 스크롤하고 강조 */
|
||||
focusRow: (index: number) => void;
|
||||
/** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */
|
||||
edit: (index: number, key?: string) => boolean;
|
||||
}
|
||||
|
||||
/** 나무 아이콘 — 폴더 · 항목(글꼴색을 따름) · 공용 나무(`ui_template_tree` 기본 줄)와 같은 꼴 */
|
||||
export const TREE_ICON = {
|
||||
folder:
|
||||
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
};
|
||||
|
||||
const EMPTY = "-";
|
||||
const ESC: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
|
||||
@@ -169,14 +187,16 @@ function rowHtml(
|
||||
if (!span) continue; // 위 칸에 합쳐짐
|
||||
const lvc =
|
||||
lv?.key === key
|
||||
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}`
|
||||
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}${lv.tree === undefined ? "" : " has-tree"}`
|
||||
: "";
|
||||
// 나무(235-1) = 안내선 + 폴더 · 파일 아이콘 — 글 상자 칸은 안내선만
|
||||
const guide = lv?.key === key ? (lv.tree ?? "") : "";
|
||||
const c = spans ? ` data-c="${j}"` : "";
|
||||
if (edit !== null && editable?.(edit, key)) {
|
||||
const v = r[key];
|
||||
const raw = v === null || v === undefined ? "" : String(v);
|
||||
const tip = esc(cols[j].title ?? cols[j].label);
|
||||
tr += `<td class="is-edit${lvc}" data-num=""${c}><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
tr += `<td class="is-edit${lvc}" data-num=""${c}>${guide}<input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
continue;
|
||||
}
|
||||
const [txt, empty] = formatCell(r[key], fmts[j], blank);
|
||||
@@ -197,7 +217,11 @@ function rowHtml(
|
||||
(fmts[j] ? ' data-num=""' : "") +
|
||||
(span > 1 ? ` rowspan="${span}"` : "") +
|
||||
c;
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
const icon =
|
||||
lv?.key === key && lv.tree !== undefined
|
||||
? `<span class="ui-data-table__icon">${lv.node ? TREE_ICON.folder : TREE_ICON.leaf}</span>`
|
||||
: "";
|
||||
tr += `<td${attrs}>${guide}${icon}${esc(txt)}</td>`;
|
||||
}
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
|
||||
.filter(Boolean)
|
||||
@@ -209,9 +233,10 @@ function rowHtml(
|
||||
: `<tr>${tr}</tr>`;
|
||||
}
|
||||
|
||||
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
|
||||
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · 나무 안내선 HTML(나무 아니면 없음) ·
|
||||
* spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
|
||||
interface RowMore {
|
||||
lv?: { key: string; n: number; node: boolean };
|
||||
lv?: { key: string; n: number; node: boolean; tree?: string };
|
||||
spans?: number[];
|
||||
}
|
||||
|
||||
@@ -248,6 +273,25 @@ export function mergeSpans(o: DataTableOptions, from: number, to: number): numbe
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 나무 안내선(235-1) — 줄마다 칸(한 단 = 들여쓰기 한 칸 · 레벨 2 부터) span HTML.
|
||||
* 자기 칸 = 아래에 같은 레벨 형제가 더 있으면 ├(is-tee) · 끝이면 └(is-end) · 윗대 칸 = 그 윗대 형제가 아래에 더 있으면 │(is-bar) */
|
||||
export function treeGuides(levels: number[]): string[] {
|
||||
const out: string[] = new Array(levels.length);
|
||||
const more: boolean[] = []; // 레벨 d 형제가 아래에 더 있음(더 얕은 줄 전까지)
|
||||
const span = (cls: string, slot: number) =>
|
||||
`<span class="ui-data-table__guide ${cls}" style="--g:${slot}"></span>`;
|
||||
for (let i = levels.length - 1; i >= 0; i--) {
|
||||
const lv = Math.max(1, levels[i]);
|
||||
let html = "";
|
||||
for (let d = 2; d < lv; d++) if (more[d]) html += span("is-bar", d - 2);
|
||||
if (lv >= 2) html += span(more[lv] ? "is-tee" : "is-end", lv - 2);
|
||||
out[i] = html;
|
||||
more[lv] = true;
|
||||
more.length = lv + 1; // 더 깊은 레벨 = 이 줄에서 끊김
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
|
||||
export interface RowWindow {
|
||||
from: number;
|
||||
@@ -284,6 +328,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
const blank = o.empty ?? EMPTY;
|
||||
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
|
||||
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
|
||||
const tree = o.level?.tree ? treeGuides(o.rows.map((_r, i) => o.level!.of(i) ?? 1)) : null;
|
||||
// 더블클릭 입력(235-1) — 글 상자 = 지금 고치는 칸만
|
||||
const inputAt = o.editOnDouble
|
||||
? (i: number, key: string) =>
|
||||
o.editing?.index === i && o.editing.key === key && !!o.editable?.(i, key)
|
||||
: o.editable;
|
||||
for (let i = from; i < to; i++) {
|
||||
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
|
||||
const node = o.level ? isNode(o.level, i, n) : false;
|
||||
@@ -296,12 +346,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
blank,
|
||||
pick ? i : null,
|
||||
i,
|
||||
o.editable,
|
||||
inputAt,
|
||||
o.focus === i,
|
||||
o.rowClass?.(i),
|
||||
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
|
||||
{
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN, node } } : {}),
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}),
|
||||
...(spans ? { spans: spans[i - from] } : {}),
|
||||
},
|
||||
);
|
||||
@@ -419,14 +469,62 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
const key = input.dataset.k;
|
||||
setTimeout(() => state.onEdit?.(index, key, input.value));
|
||||
});
|
||||
root.addEventListener("click", (event) => {
|
||||
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return;
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
|
||||
/** 누른 칸 [줄, 열 키] — 본문 줄 밖 = null */
|
||||
const cellAt = (target: EventTarget | null): [number, string] | null => {
|
||||
const row = (target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return null;
|
||||
const cell = (target as Element).closest<HTMLTableCellElement>("td");
|
||||
const column = cell ? state.columns[cellColumn(cell)] : undefined;
|
||||
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
|
||||
state.onRowClick?.(Number(row.dataset.i));
|
||||
return [Number(row.dataset.i), column?.key ?? ""];
|
||||
};
|
||||
let lastKey = "";
|
||||
root.addEventListener("click", (event) => {
|
||||
const at = cellAt(event.target);
|
||||
if (!at) return;
|
||||
lastKey = at[1];
|
||||
if (at[1]) state.onCellClick?.(at[0], at[1]);
|
||||
state.onRowClick?.(at[0]);
|
||||
});
|
||||
const edit = (index: number, key?: string): boolean => {
|
||||
if (!state.editOnDouble || !state.editable) return false;
|
||||
const can = state.columns.map((c) => c.key).filter((k) => state.editable!(index, k));
|
||||
const k = [key, lastKey, can[0]].find((x) => x !== undefined && can.includes(x));
|
||||
if (k === undefined) return false;
|
||||
state.editing = { index, key: k };
|
||||
draw(true);
|
||||
const input = root.querySelector<HTMLInputElement>(
|
||||
`input[data-i="${index}"][data-k="${CSS.escape(k)}"]`,
|
||||
);
|
||||
input?.focus();
|
||||
input?.select();
|
||||
return true;
|
||||
};
|
||||
if (opts.editOnDouble) {
|
||||
root.addEventListener("dblclick", (event) => {
|
||||
const at = cellAt(event.target);
|
||||
if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]);
|
||||
});
|
||||
// Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지)
|
||||
root.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return;
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
root.focus();
|
||||
});
|
||||
// 초점 떠남 = 입력 끝 — 다시 그린 같은 칸으로 초점이 돌아왔으면 그대로
|
||||
root.addEventListener("focusout", (event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (!state.editing || !input.dataset?.k) return;
|
||||
setTimeout(() => {
|
||||
const now = document.activeElement as HTMLElement | null;
|
||||
const back = now && root.contains(now) && now.dataset.k === input.dataset.k;
|
||||
if (back && now.dataset.i === input.dataset.i) return;
|
||||
state.editing = null;
|
||||
draw(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
draw(true);
|
||||
if (opts.fillViewport) fillViewport(root, () => draw(false));
|
||||
return {
|
||||
@@ -441,5 +539,6 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
|
||||
draw(true);
|
||||
},
|
||||
edit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import "./ui_template_tree.css";
|
||||
import { enterMenu, leaveMenu } from "./ui_template_context_menu";
|
||||
import { TREE_ICON } from "./ui_template_data_table";
|
||||
import { el } from "./ui_template_elements";
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -28,13 +29,6 @@ export interface TreeNode {
|
||||
folder?: boolean;
|
||||
}
|
||||
|
||||
/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */
|
||||
const ICON = {
|
||||
folder:
|
||||
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
};
|
||||
|
||||
export interface TreeMenuItem {
|
||||
label: string;
|
||||
run: () => void;
|
||||
@@ -130,7 +124,7 @@ export function createTreeRow(
|
||||
): HTMLButtonElement {
|
||||
const branch = !!node.children?.length || !!node.folder;
|
||||
const icon = el("span", { className: "ui-tree__icon" });
|
||||
icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만
|
||||
icon.innerHTML = branch ? TREE_ICON.folder : TREE_ICON.leaf; // 고정 SVG 글만
|
||||
const button = el("button", {
|
||||
className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`,
|
||||
attrs: { type: "button" },
|
||||
|
||||
Reference in New Issue
Block a user