Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts
T
eomsangdonandClaude Opus 5.5 bcb1c725c4 refactor(M02): 항목 카드 넷 공용 틀 CardShell (PLAN 31-7)
- 공용 틀 — 제목 줄(제목 · 저장 안 한 점 ● · 오른쪽 단추 자리) + 한 줄 부제(번호 · 이름 · 말줄임) · 상태(불러오는 중 · 실패 + 다시 불러오기 · 비었음) · [저장](늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 알림 글 한 벌) · ?(단축키 상자)
- 도면 정보 · 면적산출 · 재료산출(ItemCards) · 선택 입력(관리 화면 · 고르기 모달 값 카드)이 틀에 내용만 넣음
- 「〇〇 수정」 빈 단추 한 모양 · 엑셀 카드에도 ? · 불러오는 중 네 카드 · 실패는 카드 안 글(토스트 안 씀) · 물음 한 번(어느 카드든)
- 저장 길(saveTemplate · 도면 write · sheetEdit.save · 위 [저장] saveInputs) · 단축키는 그대로 · 고르기 모달은 보기만
- 시험 test_m02_card_shell 새로 · 옛 모양 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:24:49 +09:00

161 lines
6.0 KiB
TypeScript

/* =============================================================================
* 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<void>) => Promise<boolean>;
}
/** 카드 [저장] — 늘 보임 · 고쳐야 켜짐 · 카드 안 Ctrl+S · 저장 뒤 점 지움 · `onDirty` = 페이지에 알림 */
export function saveControl(
shell: CardShell,
save: () => Promise<void>,
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<void> = save): Promise<boolean> => {
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;
}