/* ============================================================================= * M02_MasterTemplete_CardShell.ts * 항목 카드 공용 틀(PLAN 31-7) — 도면 정보 · 면적산출 · 재료산출 · 선택 입력(관리 화면 · 고르기 모달)이 * 내용만 넣음. 모양 · 높이 · 스크롤은 `M02_MasterTemplete_Structure.css`. * 머리 = 제목 줄(h3 · 제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 넘치면 말줄임) * 본문 = 카드 높이를 채움 · 넘치면 카드 안 스크롤 · 상태 = 불러오는 중 · 실패 + [다시 불러오기] · 비었음 * [저장](`saveControl`) = 늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 알림 글 한 벌 · ?(`helpButton`) = 단축키 상자 * 접기는 부모의 attachCollapsible(제목 줄 클릭) — 오른쪽 단추를 눌러도 접히지 않음. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; export const CARD_TEXT = { save: "저장", saved: "저장했음", failed: "저장 못 함 — {value}", stale: "그 사이 바뀜 — 다시 읽음", help: "?", }; export interface CardShell { root: HTMLElement; body: HTMLElement; /** 제목 줄 오른쪽 단추 자리 */ tools: HTMLElement; /** 저장 안 한 고침 — 제목 옆 점(●) */ setDirty: (dirty: boolean) => void; /** 0.3초 넘으면 본문에 「불러오는 중」 — 돌려받은 함수 = 끝 */ loading: () => () => void; /** 실패 — 본문에 까닭 + [다시 불러오기](토스트 안 씀) */ fail: (reason: string, retry: () => void) => void; /** 비었음 · 안내 글 한 모양 */ note: (text: string) => void; /** 카드 안에 초점이 있을 때 Ctrl+S */ onSave: (run: () => void) => void; } export interface CardShellOptions { title: string; body: HTMLElement; /** 부제 한 줄에 놓을 것(번호 · 이름 · 요약) */ sub?: HTMLElement[]; className?: string; /** 상태 글을 그릴 칸 — 없으면 본문 */ stateHost?: HTMLElement; } export const cardNote = (text: string): HTMLElement => el("p", { className: "m02-struct__empty", text }); export function cardShell(opt: CardShellOptions): CardShell { const dot = el("span", { className: "m02-struct__dot", text: "●", attrs: { hidden: "" } }); const tools = el("span", { className: "m02-struct__card-tools" }); tools.addEventListener("click", (event) => event.stopPropagation()); // 단추 누름 ≠ 접기 const root = el("section", { className: `m02-struct__card ${opt.className ?? ""} ui-collapsible ui-sidebar-section ui-sidebar-card`, children: [ el("h3", { className: "ui-collapsible__title", children: [el("span", { text: opt.title }), dot, tools], }), ...(opt.sub ? [el("div", { className: "m02-struct__sub", children: opt.sub })] : []), opt.body, ], }); const host = opt.stateHost ?? opt.body; let save: (() => void) | null = null; root.addEventListener("keydown", (event) => { if (!save || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return; event.preventDefault(); save(); }); return { root, body: opt.body, tools, setDirty: (dirty) => (dot.hidden = !dirty), loading: () => showLoading(host), fail: (reason, retry) => host.replaceChildren(loadingError(reason, retry)), note: (text) => host.replaceChildren(cardNote(text)), onSave: (run) => (save = run), }; } export interface SaveControl { button: HTMLButtonElement; dirty: () => boolean; mark: (dirty: boolean) => void; /** 저장 한 번(`run` 없으면 틀의 저장) — 알림 글은 여기서 한 벌 · 성공이면 true */ run: (save?: () => Promise) => Promise; } /** 카드 [저장] — 늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 저장 뒤 점 지움 · `onDirty` = 페이지에 알림 */ export function saveControl( shell: CardShell, save: () => Promise, onDirty?: (dirty: boolean) => void, ): SaveControl { const button = createButton({ label: CARD_TEXT.save, variant: "filled", size: "sm" }); let dirty = false; let saving = false; const paint = (): void => { button.disabled = !dirty || saving; shell.setDirty(dirty); }; const mark = (next: boolean): void => { dirty = next; paint(); onDirty?.(next); }; const run = async (fn: () => Promise = save): Promise => { if (saving) return false; saving = true; paint(); try { await fn(); mark(false); showToast(CARD_TEXT.saved, "success"); return true; } catch (error) { const why = error instanceof Error ? error.message : ""; showToast( error instanceof StaleError ? CARD_TEXT.stale : CARD_TEXT.failed.replace("{value}", why), "error", ); return false; } finally { saving = false; paint(); } }; button.addEventListener("click", () => void run()); shell.onSave(() => void (dirty && run())); paint(); return { button, dirty: () => dirty, mark, run }; } /** ? — 단축키 상자(카드 오른쪽 위 · 누르면 보이고 숨김) · 상자는 본문 앞에 끼움(본문 채움 규칙 그대로) */ export function helpButton(shell: CardShell, title: string, keys: [string, string][]): HTMLElement { const help = createButton({ label: CARD_TEXT.help, variant: "ghost", size: "sm" }); help.title = help.ariaLabel = title; const box = el("div", { className: "m02-struct__keys", attrs: { hidden: "" }, children: [ el("strong", { text: title }), el("dl", { children: keys.flatMap(([key, what]) => [ el("dt", { text: key }), el("dd", { text: what }), ]), }), ], }); help.addEventListener("click", () => (box.hidden = !box.hidden)); shell.root.insertBefore(box, shell.body); return help; }