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
+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) {