/* ============================================================================= * ui_template_toast.ts * 부품 「알림」(552-3 · 목록 24) — 잠깐 떴다 사라지는 토스트 · 화면을 잠그는 로딩 가림막. * 꼴 = 토스트 넷(성공 · 오류 · 정보 · 경고 — 오른쪽 위 · 3 초) · 누르면 동작(`onClick`) · 로딩 가림막(스피너) · 진행 가림막(진행 원 · 단계 글) * 크기 = 토스트 글 14 · 여백 16 / 24 * 상태 = 기본만(같은 문구가 떠 있는 동안 또 오면 버림) * 쓰는 법 = `showToast("저장됨", "success")` · `showLoadingOverlay()` … `hideLoadingOverlay()` · `setLoadingOverlayProgress(0.4, "지형 읽는 중")` * 부품 모음 견본 = `toastNode(글, 갈래)`(떠 있지 않은 토스트 한 장) * 대신하는 옛 변종 = 시트 `.ss-toast` · CAD `.aislo-cad-toast` · `.design-dock__busy` · `.cad-host[data-loading]` * 모양 = `ui_template_elements_styles.ts`(.ui-toast · .ui-overlay · 주입 차례가 페이지 CSS 뒤라 그대로 둠) * ========================================================================== */ import { el } from "./ui_template_elements_base"; import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress"; /* 로딩 가림막 — 싱글턴 · show/hide 로 참조 수를 셈 */ let overlayEl: HTMLDivElement | null = null; let overlayCount = 0; let overlayProgress: ProgressCircleHandle | null = null; function ensureOverlay(): HTMLDivElement { if (overlayEl) return overlayEl; const spinner = el("div", { className: "ui-spinner", attrs: { "aria-hidden": "true" } }); overlayEl = el("div", { className: "ui-overlay", attrs: { role: "status", "aria-live": "polite" }, children: [spinner], }); document.body.append(overlayEl); return overlayEl; } /** 로딩 스피너 오버레이 표시 (중첩 호출 안전) */ export function showLoadingOverlay(): void { overlayCount += 1; ensureOverlay().classList.add("is-active"); } /** 로딩 스피너 오버레이 해제 (모든 참조 종료 시 실제 숨김) */ export function hideLoadingOverlay(): void { overlayCount = Math.max(0, overlayCount - 1); if (overlayCount === 0 && overlayEl) { overlayEl.classList.remove("is-active", "ui-overlay--progress"); overlayProgress?.remove(); overlayProgress = null; } } /** 띄운 잠금판에 진행 원 · 글(520-3) — 스피너 자리 · 모든 참조가 풀리면 함께 걷힘. * ratio null = 진행률 미상(회전만). 잠금판이 안 떠 있으면 아무것도 안 함. */ export function setLoadingOverlayProgress(ratio: number | null, label?: string): void { if (overlayCount === 0) return; const overlay = ensureOverlay(); if (!overlayProgress) { overlayProgress = createProgressCircle(); overlay.append(overlayProgress.root); overlay.classList.add("ui-overlay--progress"); } overlayProgress.set(ratio, label); } export type ToastKind = "success" | "error" | "info" | "warning"; let toastContainer: HTMLDivElement | null = null; function ensureToastContainer(): HTMLDivElement { if (toastContainer) return toastContainer; toastContainer = el("div", { className: "ui-toast-container" }); document.body.append(toastContainer); return toastContainer; } /** 직전에 띄운 문구와 그 소멸 시각 — 같은 문구가 연달아 오면 무시한다. */ let lastToast: { key: string; until: number } | null = null; /** * 토스트 표시. durationMs 후 자동 소멸. * * **같은 문구 연속 무시**(2026-08-30 사용자 지시 2) — 한계 절삭 안내처럼 한 조작에서 * 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는 * 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다. */ export function showToast( message: string, kind: ToastKind = "info", durationMs = 3000, /** 누르면 실행할 동작 — 지정하면 토스트가 클릭 대상이 된다(CAD 백업 되살리기 등). */ onClick?: () => void, ): void { const key = `${kind}::${message}`; const now = Date.now(); if (lastToast && lastToast.key === key && now < lastToast.until) return; lastToast = { key, until: now + durationMs }; const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message }); if (onClick) { toast.style.cursor = "pointer"; toast.addEventListener("click", () => { onClick(); toast.remove(); }); } ensureToastContainer().append(toast); // 진입 애니메이션 트리거 requestAnimationFrame(() => toast.classList.add("is-visible")); window.setTimeout(() => { toast.classList.remove("is-visible"); window.setTimeout(() => toast.remove(), 200); }, durationMs); } /** 부품 모음 견본 — 떠 있지 않은 토스트 한 장 */ export function toastNode(message: string, kind: ToastKind = "info"): HTMLElement { const toast = el("div", { className: `ui-toast ui-toast--${kind} is-visible`, text: message }); return toast; }