feat(B08): 표 칸 최대 높이 = 브라우저 창 아래 끝까지 · 공용 표 fillViewport 옵션 (199-1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-10 14:07:15 +09:00
co-authored by Claude Sonnet 5.5
parent ae37f92d60
commit 706906171b
7 changed files with 297 additions and 1 deletions
@@ -234,6 +234,7 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
frozen: 1,
total: totalRow(table),
caption: "토적표",
fillViewport: true,
});
grid.root.dataset.b08Earthwork = "table";
host.replaceChildren(tableCard(info, grid.root));
@@ -41,6 +41,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
})),
frozen: 1,
caption: "토공집계표",
fillViewport: true,
});
host.replaceChildren(tableCard([info], grid.root));
}
@@ -91,7 +92,14 @@ function haulSheet(
`${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`,
),
);
const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info });
const grid = createDataTable({
columns,
rows,
frozen: 1,
total,
caption: info,
fillViewport: true,
});
host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages));
}
+1
View File
@@ -366,6 +366,7 @@ export async function renderMaterialSheet(
frozen: built.frozen,
empty: "",
caption: BASIS,
fillViewport: true,
editable: (i, key) => built.editable(i, key),
onEdit: (i, key, text) => {
const value = parse(text);
+1
View File
@@ -195,6 +195,7 @@ function buildTable(
frozen: hasMissing ? 2 : 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
fillViewport: true,
});
table.root.dataset.b08Summary = "table";
return table.root;
@@ -0,0 +1,135 @@
/* 창 아래 끝까지 칸(`ui_template/ui_template_data_table.ts` · PLAN 199-1) 단위검증 헬퍼 —
* 트랜스파일해 가짜 DOM(요소 트리 · 창 · ResizeObserver)에서 돌리고 판정할 값을 JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
// ── 가짜 DOM ──
const listeners = { resize: new Set() };
const observers = [];
const win = {
innerHeight: 900,
scrollY: 0,
addEventListener: (t, f) => listeners[t]?.add(f),
removeEventListener: (t, f) => listeners[t]?.delete(f),
};
class RO {
constructor(fn) {
this.fn = fn;
this.targets = new Set();
observers.push(this);
}
observe(el) {
this.targets.add(el);
}
disconnect() {
this.targets.clear();
}
}
function el(name, css = {}, height = 0) {
const node = {
name,
css,
height,
top: 0,
parentElement: null,
nextElementSibling: null,
isConnected: false,
style: {},
getBoundingClientRect() {
return { top: this.top - win.scrollY, height: this.height };
},
};
return node;
}
function link(parent, ...kids) {
kids.forEach((k, i) => {
k.parentElement = parent;
k.nextElementSibling = kids[i + 1] ?? null;
});
parent.kids = kids;
return parent;
}
const sty = (n) => ({
rowGap: n.css.rowGap ?? "normal",
paddingBottom: n.css.paddingBottom ?? "0px",
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
marginBottom: n.css.marginBottom ?? "0px",
});
const doc = { body: null };
const box = { exports: {} };
const sandbox = {
window: win,
document: doc,
getComputedStyle: sty,
ResizeObserver: RO,
};
new Function("exports", "module", "require", ...Object.keys(sandbox), js)(
box.exports,
box,
(p) => (p.endsWith(".css") ? {} : require(p)),
...Object.values(sandbox),
);
const m = box.exports;
// 문서: html > body(padding-bottom 8) > page(padding-bottom 16 · gap 8) > [tabs 40] [card(padding-bottom 12) > [root][note 40]]
const html = el("html");
const body = el("body", { paddingBottom: "8px" });
const page = el("page", { paddingBottom: "16px", rowGap: "8px" });
const tabs = el("tabs", {}, 40);
const card = el("card", { paddingBottom: "12px", marginBottom: "4px" });
const root = el("root");
const note = el("note", {}, 40);
link(html, body);
link(body, page);
link(page, tabs, card);
link(card, root, note);
doc.body = body;
root.top = 200;
const out = { clamp: m.fillHeight(900, 200, 68), small: m.fillHeight(300, 250, 100) };
// 아래 자리 = note(40 + 줄 사이 0) + card 여백(12) + card 바깥 여백(4) + page 아래(16) + body 아래(8)
out.below = m.spaceBelow(root);
out.belowExpected = 40 + 0 + 12 + 4 + 16 + 8;
// 아직 문서에 안 붙음 — 높이 안 건드림
let fits = 0;
const stop = m.fillViewport(root, () => (fits += 1));
out.beforeAttach = root.style.maxHeight ?? null;
out.observed =
observers.some((o) => o.targets.has(root)) && observers.some((o) => o.targets.has(body));
// 붙음 → 관찰자가 부름
[html, body, page, tabs, card, root, note].forEach((n) => (n.isConnected = true));
observers.forEach((o) => o.fn());
out.first = root.style.maxHeight;
out.fits = fits;
// 창 크기 바뀜 → 다시 셈
win.innerHeight = 600;
[...listeners.resize].forEach((f) => f());
out.afterResize = root.style.maxHeight;
// 스크롤해도 같은 값(문서 좌표 기준)
win.scrollY = 80;
[...listeners.resize].forEach((f) => f());
out.afterScroll = root.style.maxHeight;
win.scrollY = 0;
// 아래 형제가 늘어남 → 그만큼 줄어듦
note.height = 100;
observers.forEach((o) => o.fn());
out.afterNoteGrow = root.style.maxHeight;
// 칸이 떨어지면 멈춤
root.isConnected = false;
[...listeners.resize].forEach((f) => f());
out.listenersAfterDetach = listeners.resize.size;
stop();
console.log(JSON.stringify(out));
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""PLAN 199-1 — B08 표 칸 최대 높이 = 브라우저 창 아래 끝까지.
Node 헬퍼(`helper_ui_fill_viewport.cjs`)가 `ui_template_data_table.ts` 의 창 끝까지 셈을 가짜 DOM 에서 돌리고,
여기서 옵션 · 셈 · 창 크기 바뀜 · 합계 줄 고정 · B08 적용을 판정함.
"""
import json
import os
import re
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
return f.read()
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_ui_fill_viewport.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_최대_높이_창_기준_셈(r):
assert r["clamp"] == 900 - 200 - 68
assert r["small"] == 120 # 아주 작은 창에서도 머리 + 몇 줄은 남김
assert r["below"] == r["belowExpected"] == 80 # 뒤 형제 + 윗대 아래 여백
def test_붙은_뒤에_셈(r):
assert r["beforeAttach"] is None # 문서에 붙기 전엔 안 건드림
assert r["observed"] # 칸 + 문서 본문(body) 둘 다 관찰
assert r["first"] == f"{900 - 200 - 80}px" # 620
assert r["fits"] >= 1
def test_창_크기_바뀌면_다시_셈(r):
assert r["afterResize"] == f"{600 - 200 - 80}px" # 320
def test_스크롤_위치와_무관(r):
assert r["afterScroll"] == r["afterResize"]
def test_아래_요소가_늘면_그만큼_줄어듦(r):
assert r["afterNoteGrow"] == f"{600 - 200 - 140}px" # 260
def test_칸이_떨어지면_창_청취_해제(r):
assert r["listenersAfterDetach"] == 0
def test_공용_표_옵션과_연결():
ts = _read("ui_template", "ui_template_data_table.ts")
assert re.search(r"fillViewport\?: boolean", ts)
assert re.search(r"if \(opts\.fillViewport\) fillViewport\(root, \(\) => draw\(false\)\)", ts)
def test_합계_줄_고정_그대로():
css = _read("ui_template", "ui_template_data_table.css")
assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S)
assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로
@pytest.mark.parametrize(
"name, count",
[
("B08_Quantity_UI_Summary.ts", 1),
("B08_Quantity_UI_Earthwork.ts", 1),
("B08_Quantity_UI_Earthwork_Sheets.ts", 2),
("B08_Quantity_UI_Material.ts", 1),
],
)
def test_B08_표_모두_적용(name, count):
src = _read("B08_Quantity", name)
assert src.count("fillViewport: true") == count == src.count("createDataTable({")
+61
View File
@@ -10,6 +10,7 @@
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -62,6 +63,8 @@ export interface DataTableOptions {
rowClass?: (index: number) => string | null | undefined;
/** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */
cellClass?: (index: number, key: string) => string | null | undefined;
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
fillViewport?: boolean;
}
export interface DataTable {
@@ -240,6 +243,63 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
const OVER = 40;
/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ────────────
* 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
* 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤.
* ------------------------------------------------------------------------------------------ */
/** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */
const MIN_HEIGHT = 120;
/** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */
export function fillHeight(viewport: number, top: number, below: number): number {
return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below));
}
const px = (v: string): number => parseFloat(v) || 0;
/** 칸 아래로 문서 끝까지 차지하는 자리 */
export function spaceBelow(el: HTMLElement): number {
let sum = 0;
for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) {
const parent = node.parentElement;
const own = getComputedStyle(node);
const around = getComputedStyle(parent);
const gap = px(around.rowGap);
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
const h = s.getBoundingClientRect().height;
if (h) sum += h + gap;
}
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
}
return sum;
}
export function fillViewport(el: HTMLElement, onFit?: () => void): () => void {
let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤
let observer: ResizeObserver | null = null;
const fit = (): void => {
if (!el.isConnected) {
if (seen) stop();
return;
}
seen = true;
const top = el.getBoundingClientRect().top + window.scrollY;
el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`;
onFit?.();
};
const stop = (): void => {
window.removeEventListener("resize", fit);
observer?.disconnect();
};
window.addEventListener("resize", fit);
// 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈
observer = new ResizeObserver(fit);
observer.observe(el);
observer.observe(document.body);
return stop;
}
export function createDataTable(opts: DataTableOptions): DataTable {
const root = document.createElement("div");
root.className = "ui-data-table";
@@ -287,6 +347,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
state.onRowClick?.(Number(row.dataset.i));
});
draw(true);
if (opts.fillViewport) fillViewport(root, () => draw(false));
return {
root,
setRows(rows, total) {