feat(M02): 컨테이너 안 「불러오는 중」 — 0.3초 넘을 때만 · 실패 = 까닭 + 다시 불러오기 (PLAN 30-6)

- 공용 부품 ui_template_loading(showLoading · loadingError · delayedShow) — 칸 안 작은 회전 표시 + 「불러오는 중...」 · 화면 전체 잠금 아님 · 다른 페이지도 씀
- 붙인 자리 — 좌측 나무(구조물 · 일위대가 · 사람 나무) · 상세 칸(구조물 표 · 도면 · 면적 · 재료) · 조합 목록 · 조합 상세 · 항목 카드
- 공용 글 Common_Load_Failed · Common_Reload
- 시험 test_ui_loading(0.3초 규칙 가짜 시계 · 실패 글 · 붙은 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 20:02:36 +09:00
co-authored by Claude Opus 5.5
parent 71666a8633
commit 25cf716a0d
9 changed files with 233 additions and 6 deletions
@@ -10,6 +10,7 @@
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { showLoading } from "@ui/ui_template_loading";
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch";
import {
@@ -254,10 +255,13 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
c.host.replaceChildren();
}
const source = opts.source();
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6) · 못 읽은 것은 아래 그대로(알림 · 「없음」)
const stops = [previewHost, ...sheets.map((c) => c.host)].map((host) => showLoading(host));
const [drawing, ...books] = await Promise.allSettled([
source.read("structure", col.id),
...sheets.map((c) => source.read(c.kind, col.id)),
]);
stops.forEach((stop) => stop());
if (mine !== seq) return;
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
// 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」
@@ -158,7 +158,14 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary {
if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;
editor.showList();
},
refresh: () => tree.refresh(),
refresh: async () => {
const stop = editor.loading(); // 조합 목록 칸에도 「불러오는 중」(나무 칸은 나무 부품이 · PLAN 30-6)
try {
await tree.refresh();
} finally {
stop();
}
},
tools: editor.newButton,
bind: (fn) => (setDirty = fn),
};
@@ -27,6 +27,7 @@ import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
import { buildPreview } from "./M02_MasterTemplete_Labor_Preview";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { matchText } from "./M02_MasterTemplete_TreePanel_Model";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
@@ -83,6 +84,8 @@ export interface LaborEditor {
current: () => string | null;
/** 라이브러리 찾기 칸 글 — 목록을 같은 규칙(`matchText`)으로 거름 */
setQuery: (text: string) => void;
/** 조합 목록을 새로 받는 동안 칸 안 「불러오는 중」(목록 화면일 때만 · 돌려받은 함수 = 끝) */
loading: () => () => void;
}
export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
@@ -167,17 +170,26 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
};
const open = async (key: string): Promise<void> => {
const my = ++nav;
const stop = showLoading(view); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
try {
const my = ++nav;
const one = await fetchCombo(key);
await logicsReady;
if (my !== nav) return;
stop();
draw(one.combo, one.version);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
if (my !== nav) return;
stop();
view.replaceChildren(loadingError(reason(error), () => void open(key)));
} finally {
stop();
}
};
/** 조합 목록을 새로 받는 동안 — 목록 화면일 때만 칸 안 표시(돌려받은 함수 = 끝) */
const loading = (): (() => void) => (current ? () => {} : showLoading(view));
const startNew = (path: string[]): void => draw(blankCombo(path), "");
/* --- 상세 --- */
@@ -390,5 +402,6 @@ export function createLaborEditor(opt: LaborEditOptions): LaborEditor {
isDirty: dirty,
current: () => current?.combo.키 ?? null,
setQuery,
loading,
};
}
@@ -11,6 +11,7 @@
import "./M02_MasterTemplete_TreePanel.css";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
createTreeRow,
@@ -393,6 +394,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
async function refresh(): Promise<void> {
const mine = ++seq;
const stop = showLoading(body); // 0.3초 넘으면 나무 칸 안에 「불러오는 중」(PLAN 30-6)
try {
const got = await api.fetch(opt.layer, owner);
if (mine !== seq) return;
@@ -400,8 +402,11 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
} catch (error) {
if (mine !== seq) return;
view = null;
body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error))));
stop();
body.replaceChildren(loadingError(why(error), () => void refresh()));
return;
} finally {
stop(); // 늦게 온 옛 요청의 표시도 걷음
}
paint();
}
@@ -6,6 +6,7 @@
import { setLeaveGuard } from "../A00_Common/router";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
import { t as L } from "@ui/ui_template_locale";
import {
readFilled,
@@ -463,6 +464,7 @@ export function createMain(
return empty("");
}
title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a
const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6)
try {
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
if (mine !== seq) return;
@@ -481,12 +483,15 @@ export function createMain(
const field = NUMBER_FIELD[next.kind];
const number = field && (got.문서 as Record<string, unknown> | null)?.[field];
if (number) title.textContent = `${String(number)} ${next.label ?? next.name}`;
stop();
await mount(shown, next);
} catch (error) {
if (mine !== seq) return;
empty("");
stop();
const why = error instanceof Error ? error.message : "";
showToast(L("M02_LoadFailed").replace("{value}", why), "error");
host.replaceChildren(loadingError(why, () => void open(next))); // 그 자리에 까닭 + 다시 불러오기
} finally {
stop();
}
}
+84
View File
@@ -0,0 +1,84 @@
"""컨테이너 안 「불러오는 중」(PLAN 30-6 · `ui_template_loading.ts`) — 0.3초 · 실패 글 · M02 자리.
① `delayedShow` 를 Node 로 가짜 시계로 돌림 — 0.3초 전에 끝나면 안 보임(깜빡임 없음) · 넘으면 보이고
끝나면 걷힘 · 끝낸 뒤 시계가 와도 안 보임.
② 실패 자리 = 까닭 글(「불러오지 못함 — 까닭」) + [다시 불러오기] · 화면 전체 잠금 안 씀.
③ M02 나무 · 상세(표 · 도면) · 조합 목록 · 조합 상세 · 항목 카드가 이 부품을 씀.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
LOADING = ROOT / "ui_template" / "ui_template_loading.ts"
M02 = ROOT / "M02_MasterTemplete"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_0점3초_넘을_때만_보임():
found = re.search(r"^export function delayedShow[\s\S]*?^}", _text(LOADING), re.M)
assert found, "delayedShow 를 못 찾음"
script = (
"const LOADING_DELAY_MS = 300;\n"
+ found.group(0).replace("export ", "")
+ """
let now = 0; const jobs = [];
const timer = {
set: (fn, ms) => { jobs.push({ at: now + ms, fn, live: true }); return jobs.length - 1; },
clear: (id) => { if (jobs[id]) jobs[id].live = false; },
};
const tick = (ms) => {
now += ms;
for (const j of jobs) if (j.live && j.at <= now) { j.live = false; j.fn(); }
};
const log = [];
const on = (n) => () => log.push(n + ":show"); const off = (n) => () => log.push(n + ":hide");
const fast = delayedShow(on("fast"), off("fast"), undefined, timer);
tick(200); fast(); tick(500);
const slow = delayedShow(on("slow"), off("slow"), undefined, timer);
tick(299); log.push("299"); tick(1); slow(); tick(500);
console.log(JSON.stringify(log));
"""
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["299", "slow:show", "slow:hide"]
def test_실패_글과_다시_불러오기_M02_자리():
src = _text(LOADING)
assert 'reason ? `${t("Common_Load_Failed")} — ${reason}` : t("Common_Load_Failed")' in src
assert 'createButton({ label: t("Common_Reload")' in src and 'role: "alert"' in src
assert "showLoadingOverlay()" not in src # 칸 안만(화면 전체 잠금 안 부름)
# 브라우저 setTimeout 을 객체 메서드로 부르면 Illegal invocation — 감싼 기본값(ORCA 흠)
assert "set: (fn, ms) => setTimeout(fn, ms)," in src
assert "clear: (id) => clearTimeout(id)," in src
locale = _text(ROOT / "ui_template" / "ui_template_locale_common.ts")
assert 'Common_Load_Failed: ["불러오지 못함"' in locale
assert 'Common_Reload: ["다시 불러오기"' in locale
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "const stop = showLoading(body);" in tree
assert "loadingError(why(error), () => void refresh())" in tree
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert "const stop = showLoading(host);" in main
assert "loadingError(why, () => void open(next))" in main
edit = _text(M02 / "M02_MasterTemplete_Labor_Edit.ts")
assert "loadingError(reason(error), () => void open(key))" in edit
assert "(current ? () => {} : showLoading(view))" in edit
assert "const stop = editor.loading();" in _text(M02 / "M02_MasterTemplete_Labor.ts")
assert "showLoading(host)" in _text(M02 / "M02_MasterTemplete_ItemCards.ts")
+36
View File
@@ -0,0 +1,36 @@
/* 컨테이너 안 불러오는 중 · 실패 자리(ui_template_loading.ts) — 회전 표시는 공용 .ui-spinner 와 같은 색 · 작게 */
.ui-loading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.ui-loading__spin {
flex: none;
width: 16px;
height: 16px;
border: 2px solid var(--color-mist-violet);
border-top-color: var(--color-royal-amethyst);
border-radius: 50%;
animation: ui-loading-spin 0.8s linear infinite;
}
.ui-loading--error {
color: var(--color-danger);
}
@keyframes ui-loading-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-loading__spin {
animation-duration: 2.4s;
}
}
+71
View File
@@ -0,0 +1,71 @@
/* =============================================================================
* ui_template_loading.ts
* 컨테이너 안 「불러오는 중」 — 화면 전체 잠금(`showLoadingOverlay`)과 달리 그 칸 안에만 작은 회전 표시 +
* 글 · 0.3초 넘게 걸릴 때만 보임(빠른 응답은 깜빡임 없음) · 실패하면 그 자리에 까닭 + [다시 불러오기].
*
* const stop = showLoading(host);
* try { const got = await load(); stop(); draw(got); }
* catch (e) { stop(); host.replaceChildren(loadingError(why(e), () => void again())); }
* ========================================================================== */
import "./ui_template_loading.css";
import { createButton, el } from "./ui_template_elements";
import { t } from "./ui_template_locale";
export const LOADING_DELAY_MS = 300;
/** `delay` 뒤에 `show` — 그 전에 돌려받은 함수를 부르면 안 보임 · 보인 뒤면 `hide` */
export function delayedShow(
show: () => void,
hide: () => void,
delay: number = LOADING_DELAY_MS,
// 브라우저 setTimeout 은 window 에서 불러야 함(객체 메서드로 부르면 Illegal invocation) — 감싸서 넘김
timer: {
set: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
clear: (id: ReturnType<typeof setTimeout>) => void;
} = {
set: (fn, ms) => setTimeout(fn, ms),
clear: (id) => clearTimeout(id),
},
): () => void {
let shown = false;
const id = timer.set(() => {
shown = true;
show();
}, delay);
return () => {
timer.clear(id);
if (shown) hide();
};
}
/** 칸 안 불러오는 중 — 돌려받은 함수 = 끝(보였으면 표시를 뗌 · 내용은 부르는 쪽이 그림) */
export function showLoading(host: HTMLElement, text = t("Common_Status_Loading")): () => void {
const box = el("div", {
className: "ui-loading",
attrs: { role: "status", "aria-live": "polite" },
children: [
el("span", { className: "ui-loading__spin", attrs: { "aria-hidden": "true" } }),
el("span", { text }),
],
});
return delayedShow(
() => host.replaceChildren(box),
() => box.remove(),
);
}
/** 실패 글 — 까닭 그대로 · 없으면 「불러오지 못함」 */
export const loadingFailText = (reason: string): string =>
reason ? `${t("Common_Load_Failed")} — ${reason}` : t("Common_Load_Failed");
/** 실패 자리 — 까닭 글 + [다시 불러오기] */
export function loadingError(reason: string, retry: () => void): HTMLElement {
const again = createButton({ label: t("Common_Reload"), variant: "ghost", size: "sm" });
again.addEventListener("click", retry);
return el("div", {
className: "ui-loading ui-loading--error",
attrs: { role: "alert" },
children: [el("span", { text: loadingFailText(reason) }), again],
});
}
+2
View File
@@ -39,6 +39,8 @@ export const ui_locales_common = {
* 공통 — 상태 / 메시지
* ------------------------------------------------------------------------ */
Common_Status_Loading: ["불러오는 중...", "Loading..."],
Common_Load_Failed: ["불러오지 못함", "Failed to load"],
Common_Reload: ["다시 불러오기", "Reload"],
Common_Status_Saving: ["저장 중...", "Saving..."],
Common_Status_Processing: ["처리 중...", "Processing..."],
Common_Status_Success: ["완료되었습니다", "Completed"],