diff --git a/M01_MasterData/M01_MasterData_UI_Cells.ts b/M01_MasterData/M01_MasterData_UI_Cells.ts
index 892238baa..fead68a7d 100644
--- a/M01_MasterData/M01_MasterData_UI_Cells.ts
+++ b/M01_MasterData/M01_MasterData_UI_Cells.ts
@@ -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(
diff --git a/resources/tester/test_709_pager_fit.py b/resources/tester/test_709_pager_fit.py
new file mode 100644
index 000000000..c26ab9d5d
--- /dev/null
+++ b/resources/tester/test_709_pager_fit.py
@@ -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
diff --git a/ui_template/ui_template_divider.ts b/ui_template/ui_template_divider.ts
new file mode 100644
index 000000000..09693f23d
--- /dev/null
+++ b/ui_template/ui_template_divider.ts
@@ -0,0 +1,15 @@
+/* =============================================================================
+ * ui_template_divider.ts
+ * 부품 「수평 구분선」 `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;
+}
diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css
index 0c7a2cde9..e5ceff61f 100644
--- a/ui_template/ui_template_overlay.css
+++ b/ui_template/ui_template_overlay.css
@@ -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);
+}
diff --git a/ui_template/ui_template_pager.css b/ui_template/ui_template_pager.css
new file mode 100644
index 000000000..df0e27651
--- /dev/null
+++ b/ui_template/ui_template_pager.css
@@ -0,0 +1,6 @@
+/* 쪽 단추(pager) — 표 바로 아래 한 줄 · 글은 보조 색 */
+.ui-pager {
+ gap: var(--spacing-12);
+ color: var(--color-text-secondary);
+ font-size: var(--text-body-sm);
+}
diff --git a/ui_template/ui_template_pager.ts b/ui_template/ui_template_pager.ts
new file mode 100644
index 000000000..33d2b5e64
--- /dev/null
+++ b/ui_template/ui_template_pager.ts
@@ -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("tbody tr");
+ const head = grid.querySelector("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,
+ ],
+ });
+}
diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts
index 1d62c05b0..9543f67cf 100644
--- a/ui_template/ui_template_ui_kit_parts.ts
+++ b/ui_template/ui_template_ui_kit_parts.ts
@@ -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);