Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-10 23:40:26 +09:00
6 changed files with 150 additions and 11 deletions
@@ -6,6 +6,8 @@
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
* 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 ·
* 누른 줄만 고름(is-focus) · Esc = 고름도 풂
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래
* 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1)
@@ -17,6 +19,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info";
import {
clampColumns,
createGroupCards,
unpickCards,
type GroupCard,
type GroupFoot,
type GroupLine,
@@ -185,6 +188,21 @@ export function lineHint(hint: HTMLElement | null): HTMLElement | null {
return hint;
}
/** 248-1 지금 그린 묶음 판 빨강 풀기(esc = 고름도) — 방향키 · Esc 가 부름 */
let live: { root: HTMLElement; unpick: (esc: boolean) => void } | null = null;
let keysOn = false;
const UNPICK_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Escape"]);
function listenKeys(): void {
if (keysOn) return;
keysOn = true;
document.addEventListener("keydown", (event) => {
if (!live?.root.isConnected || !UNPICK_KEYS.has(event.key)) return;
if ((event.target as Element | null)?.closest?.("input, textarea, select")) return;
live.unpick(event.key === "Escape");
});
}
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
const chosen = new Map<TabId, GroupView>();
@@ -227,11 +245,28 @@ export function renderGroupPanel(o: {
placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽
});
cards.dataset.b09Table = o.tab;
live = { root: cards, unpick: (esc) => unpickCards(cards, esc) };
listenKeys();
return [cards, ...(lead ? [lead] : [])];
}
o.setTools([toggle]);
const range = pickRange(o.spec.rows, o.focus);
const table = o.makeTable((i) => pickClass(range, i));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
let range = pickRange(o.spec.rows, o.focus);
let chose = -1; // 누른 줄(다시 그려도 유지)
const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i)));
// 줄 class 만 고침(다시 그리지 않음 — 더블클릭 입력 그대로)
const mark = (row: number): void => {
range = null;
chose = row;
for (const tr of table.root.querySelectorAll("tr.is-pick, tr.is-focus"))
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end", "is-focus");
table.root.querySelector(`tr[data-i="${row}"]`)?.classList.add("is-focus");
};
table.root.addEventListener("click", (event) => {
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
mark(tr ? Number(tr.dataset.i) : chose);
});
live = { root: table.root, unpick: (esc) => mark(esc ? -1 : chose) };
listenKeys();
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
return [table.root, ...(lead ? [lead] : [])];
}
+8 -1
View File
@@ -66,6 +66,9 @@ class E {
setAttribute(k, v) { this.attrs[k] = String(v); }
scrollIntoView() { this.scrolled = true; }
matches(sel) {
if (sel.includes(",")) return sel.split(",").some((s) => this.matches(s.trim()));
const tag = sel.match(/^([a-z]+)([.[].*)$/); // tr.is-pick · tr[data-i="3"]
if (tag) return this.matches(tag[1]) && this.matches(tag[2]);
if (sel.startsWith(".")) return this.cls.has(sel.slice(1));
const m = sel.match(/^\[data-([\w-]+)(?:="(.*)")?\]$/);
const k = m ? m[1].replace(/-(\w)/g, (_, c) => c.toUpperCase()) : "";
@@ -75,11 +78,15 @@ class E {
closest(sel) { for (let e = this; e; e = e.parent) if (e.matches(sel)) return e; return null; }
all(sel, out = []) { for (const c of this.children) { if (c.matches(sel)) out.push(c); c.all(sel, out); } return out; }
querySelector(sel) { return this.all(sel)[0] ?? null; }
querySelectorAll(sel) { return this.all(sel); }
get isConnected() { return true; }
contains(x) { for (let e = x; e; e = e.parent) if (e === this) return true; return false; }
get text() { return this.textContent + this.children.map((c) => c.text).join(""); }
click() { const event = { target: this }; for (let e = this; e; e = e.parent) (e.on.click ?? []).forEach((f) => f(event)); }
}
globalThis.document = { createElement: (t) => new E(t) };
const docKeys = []; // 248-1 문서 keydown — key(이름, 대상) 로 부름
globalThis.document = { createElement: (t) => new E(t), addEventListener: (t, f) => t === "keydown" && docKeys.push(f) };
const key = (k, target = null) => docKeys.forEach((f) => f({ key: k, target }));
globalThis.requestAnimationFrame = (f) => f();
"""
+2 -2
View File
@@ -122,9 +122,9 @@ def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선():
" foot: cards.every((c) => c.querySelector('.ui-group-card__foot')),"
" focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));"
)
# 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤
# 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 박스 스크롤(248-1 줄 강조 안 붙임)
assert out["first"] == [False] * 4 + [True] + [False] * 8
assert out["focus"] == [True, True]
assert out["focus"] == [False, True]
assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음
assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로)
assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음
@@ -149,10 +149,10 @@ def test_화면_보기_바꿈_누름_이동():
"\nconst [view] = renderGroupPanel(o);"
"\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);"
"\nconst label = btn.textContent;"
"\nview.querySelector('[data-at=\"2\"]').click();"
"\nview.querySelector('[data-at=\"0\"]').click();"
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
"\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');"
"\nview.querySelector('[data-at=\"2\"]').click();"
"\nview.querySelector('[data-at=\"0\"]').click();"
"\nbtn.click();"
"\nconst [view2] = renderGroupPanel(o);"
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws,"
@@ -162,7 +162,7 @@ def test_화면_보기_바꿈_누름_이동():
)
assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드
assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동
assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조
assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임)
assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2)
assert out["redraws"] == 1
assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3]
@@ -0,0 +1,90 @@
"""248-1 B09 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) 다른 탭 이동 빨강 = 설계내역서(242-1) 규칙.
- 이동해 온 호표 = 빨강 하나 — 카드 = 박스 is-pick 만(줄 is-focus 안 붙음) · 표 = 구간 is-pick 만 ·
공용 focusRow(at, false) = 스크롤만
- 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · 누른 줄만 고름(is-focus) · Esc = 고름도 풂 · 다시 그려도 그대로
- 글 칸 안 키 · 다른 키 = 그대로
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
from resources.tester.test_171_3_b09_group_tabs import DG # noqa: E402
from resources.tester.test_181_2_b09_box_pick import ROWS # noqa: E402
LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts"
INPUT = "{ closest: (s) => (s.includes('input') ? {} : null) }" # 글 칸 안 키
def test_카드_빨강_하나_누름_키_풂():
out = run(
[COMMON, TABS, LABOR, GROUPS],
f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});"
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'DG1|1', goto: () => {}, redraw: () => {},"
" setTools: () => {}, makeTable: () => null };"
"\nconst marks = (v) => [v.all('.is-pick').length, v.all('.is-focus').map((e) => e.dataset.at ?? 'box')];"
"\nconst out = {};"
"\nlet [v] = renderGroupPanel(o); out.came = marks(v);"
"\nv.querySelector('[data-at=\"2\"]').click(); out.line = marks(v);"
"\n[v] = renderGroupPanel(o); v.querySelector('.ui-group-card').click(); out.box = marks(v);"
"\n[v] = renderGroupPanel(o); key('a'); key('ArrowDown', "
+ INPUT
+ "); out.kept = marks(v);"
"\nkey('ArrowDown'); out.arrow = marks(v);"
"\n[v] = renderGroupPanel(o); v.querySelector('[data-at=\"2\"]').click(); key('Escape'); out.esc = marks(v);"
"\nconsole.log(JSON.stringify(out));",
RAW,
)
assert out["came"] == [1, []] # 박스 빨강만 · 줄 강조 없음
assert out["line"] == [0, ["2"]] # 누른 줄만 고름
assert out["box"] == [0, []] # 카드 빈 곳 누름 = 빨강만 풂
assert out["kept"] == [1, []] # 다른 키 · 글 칸 안 = 그대로
assert out["arrow"] == [0, []]
assert out["esc"] == [0, []] # Esc = 고름도 풂
def test_표_빨강_하나_누름_키_풂():
out = run(
[COMMON, TABS, GROUPS],
f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};"
"\nlet extra = null; let root = null; const scrolled = [];"
"\nconst makeTable = (e) => { extra = e; root = document.createElement('table');"
" spec.rows.forEach((_, i) => { const tr = document.createElement('tr'); tr.dataset.i = String(i);"
" tr.className = e(i) ?? ''; tr.append(document.createElement('td')); root.append(tr); });"
" return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };"
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {},"
" setTools: () => {}, makeTable };"
"\nchosen.set('dg', 'table');"
"\nconst marks = () => [root.children.map((tr) => tr.className || null),"
" spec.rows.map((_, i) => extra(i))];"
"\nconst out = {};"
"\nrenderGroupPanel(o); out.came = marks(); out.scrolled = [...scrolled];"
"\nroot.children[1].children[0].click(); out.click = marks();"
"\nrenderGroupPanel(o); key('Enter'); key('ArrowUp', " + INPUT + "); out.kept = marks()[0];"
"\nkey('ArrowUp'); out.arrow = marks();"
"\nrenderGroupPanel(o); root.children[5].click(); key('Escape'); out.esc = marks();"
"\nconsole.log(JSON.stringify(out));",
)
red = [None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end", None]
none = [None] * 8
assert out["came"] == [red, red] # 구간 빨강만
assert out["scrolled"] == [[4, False]] # 스크롤만(공용 강조 안 붙임)
focus1 = [None, "is-focus"] + [None] * 6
assert out["click"] == [focus1, focus1] # 누른 줄만 · 다시 그려도
assert out["kept"] == red
assert out["arrow"] == [none, none]
assert out["esc"] == [none, none]
def test_배선():
groups = (ROOT / GROUPS).read_text("utf-8")
assert "table.focusRow(at, false)" in groups
common = (ROOT / COMMON).read_text("utf-8")
assert 'el?.classList.add("is-focus")' not in common
assert "unpickCards(grid, true);" in common
+9 -2
View File
@@ -7,7 +7,7 @@
* 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽)
* 접기 없음 — 묶음 모두 펼친 박스 · 같은 가로 줄 카드 = 높이 같음(200-1 · 합계 줄은 아래 붙음)
* 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선
* (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`)
* (`is-pick`) + 스크롤만(248-1 빨강 하나) · 누름 = 빨강 풂 · 누른 줄만 고름(`is-focus`)
* ========================================================================== */
import "./ui_template_group_cards.css";
@@ -224,7 +224,9 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
grid.addEventListener("click", (event) => {
const el = (event.target as Element).closest<HTMLElement>("[data-at]");
unpickCards(grid, true);
if (!el || !grid.contains(el)) return;
el.classList.add("is-focus");
opts.onPick?.(Number(el.dataset.at));
});
@@ -232,8 +234,13 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
const el = grid.querySelector<HTMLElement>(`[data-at="${opts.focus}"]`);
const box = el?.closest(".ui-group-card");
box?.classList.add("is-pick");
el?.classList.add("is-focus");
requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" }));
}
return root;
}
/** 248-1 박스 빨강(`is-pick`) 풂 · lines = 고른 줄(`is-focus`)도 */
export function unpickCards(root: ParentNode, lines = false): void {
for (const el of root.querySelectorAll(lines ? ".is-pick, .is-focus" : ".is-pick"))
el.classList.remove("is-pick", "is-focus");
}