feat(b09): 탭 상세 안내 글 영역 = 줄 수 변수(--b09-hint-lines 기본 3)만큼 · 넘치면 영역 안 스크롤 · 탭 표 = 공용 fillViewport(창 끝 − 안내 글) · 설계내역서 레벨 안내 + 탭 안내 한 영역 · 공용 spaceBelow 형제 바깥 여백도 셈(페이지 스크롤 7px 없앰) (310 · 요구 203)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-13 18:15:53 +09:00
co-authored by Claude Opus 5.5
parent 4821e3b060
commit e657067bd3
10 changed files with 217 additions and 24 deletions
+4 -4
View File
@@ -1,6 +1,7 @@
/* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */
.b09-estimation {
--b09-hint-lines: 3; /* 203 안내 글 영역 줄 수 — 이 값만 바꿈(높이 · 스크롤 · 표 칸 같이 맞음) */
display: flex;
flex-direction: column;
gap: var(--spacing-8);
@@ -57,7 +58,7 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
white-space: nowrap;
}
/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */
/* 202-1 안내 줄 = 표 · 카드 판 아래 · 203 줄 수 변수만큼 보임 · 넘치면 영역 안 스크롤 */
.b09-estimation .ui-data-table + .ui-general-block__empty,
.b09-estimation .ui-group-cards + .ui-general-block__empty {
margin: var(--spacing-8) 0 0;
@@ -65,9 +66,8 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
.b09-estimation__line-hint {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-height: calc(var(--b09-hint-lines) * 1lh);
overflow-y: auto;
font-size: var(--text-caption);
}
+6 -2
View File
@@ -183,6 +183,7 @@ export function renderB09Estimation(root: HTMLElement): void {
const makeTable = (extra?: (index: number) => string | null): DataTable => {
const made = createDataTable({
...tableOptionsOf(spec, tab),
fillViewport: true, // 203 표 칸 = 창 끝 − 안내 글 영역(팝업 · 인쇄는 그대로)
rowClass: extra
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
@@ -230,7 +231,7 @@ export function renderB09Estimation(root: HTMLElement): void {
);
return made;
};
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 203 안내 글 = 줄 수 변수만큼 · 넘치면 스크롤
// 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1)
if (isGroupTab(tab)) {
return void panel.replaceChildren(
@@ -259,7 +260,10 @@ export function renderB09Estimation(root: HTMLElement): void {
if (range) unpickOnRelease(table.root);
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
if (level) bars.setTools(level.tools);
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
const foot = hint ?? level?.guide;
panel.replaceChildren(table.root, ...(foot ? [foot] : []));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
};
@@ -228,7 +228,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
/* ── 그리기 ───────────────────────────────────────────────────────────── */
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
/** 안내 글 = 줄 수 변수(203 `--b09-hint-lines`)만큼 · 넘치면 영역 안 스크롤 · 가로로 잘렸을 때만 전문(공용 호버 185-3) */
export function lineHint(hint: HTMLElement | null): HTMLElement | null {
if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint;
hint.classList.add("b09-estimation__line-hint");
@@ -1,6 +1,6 @@
"""200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게.
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
- 안내 글 = 203 줄 수 변수만큼(넘치면 스크롤) · 가로로 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
- 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래
- 카드 제목 줄 · 합계 줄 바탕색(200-3)
"""
@@ -78,13 +78,12 @@ def test_안내_글_없으면_호버도_없음():
assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0
def test_CSS_안내_글_줄임표_한_줄():
def test_CSS_안내_글_줄_수_변수():
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
hint = _block(page, ".b09-estimation__line-hint")
for need in (
"overflow: hidden",
"text-overflow: ellipsis",
"white-space: nowrap",
for need in ( # 203 한 줄 줄임표 → 줄 수 변수만큼 · 넘치면 스크롤
"max-height: calc(var(--b09-hint-lines) * 1lh)",
"overflow-y: auto",
"min-width: 0",
):
assert need in hint, need
@@ -200,7 +200,9 @@ def test_셸_배선():
"bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src
assert (
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in src
) # 203 레벨 안내 + 탭 안내 = 한 영역
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
assert "b09-estimation__level" not in lvl
@@ -106,9 +106,8 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert (
"panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []))"
in shell
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 안내 줄 = 맨 아래
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
assert "setTools: bars.setTools" in shell
+185
View File
@@ -0,0 +1,185 @@
"""203 B09 탭 상세 안내 글 영역 = 줄 수 변수(`--b09-hint-lines` 기본 3)만큼 · 넘치면 영역 안 스크롤 · 표 칸 = 창 끝까지.
- CSS: 줄 수 변수 하나 · 안내 글 최대 높이 = 변수 × 줄 높이(1lh) · 세로 스크롤 · 한 줄 줄임표 걷음
- Shell: 탭 표 = 공용 fillViewport(팝업 · 인쇄 표는 그대로) · 설계내역서 레벨 안내 + 탭 안내 = 한 영역
- 공용 spaceBelow: 아래 형제 바깥 여백(B09 안내 글 margin-top)도 셈 — 빼면 페이지 스크롤이 생김
- 실제 브라우저(정적 쪽 · 서버 불필요): 변수 3 · 2 × 줄 많음 · 적음 — 안내 글 높이 · 합계 줄 · 안내 글 자리 · 페이지 스크롤 0
"""
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B09 = ROOT / "B09_Estimation"
UI = ROOT / "ui_template"
def _read(path: Path) -> str:
return path.read_text("utf-8")
def _block(css: str, selector: str) -> str:
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, selector
return m.group(1)
def test_CSS_줄_수_변수_하나():
css = _read(B09 / "B09_Estimation_UI_Page.css")
assert re.search(r"--b09-hint-lines:\s*3;", _block(css, ".b09-estimation"))
assert css.count("--b09-hint-lines:") == 1 # 고칠 자리 하나
hint = _block(css, ".b09-estimation__line-hint")
assert "max-height: calc(var(--b09-hint-lines) * 1lh)" in hint
assert "overflow-y: auto" in hint
for gone in ("white-space: nowrap", "text-overflow: ellipsis"):
assert gone not in hint, gone
def test_Shell_표_창_끝_안내_한_영역():
shell = _read(B09 / "B09_Estimation_UI_Shell.ts")
assert "fillViewport: true" in shell
assert "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
assert "hint.textContent = `${level.guide.textContent} · ${hint.textContent}`" in shell
# 팝업 · 인쇄 표는 창 끝 셈 안 씀(팝업 = 창 안 max-height · 인쇄 = 펼침)
assert "fillViewport" not in _read(B09 / "B09_Estimation_UI_Builders.ts")
assert "fillViewport" not in _read(B09 / "B09_Estimation_UI_Popup.ts")
# ── 실제 브라우저 ─────────────────────────────────────────────────────────────
TOKENS = (
":root { --spacing-4: 4px; --spacing-8: 8px; --spacing-16: 16px; --spacing-24: 24px;"
" --text-caption: 12px; --radius-lg: 8px; }"
" html, body { margin: 0; line-height: 1.5; font-family: sans-serif; }"
)
LONG = " · ".join(["긴 안내 글 시험 문장"] * 40)
SHORT = "줄을 누르면 단가를 낸 탭 · 줄로 이동"
def _module_js() -> str:
"""공용 표 모듈(TS) → 브라우저 ES 모듈(CSS import 뺌)."""
src = UI / "ui_template_data_table.ts"
code = (
"const ts = require(process.argv[1]);"
"const fs = require('fs');"
"const out = ts.transpileModule(fs.readFileSync(process.argv[2], 'utf8'),"
" { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 } });"
"process.stdout.write(out.outputText);"
)
ts_path = ROOT / "config" / "node_modules" / "typescript"
proc = subprocess.run(
["node", "-e", code, str(ts_path), str(src)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return re.sub(r'^import\s+"[^"]+\.css";\s*$', "", proc.stdout, flags=re.M)
MEASURE = """async ([rows, lines, text]) => {
const { createDataTable } = window.dt;
const body = document.querySelector('.b09-estimation');
body.style.setProperty('--b09-hint-lines', String(lines));
const panel = body.querySelector('.b09-estimation__panel');
const table = createDataTable({
columns: [{ key: 'name', label: '이름' }, { key: 'sum', label: '금액', kind: 'number' }],
rows: Array.from({ length: rows }, (_, i) => ({ name: '줄 ' + i, sum: i })),
total: { name: '계', sum: 1 },
fillViewport: true,
});
const hint = document.createElement('p');
hint.className = 'ui-general-block__empty b09-estimation__line-hint';
hint.textContent = text;
panel.replaceChildren(table.root, hint);
await new Promise((r) => setTimeout(r, 300));
const t = table.root.getBoundingClientRect();
const h = hint.getBoundingClientRect();
const foot = table.root.querySelector('tfoot').getBoundingClientRect();
return {
vh: innerHeight,
page: document.scrollingElement.scrollHeight,
lh: parseFloat(getComputedStyle(hint).lineHeight),
hintH: h.height,
hintScrolls: hint.scrollHeight > hint.clientHeight,
tableTop: t.top,
tableBottom: t.bottom,
tableScrolls: table.root.scrollHeight > table.root.clientHeight,
maxH: parseFloat(table.root.style.maxHeight),
hintTop: h.top,
hintBottom: h.bottom,
padB: parseFloat(getComputedStyle(body).paddingBottom),
footBottom: foot.bottom,
};
}"""
@pytest.fixture(scope="module")
def page():
from playwright.sync_api import sync_playwright
css = _read(UI / "ui_template_data_table.css") + _read(B09 / "B09_Estimation_UI_Page.css")
html = (
f"<!doctype html><html><head><style>{TOKENS}{css}</style></head><body>"
"<div class='b09-estimation'><div class='b09-estimation__tabs'><button>탭</button></div>"
"<div class='b09-estimation__panel'></div></div></body></html>"
)
js = _module_js()
with sync_playwright() as p:
browser = p.chromium.launch(channel="msedge", headless=True)
pg = browser.new_page(viewport={"width": 1280, "height": 900})
pg.set_content(html)
pg.add_script_tag(
type="module",
content=f"{js}\nwindow.dt = {{ createDataTable }};",
)
pg.wait_for_function("!!window.dt")
yield pg
browser.close()
@pytest.mark.parametrize("lines", [3, 2])
def test_줄_많음_표_창_끝_합계_줄_고정_안내_글_바닥(page, lines):
out = page.evaluate(MEASURE, [300, lines, LONG])
assert out["hintH"] == pytest.approx(lines * out["lh"], abs=0.5), out # 높이 = 줄 수 × 줄 높이
assert out["hintScrolls"], out # 넘친 글 = 영역 안 스크롤
assert out["tableScrolls"], out
assert out["page"] == out["vh"], out # 페이지 스크롤 0
assert out["hintBottom"] + out["padB"] == pytest.approx(out["vh"], abs=1.5), out # 창 끝까지 씀
assert out["hintTop"] == pytest.approx(out["tableBottom"] + 8, abs=0.5), out # 표 바로 아래
assert out["tableBottom"] - 30 < out["footBottom"] <= out["tableBottom"], (
out
) # 합계 줄 = 표 칸 바닥
def test_줄_수_2_이면_표가_한_줄만큼_더_김(page):
three = page.evaluate(MEASURE, [300, 3, LONG])
two = page.evaluate(MEASURE, [300, 2, LONG])
assert two["maxH"] - three["maxH"] == pytest.approx(three["lh"], abs=1), (two, three)
@pytest.mark.parametrize("lines", [3, 2])
def test_줄_적음_합계_줄_안내_글_끌려_올라옴(page, lines):
out = page.evaluate(MEASURE, [3, lines, LONG])
assert not out["tableScrolls"], out
assert out["tableBottom"] - out["tableTop"] < out["maxH"] - 100, out # 내용만큼(창 끝 아님)
assert out["hintTop"] == pytest.approx(out["tableBottom"] + 8, abs=0.5), out # 표 끝에 붙음
assert out["hintBottom"] < out["vh"] - 300, out # 화면 바닥에 떨어져 있지 않음
assert out["hintH"] == pytest.approx(lines * out["lh"], abs=0.5), out
assert out["footBottom"] <= out["tableBottom"], out
def test_짧은_글은_한_줄만(page):
out = page.evaluate(MEASURE, [300, 3, SHORT])
assert out["hintH"] == pytest.approx(out["lh"], abs=0.5), out
assert not out["hintScrolls"], out
assert out["page"] == out["vh"], out
def test_spaceBelow_형제_바깥_여백_셈():
src = _read(UI / "ui_template_data_table.ts")
assert "px(m.marginTop) + px(m.marginBottom)" in src # 브라우저 배치는 위 시험이 잼
@@ -85,7 +85,9 @@ console.log(JSON.stringify({
def test_표_위_도구_줄_없음_쉘_배선():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "if (level) bars.setTools(level.tools);" in shell
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in shell
assert (
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
) # 203 레벨 안내 + 탭 안내 = 한 영역
assert "...(level ? [level] : [])" not in shell # 표 위 자리 없음
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
lvl = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
+5 -4
View File
@@ -2,7 +2,7 @@
- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음
- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버)
- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표)
- B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) · B09 안내 글 = 203 줄 수 변수만큼(test_203)
- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈)
"""
@@ -108,12 +108,13 @@ def _block(css: str, selector: str) -> str:
return m.group(1)
def test_B09_안내_글_한_줄():
def test_B09_안내_글_줄_수_변수():
css = _read(B09 / "B09_Estimation_UI_Page.css")
block = _block(css, ".b09-estimation__line-hint")
assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block
# 203 한 줄 줄임표 → 줄 수 변수만큼 · 넘치면 스크롤(test_203_b09_hint_lines)
assert "max-height: calc(var(--b09-hint-lines) * 1lh)" in block and "overflow-y: auto" in block
shell = _read(B09 / "B09_Estimation_UI_Shell.ts")
assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄
assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 꼴
assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts")
level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts")
assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level
+3 -2
View File
@@ -383,7 +383,7 @@ function isNode(lv: NonNullable<DataTableOptions["level"]>, i: number, n: number
const OVER = 40;
/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ────────────
* 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
* 아래 자리 = 칸 뒤 형제 요소 높이 · 바깥 여백 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
* 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤.
* ------------------------------------------------------------------------------------------ */
@@ -407,7 +407,8 @@ export function spaceBelow(el: HTMLElement): number {
const gap = px(around.rowGap);
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
const h = s.getBoundingClientRect().height;
if (h) sum += h + gap;
const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top)
if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
}
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
}