diff --git a/M01_MasterData/M01_MasterData_UI_Logic_List.ts b/M01_MasterData/M01_MasterData_UI_Logic_List.ts index e90dd77ad..ef0508908 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_List.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_List.ts @@ -8,6 +8,7 @@ import { createInputField, el } from "@ui/ui_template_elements"; import { aliasText, matchLogic } from "@ui/ui_template_logic_find"; import { buildPager } from "./M01_MasterData_UI_Cells"; +import { pageKeepFirst, watchFit } from "@ui/ui_template_pager"; import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import "./M01_MasterData_UI_Head.css"; @@ -41,7 +42,7 @@ export interface ListHandle { export const logicId = (sub: string, key: string): string => `${sub}\n${key}`; -const SIZE = 30; +const DEFAULT_SIZE = 30; // 붙기 전 어림 — 붙은 뒤 화면 높이로 다시 셈 const COLS = [ "키", "원문번호", @@ -64,6 +65,7 @@ export function buildList( let aliases = new Map(); const aliasOf = (x: ListItem): string => aliases.get(x.id) ?? x.alias; let page = 1; + let size = DEFAULT_SIZE; const pick = { sub: "", detail: "" }; const searchField = createInputField({ type: "search", placeholder: tx("List_Search") }); const search = searchField.input; @@ -84,9 +86,9 @@ export function buildList( 별칭: aliasOf(x), }).ok, ); - page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); - const rows = shown.slice((page - 1) * SIZE, page * SIZE).map(line); - pager.replaceWith((pager = buildPager(shown.length, SIZE, page, go))); + page = Math.min(page, Math.max(1, Math.ceil(shown.length / size))); + const rows = shown.slice((page - 1) * size, page * size).map(line); + pager.replaceWith((pager = buildPager(shown.length, size, page, go))); grid.replaceChildren( el("table", { className: "ui-table m01-master__grid", @@ -144,18 +146,29 @@ export function buildList( }; search.addEventListener("input", () => go(1)); - let pager = buildPager(0, SIZE, 1, go); + let pager = buildPager(0, size, 1, go); const root = el("div", { className: "m01-logic__listview", children: [ el("div", { className: "m01-head__filters", - children: [searchField.root, pager], + children: [searchField.root], }), grid, + pager, ], }); + watchFit( + root, + () => grid, + (rows) => { + if (rows === size) return; + page = pageKeepFirst(page, size, rows); + size = rows; + draw(); + }, + ); return { root, setItems: (logics) => { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts b/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts index f3a892d90..149fe9aa0 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts @@ -7,6 +7,7 @@ import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch"; import { buildPager, flatten, show } from "./M01_MasterData_UI_Cells"; +import { fitRows, PAGER_ROOM, pageKeepFirst, watchFit } from "@ui/ui_template_pager"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { openModal } from "@ui/ui_template_modal"; @@ -29,7 +30,8 @@ const COLS: Record = { ], 기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"], }; -const SIZE = 30; +/** 모달 안 표 칸 + 쪽 단추가 쓸 높이 — 모달 최대 높이(80vh)에서 제목 · 찾기 줄을 뺌 */ +const room = (): number => window.innerHeight * 0.8 - 130; export interface PickTableOptions { kind: PickTableKind; @@ -60,13 +62,14 @@ export function openPickTable(opt: PickTableOptions): void { }); let page = 1; + let size = fitRows(room() - PAGER_ROOM); // 처음 어림 — 붙은 뒤 다시 재서 어긋나면 바로잡음 const tried = new Set([opt.seed]); let seq = 0; const load = async (fallback = false): Promise => { const mine = ++seq; try { const q = search.value.trim(); - const got = await fetchRows(FILES[opt.kind], page, SIZE, q); + const got = await fetchRows(FILES[opt.kind], page, size, q); if (mine !== seq) return; // 최신 응답만 if (!got.total && fallback) { const next = [...(q.includes(" ") ? [q.split(/\s+/)[0]] : []), ...(opt.alt ?? [])].find( @@ -94,10 +97,6 @@ export function openPickTable(opt: PickTableOptions): void { return tr; }); host.replaceChildren( - buildPager(got.total, SIZE, got.page, (next) => { - page = next; - void load(); - }), el("div", { className: "ui-table-wrap m01-master__grid-wrap", children: [ @@ -112,12 +111,28 @@ export function openPickTable(opt: PickTableOptions): void { }), ], }), + buildPager(got.total, size, got.page, (next) => { + page = next; + void load(); + }), ); + fit.ask(); host.querySelector(".is-picked")?.scrollIntoView({ block: "nearest" }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); } }; + const fit = watchFit( + host, + () => host.querySelector(".m01-master__grid-wrap"), + (rows) => { + if (rows === size) return; + page = pageKeepFirst(page, size, rows); + size = rows; + void load(); + }, + { cap: room, once: true }, + ); let timer = 0; search.addEventListener("input", () => { window.clearTimeout(timer); diff --git a/M01_MasterData/M01_MasterData_UI_Rows.ts b/M01_MasterData/M01_MasterData_UI_Rows.ts index 2239e17df..64ae60798 100644 --- a/M01_MasterData/M01_MasterData_UI_Rows.ts +++ b/M01_MasterData/M01_MasterData_UI_Rows.ts @@ -29,8 +29,10 @@ import { withCell, } from "./M01_MasterData_UI_Cells"; import { createEmpty } from "@ui/ui_template_empty"; +import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager"; -const SIZE = 50; +/** 표 칸 위에 오는 도구줄(단추 줄 + 간격) 높이 — 첫 줄 수 어림에만 씀 */ +const BAR_H = 52; const MARKET = "재료_자재품목.json"; const LINKED = "재료_품셈재료.json"; const JOB = ["인력.json"]; @@ -94,6 +96,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void const { sub, detail, state } = pick; const file = pick.file.file; let page = pick.view?.page ?? 1; + let size = guessRows(host, BAR_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음 let data: RowsPage | null = null; let unlinked = false; @@ -210,7 +213,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void host.replaceChildren( el("div", { className: "ui-toolbar m01-master__bar", - children: [add, ...only, buildPager(d.total, SIZE, d.page, go)], + children: [add, ...only], }), el("div", { className: "ui-table-wrap m01-master__grid-wrap", @@ -221,8 +224,10 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void }), ], }), + buildPager(d.total, size, d.page, go), ...empty, ); + fit.ask(); }; const go = (next: number): void => { @@ -232,7 +237,7 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void const load = async (): Promise => { try { - data = await fetchRows(file, page, SIZE, q, unlinked, sub, detail, state); + data = await fetchRows(file, page, size, q, unlinked, sub, detail, state); for (const [key, name] of Object.entries(data.refs ?? {})) names.set(key, name); saveView(pick.group, { page: data.page }); paint(); @@ -241,9 +246,22 @@ export function renderRows(host: HTMLElement, pick: Pick, q: string): () => void } }; + const fit = watchFit( + host, + () => host.querySelector(".m01-master__grid-wrap"), + (rows) => { + if (rows === size) return; + page = pageKeepFirst(data?.page ?? page, size, rows); + size = rows; + void load(); + }, + ); const off = onDraftChange(paint); void load(); - return off; + return () => { + fit.stop(); + off(); + }; } function actionCell(label: string, onClick: () => void): HTMLTableCellElement { diff --git a/M01_MasterData/M01_MasterData_UI_Style.css b/M01_MasterData/M01_MasterData_UI_Style.css index 5ee4f67fe..277e16c7f 100644 --- a/M01_MasterData/M01_MasterData_UI_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Style.css @@ -84,9 +84,9 @@ html.is-bm-route .m01-master__headbox { min-width: 0; } -/* 겉 = 표 부품(ui-table-wrap · ui-table) · 여기는 높이 · 폭 · 머리 고정 · 칸 상태만 */ -.m01-master__grid-wrap { - max-height: calc(100dvh - 300px); +/* 겉 = 표 부품(ui-table-wrap · ui-table) · 여기는 높이 · 폭 · 머리 고정 · 칸 상태만 + 쪽 있는 표 = 높이 제한 없음(줄 수를 화면 높이로 셈 · 쪽 단추 부품 watchFit) · 쪽 없는 한 장 표(--sheet) = 화면 아래까지 안에서 스크롤 */ +.m01-master__grid-wrap--sheet { overflow: auto; } @@ -130,13 +130,9 @@ table.m01-master__grid { padding: 2px var(--spacing-4); } -.m01-master__pager { - display: flex; - align-items: center; - gap: var(--spacing-12); - margin-left: auto; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); +/* 쪽 단추(부품 ui-pager) — 간격은 부품이 줌(표 아래 한 칸) · 이미 칸 사이 간격이 있는 몸통은 겹치지 않게 뺌 */ +.m01-master__body > .ui-table-wrap + .ui-pager { + margin-top: 0; } .m01-master__table-row { diff --git a/M01_MasterData/M01_MasterData_UI_Tables.ts b/M01_MasterData/M01_MasterData_UI_Tables.ts index 75ad992c0..335f2aac7 100644 --- a/M01_MasterData/M01_MasterData_UI_Tables.ts +++ b/M01_MasterData/M01_MasterData_UI_Tables.ts @@ -30,8 +30,10 @@ import { toNumberish, toRange, } from "./M01_MasterData_UI_Cells"; +import { guessRows, pageKeepFirst, watchFit, watchHeight } from "@ui/ui_template_pager"; -const LIST_SIZE = 30; +/** 표 칸 위에 오는 거름 줄 높이 — 첫 줄 수 어림에만 씀 */ +const FILTER_H = 52; const LIST_COLS = ["키", "원문번호", "구분", "상세구분", "이름", "기준", "출처", "비고"] as const; /** 용도 대상 거름 드롭다운 후보 — PLAN 1-1 셋째 줄 그대로. */ const USAGE_TARGETS = ["인력", "기계", "재료", "할증", "환산", "작업량 조건"]; @@ -45,16 +47,22 @@ export function renderTables( ): () => void { let stop = (): void => {}; let usage = pick.view?.usage ?? ""; + let size = guessRows(host, FILTER_H); // 화면 높이로 셈 — 그린 뒤 다시 재서 어긋나면 바로잡음 + let at = 0; // 목록을 보고 있는 쪽(0 = 표 하나를 보는 중) const list = (page = pick.view?.page ?? 1): void => { stop(); stop = (): void => {}; + at = page; void showList( host, pick, q, usage, page, + size, + list, (file, key) => { + at = 0; stop = openTable(host, file, key, () => list(page), openLogic); }, (next) => { @@ -62,11 +70,22 @@ export function renderTables( saveView(pick.group, { usage, page: 1 }); list(1); }, - ); + ).then(() => fit.ask()); }; + const fit = watchFit( + host, + () => host.querySelector(".m01-master__tables"), + (rows) => { + if (!at || rows === size) return; + const page = pageKeepFirst(at, size, rows); + size = rows; + list(page); + }, + ); list(); const off = onDraftChange(() => host.querySelector(".m01-master__tables") && markChanged(host)); return () => { + fit.stop(); stop(); off(); }; @@ -86,12 +105,14 @@ async function showList( q: string, usage: string, page: number, + size: number, + goPage: (page: number) => void, open: (file: string, key: string) => void, onUsageChange: (usage: string) => void, ): Promise { let got: { total: number; tables: TableHead[] }; try { - got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, LIST_SIZE, usage); + got = await fetchTables(pick.group, pick.sub, pick.detail, q, page, size, usage); } catch (error) { showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"); return; @@ -121,12 +142,6 @@ async function showList( usageField.select.addEventListener("change", () => onUsageChange(usageField.select.value)); host.replaceChildren( el("div", { className: "m01-master__filter", children: [usageField.root] }), - buildPager( - got.total, - LIST_SIZE, - page, - (p) => void showList(host, pick, q, usage, p, open, onUsageChange), - ), el("div", { className: "ui-table-wrap m01-master__grid-wrap m01-master__tables", children: [ @@ -141,6 +156,7 @@ async function showList( }), ], }), + buildPager(got.total, size, page, goPage), ); } @@ -153,6 +169,7 @@ function openTable( openLogic: (key: string) => void, ): () => void { let off = (): void => {}; + let unfit = (): void => {}; // 쪽 없는 한 장 표 — 화면 아래까지 높이 묶음 void (async () => { let version: string; let table: Row & { 줄: Row[] }; @@ -211,6 +228,7 @@ function openTable( const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" }); backButton.addEventListener("click", () => { off(); + unfit(); back(); }); const notes = ((table.주 ?? []) as string[]).map((n) => @@ -224,6 +242,24 @@ function openTable( btn.addEventListener("click", () => openLogic(k)); return i === 0 ? btn : el("span", { text: ", ", children: [btn] }); }); + const sheet = el("div", { + className: "ui-table-wrap m01-master__grid-wrap m01-master__grid-wrap--sheet", + children: [ + el("table", { + className: "ui-table m01-master__grid", + children: [ + el("thead", { + children: [ + el("tr", { + children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))], + }), + ], + }), + body, + ], + }), + ], + }); host.replaceChildren( el("div", { className: "ui-toolbar m01-master__bar", @@ -246,29 +282,17 @@ function openTable( add, ], }), - el("div", { - className: "ui-table-wrap m01-master__grid-wrap", - children: [ - el("table", { - className: "ui-table m01-master__grid", - children: [ - el("thead", { - children: [ - el("tr", { - children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))], - }), - ], - }), - body, - ], - }), - ], - }), + sheet, ...notes, ); + unfit(); + unfit = watchHeight(sheet); }; off = onDraftChange(paint); paint(); })(); - return () => off(); + return () => { + off(); + unfit(); + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 6e04e0c2e..708f398f8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -9,6 +9,7 @@ import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { PAGER_ROOM, pageKeepFirst, watchFit } from "@ui/ui_template_pager"; import { dateText } from "../common_util/common_util_time"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; @@ -160,6 +161,7 @@ export interface ItemListHandle { export function buildItemList(): ItemListHandle { let draw = (): void => {}; let page = 1; + let size = SIZE; // 붙은 뒤 화면 높이로 다시 셈 const searchField = createInputField({ type: "search", placeholder: TEXT.search }); const search = searchField.input; const grid = el("div", { className: "ui-table-wrap m01-master__grid-wrap" }); @@ -176,9 +178,9 @@ export function buildItemList(): ItemListHandle { ): void => { page = 1; draw = (): void => { - const got = pageOf(rows, cols, search.value, page); + const got = pageOf(rows, cols, search.value, page, size); page = got.page; - const next = buildPager(got.total, SIZE, page, (p) => { + const next = buildPager(got.total, size, page, (p) => { page = p; draw(); }); @@ -230,8 +232,23 @@ export function buildItemList(): ItemListHandle { }); const root = el("div", { className: "m02-itemlist", - children: [buildHeadFilters([searchField.root, pager]), grid], + children: [buildHeadFilters([searchField.root]), grid, pager], }); + // 모달 안 목록 — 모달이 줄 수에 따라 움직이니 화면 위치 대신 높이 한도로 셈 + watchFit( + root, + () => grid, + (rows) => { + if (rows === size) return; + page = pageKeepFirst(page, size, rows); + size = rows; + draw(); + }, + { + cap: () => Math.min(window.innerHeight * 0.6, window.innerHeight - 300) + PAGER_ROOM, + once: true, + }, + ); // 처음 = 빈 표 자리(줄이 오면 `show`) note(TEXT.loading); return { root, show, message: (text = TEXT.loading) => note(text) }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 9023227ed..7a1be7e48 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -16,6 +16,7 @@ import { import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { pageKeepFirst, watchFit } from "@ui/ui_template_pager"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { @@ -103,6 +104,7 @@ export function mountLibraryList( let rows: Row[] = []; let picked: string | null = null; let page = 1; + let size = SIZE; // 붙은 뒤 화면 높이로 다시 셈 const searchField = createInputField({ type: "search", placeholder: tc("Search") }); const body = el("tbody"); let pager = buildPager(0, SIZE, 1, () => {}); @@ -116,8 +118,8 @@ export function mountLibraryList( const draw = (): void => { const q = searchField.input.value.trim().toLowerCase(); const shown = rows.filter((r) => !q || libraryLabel(r).toLowerCase().includes(q)); - page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); - const next = buildPager(shown.length, SIZE, page, (p) => { + page = Math.min(page, Math.max(1, Math.ceil(shown.length / size))); + const next = buildPager(shown.length, size, page, (p) => { page = p; draw(); }); @@ -125,7 +127,7 @@ export function mountLibraryList( pager = next; body.replaceChildren( ...(shown.length - ? shown.slice((page - 1) * SIZE, page * SIZE).map((row) => { + ? shown.slice((page - 1) * size, page * size).map((row) => { const radio = el("input", { attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 }, }) as HTMLInputElement; @@ -252,25 +254,34 @@ export function mountLibraryList( "Lib_Base", "Lib_Date", ] as const; - host.replaceChildren( - buildHeadFilters([searchField.root, pager]), - el("div", { - className: "ui-table-wrap m01-master__grid-wrap", - attrs: { "data-lib-list": opt.kind }, - children: [ - el("table", { - className: "ui-table m01-master__grid", - children: [ - el("thead", { - children: [ - el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }), - ], - }), - body, - ], - }), - ], - }), + const gridBox = el("div", { + className: "ui-table-wrap m01-master__grid-wrap", + attrs: { "data-lib-list": opt.kind }, + children: [ + el("table", { + className: "ui-table m01-master__grid", + children: [ + el("thead", { + children: [ + el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }), + ], + }), + body, + ], + }), + ], + }); + host.replaceChildren(buildHeadFilters([searchField.root]), gridBox, pager); + watchFit( + host, + () => gridBox, + (rows) => { + if (rows === size) return; + page = pageKeepFirst(page, size, rows); + size = rows; + draw(); + }, + { once: true }, ); if (opt.canEdit) tools.replaceChildren( diff --git a/resources/tester/test_709_pager_fit.py b/resources/tester/test_709_pager_fit.py index c26ab9d5d..acf39b349 100644 --- a/resources/tester/test_709_pager_fit.py +++ b/resources/tester/test_709_pager_fit.py @@ -21,7 +21,7 @@ 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("/** 표 칸 위쪽") + end = text.index("/* ── 아래부터 화면 재기") return text[start:end] @@ -44,7 +44,7 @@ def test_쪽_수(): def test_화면_높이_줄_수(): - # 머리 40 + 줄 40 · 표 칸 위 200 · 쪽 단추 자리 56 → 1080: (824-40)/40=19 · 768: (512-40)/40=11 + # 머리 40 + 줄 40 · 표 칸 위 200 · 쪽 단추 자리 64 → 1080: (816-40)/40=19 · 768: (504-40)/40=11 got = _run( "console.log(JSON.stringify([" "fitRows(1080-200-PAGER_ROOM), fitRows(768-200-PAGER_ROOM), " diff --git a/resources/tester/ui_checks/51-10_서고_표양식.js b/resources/tester/ui_checks/51-10_서고_표양식.js index 0b472b6ef..e0a3171de 100644 --- a/resources/tester/ui_checks/51-10_서고_표양식.js +++ b/resources/tester/ui_checks/51-10_서고_표양식.js @@ -5,7 +5,7 @@ * 쓰기 없음 — 서고 목록 · 문서 읽기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) * 기대 수치: * - table.wrap = "m01-master__grid-wrap" · table.table = "m01-master__grid" · table.row = "m01-master__table-row" - * - table.filters = true(찾기 칸 + 쪽 `m01-master__pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음) + * - table.filters = true(찾기 칸 + 표 바로 아래 쪽 단추 `ui-pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음) * - table.search = 1(「값 없음」 치면 한 줄) * - buttons.variants = ["filled", "ghost", "danger"](M01 [신규 로직] · [로직 복사/생성] · [로직 지우기] 와 같음) · buttons.redDelete = true * - precombo.add = "로직 추가" · picker.label = "로직 고르기" · picker.kinds ⊂ {단가승격, 일위조합} · picker.logicRows = 0 @@ -100,7 +100,7 @@ row: wrap.querySelector("[data-lib-key]").className, filters: !!wrap.parentElement.querySelector(".m01-head__filters input[type=search]") && - !!wrap.parentElement.querySelector(".m01-head__filters .m01-master__pager"), + wrap.nextElementSibling?.classList.contains("ui-pager") === true, sameCell: box(td) === box(ref.querySelector("td")), cell: box(td), }; diff --git a/ui_template/ui_template_pager.css b/ui_template/ui_template_pager.css index df0e27651..70179f079 100644 --- a/ui_template/ui_template_pager.css +++ b/ui_template/ui_template_pager.css @@ -1,6 +1,10 @@ -/* 쪽 단추(pager) — 표 바로 아래 한 줄 · 글은 보조 색 */ +/* 쪽 단추(pager) — 표 바로 아래 한 줄(표와 간격 한 칸) · 글은 보조 색 */ .ui-pager { gap: var(--spacing-12); color: var(--color-text-secondary); font-size: var(--text-body-sm); } + +.ui-table-wrap + .ui-pager { + margin-top: var(--spacing-12); +} diff --git a/ui_template/ui_template_pager.ts b/ui_template/ui_template_pager.ts index 33d2b5e64..a4ac7a58d 100644 --- a/ui_template/ui_template_pager.ts +++ b/ui_template/ui_template_pager.ts @@ -5,7 +5,8 @@ * 크기 = 도구줄 단 없음(안 단추 = 보조 단추 기본 높이) * 상태 = 첫 쪽 이전 막힘 · 끝 쪽 다음 막힘 (`disabled`) * 쓰는 법 = createPager(total, size, page, (next) => …) — 표 **바로 아래**에 놓음 - * 표 줄 수 = `fitRows(남은 높이)` 순수 셈 · `watchFit(표 칸, (줄 수) => …)` 가 화면 크기 바뀔 때 다시 알림 + * 표 줄 수 = `fitRows(남은 높이)` 순수 셈 · `watchFit(바깥 칸, 표 칸 꺼내기, (줄 수) => …)` 가 그린 뒤 · 화면 크기 바뀔 때 알림 + * 한 장 표(쪽 없음) = `watchHeight(표 칸)` 화면 아래까지 높이 묶고 안에서 스크롤 * 옛 이름 = M01 `buildPager`(`M01_MasterData_UI_Cells.ts`) 는 이 부품을 부르는 얇은 껍데기 * ========================================================================== */ @@ -16,8 +17,8 @@ import "./ui_template_pager.css"; /** 표 한 줄 높이(px) · 머리 높이 — 실제 줄이 있으면 잰 값을 씀 */ export const ROW_H = 40; -/** 표 밑 쪽 단추 줄 + 여백(px) */ -export const PAGER_ROOM = 56; +/** 표 밑 쪽 단추 줄(32) + 간격(12) + 판 아래 여백(16) + 여유(4) */ +export const PAGER_ROOM = 64; /** 표가 보일 수 있는 줄 수 아래 한도 · 위 한도 */ export const MIN_ROWS = 5; export const MAX_ROWS = 200; @@ -36,39 +37,98 @@ export function fitRows(room: number, rowH = ROW_H, headH = ROW_H): number { 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 interface FitOptions { + /** 화면 위치 대신 쓸 높이 한도(px) — 모달처럼 위치가 줄 수에 따라 움직이는 곳 */ + cap?: () => number; + /** 사라지면(떼어지면) 알림을 그침 — 열 때마다 새로 만드는 목록용 */ + once?: boolean; } -/** 표 칸이 붙은 뒤 줄 수를 재서 알림 + 창 크기 바뀔 때마다 다시. 돌려받은 함수 = 해제. */ -export function watchFit(grid: HTMLElement, onRows: (rows: number) => void): () => void { +/** 표 칸 위쪽 자리 ~ 화면 아래 사이로 들어가는 줄 수. 표 칸이 안 보이면 null. */ +function measureRows(grid: HTMLElement, opt: FitOptions): number | null { + if (!grid.isConnected || !grid.offsetParent) return null; + const body = grid.querySelector("tbody"); + const count = body?.rows.length ?? 0; + const head = grid.querySelector("thead"); + const room = opt.cap ? opt.cap() : window.innerHeight - grid.getBoundingClientRect().top; + // 줄 높이 = 그린 줄들의 평균(작은 글 · 두 줄 칸이 섞여도 맞음) · 줄 없으면 40 + const rowH = count ? body!.offsetHeight / count : ROW_H; + return fitRows(room - PAGER_ROOM, rowH, head?.offsetHeight || ROW_H); +} + +export interface Fit { + /** 그린 뒤 다시 재라고 알림 */ + ask: () => void; + stop: () => void; +} + +/** 표가 그려진 뒤 · 창 크기 바뀔 때 줄 수를 재서 **바뀌었을 때만** 알림. `root` = 늘 붙어 있는 바깥 칸. */ +export function watchFit( + root: HTMLElement, + getGrid: () => HTMLElement | null, + onRows: (rows: number) => void, + opt: FitOptions = {}, +): Fit { let last = 0; let frame = 0; + let seen = false; + let late = 0; + const stop = (): void => { + window.clearTimeout(late); + window.removeEventListener("resize", ask); + watcher?.disconnect(); + if (frame) cancelAnimationFrame(frame); + }; const check = (): void => { frame = 0; - const n = measureRows(grid); + if (opt.once) { + if (root.isConnected) seen = true; + else if (seen) return stop(); + } + const grid = getGrid(); + if (grid) watcher?.observe(grid); // 글꼴이 늦게 실려 줄 높이가 바뀌어도 다시 잼 + const n = grid ? measureRows(grid, opt) : null; if (n !== null && n !== last) { last = n; onRows(n); } }; - const ask = (): void => { + function ask(): void { if (!frame) frame = requestAnimationFrame(check); - }; + // 위 칸(거름 · 찾기)이 늦게 자리 잡아 표 위치만 밀리면 크기 변화가 안 잡힘 — 한 번 더 잼 + window.clearTimeout(late); + late = window.setTimeout(check, 400); + } window.addEventListener("resize", ask); + const watcher = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(ask); + watcher?.observe(root); // 숨었다 다시 보일 때 · 위 칸이 늘고 줄 때 ask(); - return () => { - window.removeEventListener("resize", ask); - if (frame) cancelAnimationFrame(frame); + return { ask, stop }; +} + +/** 첫 그림 전에 쓸 줄 수 어림 — `above` = 표 칸 위에 올 줄(px). */ +export function guessRows(host: HTMLElement, above = 0, cap?: () => number): number { + const room = cap ? cap() : window.innerHeight - host.getBoundingClientRect().top - above; + return fitRows(room - PAGER_ROOM); +} + +/** 쪽 없는 한 장 표 — 표 칸 위 ~ 화면 아래로 높이를 묶고 안에서 스크롤. 돌려받은 함수 = 해제. */ +export function watchHeight(grid: HTMLElement, pad = 16): () => void { + const set = (): void => { + if (!grid.isConnected || !grid.offsetParent) return; + // 표 아래에 붙은 칸(주 · 안내)이 있으면 그 높이 + 칸 사이 간격(12)만큼 비워 둠 + let below = 0; + for (let n = grid.nextElementSibling; n; n = n.nextElementSibling) + below += (n as HTMLElement).offsetHeight + 12; + const room = window.innerHeight - grid.getBoundingClientRect().top - pad - below; + grid.style.maxHeight = `${Math.max(120, Math.floor(room))}px`; }; + const frame = (): number => requestAnimationFrame(set); + window.addEventListener("resize", set); + frame(); + return () => window.removeEventListener("resize", set); } /** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */ diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 9543f67cf..0ca6d8c27 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -148,9 +148,9 @@ 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 견본 — 쪽 단추(가운데 쪽 · 한 장뿐이라 둘 다 막힘) · 수평 구분선(소제목 사이) */ +/** 709-1 견본 — 쪽 단추(가운데 쪽 · 한 장뿐이라 이전 · 다음 다 막힘) · 수평 구분선(소제목 사이) */ const pagerDemo = (s: KitState): HTMLElement | null => - s === "default" ? createPager(1234, 30, 2, noop) : s === "disabled" ? createPager(12, 30, 1, noop) : null; + s === "default" ? row(createPager(1234, 30, 2, noop), createPager(12, 30, 1, noop)) : null; const dividerDemo = (s: KitState): HTMLElement | null => { if (s !== "default") return null; const box = document.createElement("div");