From 25cf716a0d7275a0e034fb14d2418e67445d812f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 20:02:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=95=88=20=E3=80=8C=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20=EC=A4=91=E3=80=8D=20=E2=80=94=200.3=EC=B4=88=20?= =?UTF-8?q?=EB=84=98=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=C2=B7=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=3D=20=EA=B9=8C=EB=8B=AD=20+=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20(PLAN=2030-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 부품 ui_template_loading(showLoading · loadingError · delayedShow) — 칸 안 작은 회전 표시 + 「불러오는 중...」 · 화면 전체 잠금 아님 · 다른 페이지도 씀 - 붙인 자리 — 좌측 나무(구조물 · 일위대가 · 사람 나무) · 상세 칸(구조물 표 · 도면 · 면적 · 재료) · 조합 목록 · 조합 상세 · 항목 카드 - 공용 글 Common_Load_Failed · Common_Reload - 시험 test_ui_loading(0.3초 규칙 가짜 시계 · 실패 글 · 붙은 자리) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_ItemCards.ts | 4 + .../M02_MasterTemplete_Labor.ts | 9 +- .../M02_MasterTemplete_Labor_Edit.ts | 17 +++- .../M02_MasterTemplete_TreePanel.ts | 7 +- .../M02_MasterTemplete_UI_Main.ts | 9 +- resources/tester/test_ui_loading.py | 84 +++++++++++++++++++ ui_template/ui_template_loading.css | 36 ++++++++ ui_template/ui_template_loading.ts | 71 ++++++++++++++++ ui_template/ui_template_locale_common.ts | 2 + 9 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_ui_loading.py create mode 100644 ui_template/ui_template_loading.css create mode 100644 ui_template/ui_template_loading.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 820ee158..ad6429d2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -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; // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts index fbec69a5..985d1a1f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -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), }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index 23990998..ce9fbb08 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -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 => { + 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, }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 7dba3012..b82b5df4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -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 { 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(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8eb639a8..e6f0783a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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 | 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(); } } diff --git a/resources/tester/test_ui_loading.py b/resources/tester/test_ui_loading.py new file mode 100644 index 00000000..f2f205dc --- /dev/null +++ b/resources/tester/test_ui_loading.py @@ -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") diff --git a/ui_template/ui_template_loading.css b/ui_template/ui_template_loading.css new file mode 100644 index 00000000..26404b2a --- /dev/null +++ b/ui_template/ui_template_loading.css @@ -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; + } +} diff --git a/ui_template/ui_template_loading.ts b/ui_template/ui_template_loading.ts new file mode 100644 index 00000000..523d743a --- /dev/null +++ b/ui_template/ui_template_loading.ts @@ -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; + clear: (id: ReturnType) => 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], + }); +} diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 11eb7dfc..e8f680e6 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -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"],