From 2e430110693ce48a0d721b0eb535c69e8283bcaf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 14:40:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(709-1):=20=EC=AA=BD=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=EB=B6=80=ED=92=88=20=C2=B7=20=EC=88=98=ED=8F=89=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=EC=84=A0=20=EB=B6=80=ED=92=88=20=C2=B7=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EB=AA=A8=EC=9D=8C=20=EA=B2=AC=EB=B3=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_pager.ts: M01 buildPager 이동 + 화면 높이 줄 수 셈(fitRows) · 첫 줄 유지(pageKeepFirst) · 화면 크기 알림(watchFit) - ui_template_divider.ts + overlay.css .ui-divider: 수평 구분선 - M01 buildPager = 새 부품을 부르는 옛 이름(PickTable · M02 목록 그대로) - 부품 모음 견본 두 줄 · 지킴 시험 test_709_pager_fit.py --- M01_MasterData/M01_MasterData_UI_Cells.ts | 31 ++------ resources/tester/test_709_pager_fit.py | 77 ++++++++++++++++++ ui_template/ui_template_divider.ts | 15 ++++ ui_template/ui_template_overlay.css | 10 +++ ui_template/ui_template_pager.css | 6 ++ ui_template/ui_template_pager.ts | 97 +++++++++++++++++++++++ ui_template/ui_template_ui_kit_parts.ts | 15 ++++ 7 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_709_pager_fit.py create mode 100644 ui_template/ui_template_divider.ts create mode 100644 ui_template/ui_template_pager.css create mode 100644 ui_template/ui_template_pager.ts 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);