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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({")
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user