feat(B06): 좌측 세부 컨테이너 누름 · 초점 = 그 구조물 형상 고름(191-1)

- 값 바꿀 때 고름 길(pickAt)을 click · 키보드 초점에도 씀 — 칸 제 동작 뒤 setTimeout
- 고름이 폼을 다시 그려 초점 칸이 사라지면 같은 이름 새 칸에 초점 되살림
- 시험 test_191_1_box_press_pick + 흉내 DOM 헬퍼

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-10 13:22:45 +09:00
co-authored by Claude Opus 5.5
parent d72b5e006a
commit 50b45314a8
3 changed files with 320 additions and 7 deletions
@@ -119,17 +119,38 @@ export function wireStructureSelection(
revealed = field;
reveal?.(chainageM);
};
panel.root.addEventListener("focusin", (event) => {
if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null;
});
panel.root.addEventListener("change", ({ target: el }) => {
const box = (el as HTMLElement).closest<HTMLElement>("fieldset[data-spec-container]");
const pickAt = (el: HTMLElement): void => {
const box = el.closest<HTMLElement>("fieldset[data-spec-container]");
const chainageM = panel.currentChainage();
if (!box || chainageM === null) return;
revealAt(chainageM, fieldOf(el as HTMLElement));
revealAt(chainageM, fieldOf(el));
// 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름
if (!(el as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) pickBox(box);
if (!el.closest('[data-spec-item="outlet.tiers"]')) pickBox(box);
};
// 값을 안 바꾸고 상자를 누르거나 칸에 초점만 줘도 같은 고름(191-1) — 칸 제 동작(누름 · 값 반영) 뒤에
// 돌림 · 고름이 폼을 다시 그려 초점 칸이 사라지면 같은 이름 새 칸에 초점을 되살림
const pressAt = (el: HTMLElement): void => {
setTimeout(() => {
const focused = document.activeElement as HTMLElement | null;
const named = focused?.closest("[data-spec-item]") && panel.root.contains(focused);
const field = named ? fieldOf(focused!) : null;
pickAt(el);
if (!field || focused!.isConnected) return;
// ponytail: 같은 칸 이름 · 같은 태그 첫 칸 — 한 칸에 같은 태그가 여럿이면 그 차례까지 맞출 것
[...panel.root.querySelectorAll<HTMLElement>(focused!.tagName)]
.find((next) => fieldOf(next) === field)
?.focus();
}, 0);
};
let pointing = false; // 누름이 준 초점은 click 이 맡음 — 초점 고름은 키보드(Tab) 몫
panel.root.addEventListener("pointerdown", () => (pointing = true));
panel.root.addEventListener("keydown", () => (pointing = false));
panel.root.addEventListener("click", ({ target: el }) => pressAt(el as HTMLElement));
panel.root.addEventListener("focusin", (event) => {
if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null;
if (!pointing) pressAt(event.target as HTMLElement);
});
panel.root.addEventListener("change", ({ target: el }) => pickAt(el as HTMLElement));
// 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만
let painted: { chainageM: number | null; keys: Set<string> } | null = null;
panel.root.addEventListener("specpaint", () => {
@@ -0,0 +1,233 @@
/* 191-1 좌측 세부 컨테이너 누름 · 초점 = 형상 고름 — 실제 `wireStructureSelection` 을 그 자리에서
* 트랜스파일해 흉내 DOM 에서 돌림. 고름(select)이 폼을 다시 그리는 것까지 흉내. 짝 = `test_191_1_box_press_pick.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
class FakeNode {
constructor(tag, dataset = {}) {
this.tagName = tag.toUpperCase();
this.dataset = dataset;
this.children = [];
this.parentElement = null;
this._listeners = {};
}
append(...kids) {
kids.forEach((k) => {
k.parentElement = this;
this.children.push(k);
});
}
replaceChildren(...kids) {
this.children.forEach((k) => (k.parentElement = null));
this.children = [];
this.append(...kids);
}
addEventListener(type, fn) {
(this._listeners[type] ??= []).push(fn);
}
matches(sel) {
return sel.split(",").some((s) => {
const m = s.trim().match(/^(\w+)?(?:\[data-([\w-]+)(?:="([^"]*)")?\])?$/);
if (!m) return false;
const [, tag, attr, value] = m;
if (tag && this.tagName !== tag.toUpperCase()) return false;
if (!attr) return true;
const key = attr.replace(/-(\w)/g, (_, c) => c.toUpperCase());
return value === undefined ? this.dataset[key] !== undefined : this.dataset[key] === value;
});
}
closest(sel) {
for (let n = this; n; n = n.parentElement) if (n.matches?.(sel)) return n;
return null;
}
contains(node) {
for (let n = node; n; n = n.parentElement) if (n === this) return true;
return false;
}
get isConnected() {
return panelRoot.contains(this);
}
querySelectorAll(sel) {
const found = [];
const walk = (n) =>
n.children.forEach((k) => {
if (k.matches(sel)) found.push(k);
walk(k);
});
walk(this);
return found;
}
focus() {
globalThis.document.activeElement = this;
}
}
// 흉내 이벤트 — 대상에서 뿌리까지 거품
function fire(target, type) {
for (let n = target; n; n = n.parentElement)
(n._listeners[type] ?? []).forEach((fn) => fn({ type, target }));
}
globalThis.document = { activeElement: null };
const timers = [];
globalThis.setTimeout = (fn) => timers.push(fn);
const flush = () => {
while (timers.length) timers.shift()();
};
globalThis.queueMicrotask = (fn) => fn();
// 좌측 폼 — 상자 넷(유입 · 유출 · 관 · 흙막이 둘) · 칸마다 data-spec-item 입력 하나
const BOXES = [
["inlet", undefined, "inlet.kind"],
["outlet", undefined, "outlet.tiers"],
["pipe", undefined, "pipe.diameter"],
["soil_add", "0", "soil.height"],
["soil_add", "1", "soil.height"],
];
const panelRoot = new FakeNode("div");
let paints = 0;
function paint() {
paints += 1;
panelRoot.replaceChildren(
...BOXES.map(([id, index, item]) => {
const box = new FakeNode("fieldset", { specContainer: id, specIndex: index });
const legend = new FakeNode("legend");
const row = new FakeNode("label", { specItem: item });
const input = new FakeNode("input");
row.append(input);
box.append(legend, row);
return box;
}),
);
}
paint();
const boxOf = (id, index) =>
panelRoot
.querySelectorAll("fieldset")
.find((b) => b.dataset.specContainer === id && b.dataset.specIndex === index);
const inputOf = (id, index) => boxOf(id, index).querySelectorAll("input")[0];
const CH = 120;
const SECTION = { chainage_m: CH, culvert: {} };
let highlight = null;
const log = [];
const stationControls = {
structureSpan: { ownerOf: () => SECTION },
inletStructure: { set: () => {}, valueFor: () => "auto" },
revetOffset: {
select: (chainageM, key) => {
highlight = key;
log.push(["select", chainageM, key]);
},
highlightFor: () => highlight,
},
ford: { select: () => {} },
box: { select: () => {} },
};
const panel = {
root: panelRoot,
currentChainage: () => CH,
showAtChainage: () => paint(), // 고름이 폼을 다시 그림 — 누른 칸이 사라짐
releasePipe: () => {},
facility: { onInletStructureChange: () => {}, setInletStructure: () => {} },
};
function loadSelection() {
const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Structures_Selection.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const stubs = { "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} } };
new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r]);
return box.exports;
}
loadSelection().wireStructureSelection(
stationControls,
() => ({ cross_sections: [SECTION] }),
panel,
(chainageM, key) => log.push(["remember", chainageM, key]),
(chainageM) => log.push(["card", chainageM]),
(chainageM) => log.push(["reveal", chainageM]),
);
const reset = () => {
highlight = null;
log.length = 0;
document.activeElement = null;
};
// 마우스 누름 — pointerdown · (칸이면 초점) · click
function press(target, focus = false) {
fire(target, "pointerdown");
if (focus) {
target.focus();
fire(target, "focusin");
}
fire(target, "click");
flush();
}
const out = {};
// 값 바꿈(지금 길) — 기준
reset();
inputOf("inlet").focus();
fire(inputOf("inlet"), "change");
out.change = { highlight, log: [...log] };
// 빈 곳 · 제목 누름 = 같은 결과
reset();
press(boxOf("inlet"));
out.pressBox = { highlight, log: [...log] };
reset();
press(boxOf("inlet").children[0]);
out.pressLegend = { highlight, log: [...log] };
// 입력 칸 누름(초점) — 고름 + 다시 그린 새 칸에 초점
reset();
const before = paints;
const old = inputOf("pipe");
press(old, true);
out.pressInput = {
highlight,
repainted: paints > before,
oldConnected: old.isConnected,
focusSame: document.activeElement === inputOf("pipe"),
};
// 키보드(Tab) 초점 = 고름 · 다른 상자 누르면 바뀜
reset();
const tabbed = inputOf("soil_add", "1");
fire(tabbed, "keydown");
tabbed.focus();
fire(tabbed, "focusin");
flush();
out.tab = { highlight, focusSame: document.activeElement === inputOf("soil_add", "1") };
press(boxOf("soil_add", "0"));
out.switched = highlight;
press(boxOf("outlet"));
out.switched2 = highlight;
// 누름이 준 초점은 click 전엔 안 고름(누른 칸을 click 전에 지우지 않음)
reset();
const target = inputOf("pipe");
fire(target, "pointerdown");
target.focus();
fire(target, "focusin");
flush();
out.focusBeforeClick = { highlight, connected: target.isConnected };
// 단 수 칸 누름 = 값 바꿀 때처럼 안 고름
reset();
press(inputOf("outlet"), true);
out.tiers = highlight;
// 같은 상자 거듭 누름 = 한 번만 고름 · 한 번만 넘김
reset();
press(boxOf("inlet"));
press(boxOf("inlet"));
out.twice = log.filter((e) => e[0] === "select" || e[0] === "reveal").length;
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,59 @@
"""191-1 B06 좌측 세부 컨테이너 누름 · 초점 = 그 구조물 형상 고름(값 바꿀 때와 같은 길).
화면 TS 를 흉내 DOM 에서 돌린 값(`helper_191_1_box_press_pick.cjs`).
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).parent / "helper_191_1_box_press_pick.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_빈곳_누름_값바꿈과_같은_결과(out: dict) -> None:
assert out["change"]["highlight"] == "inlet"
assert out["pressBox"] == out["change"] # 고름 · 카드 · 넘김(스크롤) 차례까지 같음
def test_제목_누름_고름(out: dict) -> None:
assert out["pressLegend"]["highlight"] == "inlet"
assert ["select", 120, "inlet"] in out["pressLegend"]["log"]
def test_입력칸_누름_고름_초점_되살림(out: dict) -> None:
assert out["pressInput"] == {
"highlight": "pipe",
"repainted": True,
"oldConnected": False,
"focusSame": True,
}
def test_키보드_초점_고름_다른상자_바뀜(out: dict) -> None:
assert out["tab"] == {"highlight": "extra1", "focusSame": True}
assert out["switched"] == "extra0"
assert out["switched2"] == "outlet"
def test_누름_초점은_click_뒤(out: dict) -> None:
assert out["focusBeforeClick"] == {"highlight": None, "connected": True}
def test_단수칸_안고름_거듭누름_한번(out: dict) -> None:
assert out["tiers"] is None
assert out["twice"] == 2