Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-11-01 14:43:32 +09:00
7 changed files with 225 additions and 26 deletions
+5 -26
View File
@@ -1,10 +1,10 @@
/* =============================================================================
* M01_MasterData_UI_Cells.ts
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용)
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 (요소·표 화면 공용) · 쪽 단추 옛 이름
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { createPager } from "@ui/ui_template_pager";
import type { Row } from "./M01_MasterData_Api_Fetch";
/** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */
@@ -94,29 +94,8 @@ export function startEdit(td: HTMLElement, text: string, commit: (value: string)
input.addEventListener("blur", () => finish(true));
}
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
export function buildPager(
total: number,
size: number,
page: number,
goto: (page: number) => void,
): HTMLElement {
const last = Math.max(1, Math.ceil(total / size));
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
prev.addEventListener("click", () => goto(page - 1));
next.addEventListener("click", () => goto(page + 1));
return el("div", {
className: "ui-toolbar m01-master__pager",
children: [
el("span", {
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
}),
prev,
next,
],
});
}
/** 쪽 나눔 띠 — 부품 `createPager` 를 부르는 옛 이름(PickTable · M02 목록이 씀). */
export const buildPager = createPager;
/** 칸 하나 — 노랑(고침) · 눌러 고침. */
export function buildCell(
+77
View File
@@ -0,0 +1,77 @@
"""709 쪽 단추 부품 지킴 시험 — 쪽 수 · 화면 높이 → 줄 수 · 쪽 크기 바뀔 때 첫 줄 유지(순수 함수).
`ui_template/ui_template_pager.ts` 의 「순수 셈」 구간만 떼어 node 로 돌림(화면 · 서버 없음).
부품 모음 견본 · 옛 이름 `buildPager` 가 새 부품을 부르는지도 글로 잡음.
"""
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
PAGER = ROOT / "ui_template" / "ui_template_pager.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _pure() -> str:
text = PAGER.read_text(encoding="utf-8")
start = text.index("export const ROW_H")
end = text.index("/** 표 칸 위쪽")
return text[start:end]
def _run(body: str) -> object:
script = _pure() + "\n" + body
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
def test_쪽_수():
got = _run(
"console.log(JSON.stringify([pageCount(0,30), pageCount(30,30), pageCount(31,30), pageCount(1469,30)]));"
)
assert got == [1, 1, 2, 49]
def test_화면_높이_줄_수():
# 머리 40 + 줄 40 · 표 칸 위 200 · 쪽 단추 자리 56 → 1080: (824-40)/40=19 · 768: (512-40)/40=11
got = _run(
"console.log(JSON.stringify(["
"fitRows(1080-200-PAGER_ROOM), fitRows(768-200-PAGER_ROOM), "
"fitRows(100), fitRows(100000), fitRows(Number.NaN), fitRows(520, 36, 40)]));"
)
assert got == [19, 11, 5, 200, 5, 13]
def test_높이가_클수록_줄이_많음():
got = _run(
"const a=[]; for (let h=300; h<=1400; h+=50) a.push(fitRows(h)); "
"console.log(JSON.stringify(a.every((v,i)=>i===0||v>=a[i-1])));"
)
assert got is True
def test_쪽_크기_바뀌어도_첫_줄_유지():
# 30줄 쪽의 3쪽(첫 줄 61번째) → 20줄 쪽이면 4쪽(61~80) · 50줄 쪽이면 2쪽(51~100)
got = _run(
"console.log(JSON.stringify([pageKeepFirst(3,30,20), pageKeepFirst(3,30,50), pageKeepFirst(1,30,12)]));"
)
assert got == [4, 2, 1]
def test_옛_이름은_새_부품():
cells = (ROOT / "M01_MasterData" / "M01_MasterData_UI_Cells.ts").read_text(encoding="utf-8")
assert re.search(r"export const buildPager = createPager;", cells)
assert "createButton" not in cells
parts = (ROOT / "ui_template" / "ui_template_ui_kit_parts.ts").read_text(encoding="utf-8")
assert 'file: "ui_template_pager.ts"' in parts and 'file: "ui_template_divider.ts"' in parts
+15
View File
@@ -0,0 +1,15 @@
/* =============================================================================
* ui_template_divider.ts
* 부품 「수평 구분선」 `divider` — 왼쪽 판 칸 사이 · 패널 안 소제목 사이 · 시험 계산 옵션 / 자재 사이
* 꼴 = 가로 한 줄 `<hr class="ui-divider">` (모양 = `ui_template_overlay.css`) · 색 = 공통 값 `--color-border`
* 쓰는 법 = createDivider() — 위아래 간격 = `--spacing-8`
* (세로 나눔선 = 도구줄 `ui-toolbar__divider` · 소제목 밑줄 = 패널 머리 `divider` 꼴)
* ========================================================================== */
import "./ui_template_overlay.css";
export function createDivider(): HTMLHRElement {
const hr = document.createElement("hr");
hr.className = "ui-divider";
return hr;
}
+10
View File
@@ -567,3 +567,13 @@ html.is-bm-route[data-touch="on"]
)[hidden] {
display: none !important;
}
/* 수평 구분선(709-1) — 왼쪽 판 칸 사이 · 패널 안 소제목 사이 · 세로 나눔선은 도구줄 부품 */
.ui-divider {
flex: none;
align-self: stretch;
height: 0;
margin: var(--spacing-8) 0;
border: 0;
border-top: 1px solid var(--color-border);
}
+6
View File
@@ -0,0 +1,6 @@
/* 쪽 단추(pager) — 표 바로 아래 한 줄 · 글은 보조 색 */
.ui-pager {
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
+97
View File
@@ -0,0 +1,97 @@
/* =============================================================================
* ui_template_pager.ts
* 부품 「쪽 단추」 `pager` — 이전 · 다음 + 「n / N 쪽 · 전체 줄」 한 줄 (M01 `buildPager` 를 옮김)
* 꼴 = 한 줄 도구줄(`ui-toolbar ui-pager`) · 글 → 이전 → 다음 · 쪽이 한 장뿐이어도 그대로 보임
* 크기 = 도구줄 단 없음(안 단추 = 보조 단추 기본 높이)
* 상태 = 첫 쪽 이전 막힘 · 끝 쪽 다음 막힘 (`disabled`)
* 쓰는 법 = createPager(total, size, page, (next) => …) — 표 **바로 아래**에 놓음
* 표 줄 수 = `fitRows(남은 높이)` 순수 셈 · `watchFit(표 칸, (줄 수) => …)` 가 화면 크기 바뀔 때 다시 알림
* 옛 이름 = M01 `buildPager`(`M01_MasterData_UI_Cells.ts`) 는 이 부품을 부르는 얇은 껍데기
* ========================================================================== */
import { createButton } from "./ui_template_button";
import { el } from "./ui_template_elements_base";
import { t as L } from "./ui_template_locale";
import "./ui_template_pager.css";
/** 표 한 줄 높이(px) · 머리 높이 — 실제 줄이 있으면 잰 값을 씀 */
export const ROW_H = 40;
/** 표 밑 쪽 단추 줄 + 여백(px) */
export const PAGER_ROOM = 56;
/** 표가 보일 수 있는 줄 수 아래 한도 · 위 한도 */
export const MIN_ROWS = 5;
export const MAX_ROWS = 200;
/** 전체 줄 · 쪽 크기 → 쪽 수(최소 1). */
export const pageCount = (total: number, size: number): number =>
Math.max(1, Math.ceil(total / Math.max(1, size)));
/** 남은 높이(표 머리 포함) → 줄 수 — 머리 한 줄 빼고 줄 높이로 나눔 · 한도 안으로. */
export function fitRows(room: number, rowH = ROW_H, headH = ROW_H): number {
const n = Math.floor((room - headH) / Math.max(1, rowH));
return Math.min(MAX_ROWS, Math.max(MIN_ROWS, Number.isFinite(n) ? n : MIN_ROWS));
}
/** 쪽 크기가 바뀌어도 보던 첫 줄이 같은 쪽에 남도록 새 쪽 번호. */
export const pageKeepFirst = (page: number, oldSize: number, newSize: number): number =>
Math.floor(((Math.max(1, page) - 1) * oldSize) / Math.max(1, newSize)) + 1;
/** 표 칸 위쪽 자리 ~ 화면 아래 사이로 들어가는 줄 수. 표 칸이 화면에 없으면 null. */
export function measureRows(grid: HTMLElement): number | null {
if (!grid.isConnected) return null;
const top = grid.getBoundingClientRect().top;
if (!grid.offsetParent && getComputedStyle(grid).position !== "fixed") return null; // 숨은 칸
const row = grid.querySelector<HTMLElement>("tbody tr");
const head = grid.querySelector<HTMLElement>("thead");
const rowH = row?.offsetHeight || ROW_H;
const headH = head?.offsetHeight || ROW_H;
return fitRows(window.innerHeight - top - PAGER_ROOM, rowH, headH);
}
/** 표 칸이 붙은 뒤 줄 수를 재서 알림 + 창 크기 바뀔 때마다 다시. 돌려받은 함수 = 해제. */
export function watchFit(grid: HTMLElement, onRows: (rows: number) => void): () => void {
let last = 0;
let frame = 0;
const check = (): void => {
frame = 0;
const n = measureRows(grid);
if (n !== null && n !== last) {
last = n;
onRows(n);
}
};
const ask = (): void => {
if (!frame) frame = requestAnimationFrame(check);
};
window.addEventListener("resize", ask);
ask();
return () => {
window.removeEventListener("resize", ask);
if (frame) cancelAnimationFrame(frame);
};
}
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
export function createPager(
total: number,
size: number,
page: number,
goto: (page: number) => void,
): HTMLElement {
const last = pageCount(total, size);
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
prev.addEventListener("click", () => goto(page - 1));
next.addEventListener("click", () => goto(page + 1));
return el("div", {
className: "ui-toolbar ui-pager",
children: [
el("span", {
className: "ui-pager__text",
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
}),
prev,
next,
],
});
}
+15
View File
@@ -33,6 +33,8 @@ import { createToggle } from "./ui_template_toggle";
import { createBanner } from "./ui_template_banner";
import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
import { createDrop } from "./ui_template_drop";
import { createDivider } from "./ui_template_divider";
import { createPager } from "./ui_template_pager";
import { buildModal, confirmPanel } from "./ui_template_modal";
import { settingCard, settingRow, valueText } from "./ui_template_setting_row";
import { createTable, type Cell, type Column } from "./ui_template_table";
@@ -146,6 +148,17 @@ const pickDemo = (s: KitState): HTMLElement | null => {
if (s === "disabled") [one.root, many.root].forEach((b) => (b.disabled = true));
return row(one.root, many.root);
};
/** 709-1 견본 — 쪽 단추(가운데 쪽 · 한 장뿐이라 둘 다 막힘) · 수평 구분선(소제목 사이) */
const pagerDemo = (s: KitState): HTMLElement | null =>
s === "default" ? createPager(1234, 30, 2, noop) : s === "disabled" ? createPager(12, 30, 1, noop) : null;
const dividerDemo = (s: KitState): HTMLElement | null => {
if (s !== "default") return null;
const box = document.createElement("div");
box.style.cssText = "display:flex;flex-direction:column;width:200px";
const label = (t: string): HTMLElement => Object.assign(document.createElement("span"), { textContent: t });
box.append(label("옵션"), createDivider(), label("자재"));
return box;
};
const openMenu = (s: KitState, items = KIT_MENU): HTMLElement => {
const menu = createRowMenu(items);
menu.classList.add("is-open");
@@ -366,6 +379,8 @@ export const KIT_PARTS: KitPart[] = [
if (s === "error") bar.setError(2); return bar.root; } },
{ key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 왼쪽 · 오른쪽 값 · 조작 · 줄 52 · 좁은 꼴(줄 28)", file: "ui_template_setting_row.ts",
demo: (s) => (s === "default" ? col(settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null)), settingRow("기본 노폭", "새 노선에 먼저 들어감", createInputField({ placeholder: "3.0 m" }).root), settingRow("글꼴 파일", "", createButton({ label: "바꾸기", variant: "ghost", size: 28, onClick: noop }))]), settingCard([settingRow("측면 한계각(°)", "", createInputField({ placeholder: "30", size: 24 }).root, { compact: true }), settingRow("균열 모드", "", valueText("없음"), { compact: true })])) : null) },
{ key: "pager", name: "쪽 단추", group: "compound", kinds: "이전 · 다음 · 「n / N 쪽 · 전체 줄」 · 첫 쪽 이전 막힘 · 끝 쪽 다음 막힘 · 표 바로 아래 · 줄 수 = 화면 높이 셈(fitRows)", file: "ui_template_pager.ts", demo: pagerDemo },
{ key: "divider", name: "수평 구분선", group: "compound", kinds: "가로 한 줄 · 왼쪽 판 칸 사이 · 패널 안 소제목 사이 · 시험 계산 옵션 / 자재 사이", file: "ui_template_divider.ts", demo: dividerDemo },
{ key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 받는 꼴 안내 · 진행 · 오류", file: "ui_template_drop.ts",
demo: (s) => { if (s === "active") return null; const d = createDrop({ accept: ".las,.laz", hint: "LAS · LAZ · 2 GB 까지", onFiles: noop, disabled: s === "disabled" });
if (s === "error") d.setError("LAS · LAZ 파일만 받음"); if (s === "hover" || s === "error") markState(d.root, s);