feat(709-1): 쪽 단추 부품 · 수평 구분선 부품 · 부품 모음 견본
- 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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/* 쪽 단추(pager) — 표 바로 아래 한 줄 · 글은 보조 색 */
|
||||
.ui-pager {
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user