- [?] = 카드 제목(도면 정보 · 일위대가 · 면적산출) 바로 옆 · 도움말 = 설명 · 단축키 · 기능 · 사용법 - 도움말 글 한 곳(M02_MasterTemplete_CardHelp.ts) · 일위대가 카드에도 [?] - 도면 카드 미리보기 📂 없음(noImport) · 도면 상세는 그대로 - 시험 · 화면 시나리오(ui_checks/47-1_카드_도움말.js) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
195 lines
7.2 KiB
TypeScript
195 lines
7.2 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: "?",
|
|
helpTip: "{value} 도움말",
|
|
helpAbout: "설명",
|
|
helpKeys: "단축키",
|
|
helpFeatures: "기능",
|
|
helpSteps: "사용법",
|
|
};
|
|
|
|
export interface CardShell {
|
|
root: HTMLElement;
|
|
body: HTMLElement;
|
|
/** 제목 줄 오른쪽 단추 자리 */
|
|
tools: HTMLElement;
|
|
/** 제목 글 — 바로 옆에 ?(`helpButton`) */
|
|
title: 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" });
|
|
const title = el("span", { text: opt.title });
|
|
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: [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,
|
|
title,
|
|
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 };
|
|
}
|
|
|
|
/** [?] 도움말 네 절(47-1) — 설명 · 단축키 · 기능(단추마다 한 줄) · 사용법(차례) */
|
|
export interface CardHelp {
|
|
about: string;
|
|
keys: [string, string][];
|
|
features: [string, string][];
|
|
steps: string[];
|
|
}
|
|
|
|
const pairs = (rows: [string, string][]): HTMLElement =>
|
|
el("dl", {
|
|
children: rows.flatMap(([key, what]) => [el("dt", { text: key }), el("dd", { text: what })]),
|
|
});
|
|
|
|
/** ? — 카드 제목 바로 옆 · 누르면 도움말 상자(카드 오른쪽 위 · 보이고 숨김) · 상자는 본문 앞에 끼움 */
|
|
export function helpButton(shell: CardShell, title: string, help: CardHelp): HTMLElement {
|
|
const button = createButton({ label: CARD_TEXT.help, variant: "ghost", size: "sm" });
|
|
button.title = button.ariaLabel = CARD_TEXT.helpTip.replace("{value}", title);
|
|
button.classList.add("m02-struct__help");
|
|
const part = (name: string, heading: string, body: HTMLElement): HTMLElement =>
|
|
el("section", {
|
|
attrs: { "data-help-part": name },
|
|
children: [el("h4", { text: heading }), body],
|
|
});
|
|
const box = el("div", {
|
|
className: "m02-struct__keys",
|
|
attrs: { hidden: "" },
|
|
children: [
|
|
el("strong", { text: title }),
|
|
part("about", CARD_TEXT.helpAbout, el("p", { text: help.about })),
|
|
part("keys", CARD_TEXT.helpKeys, pairs(help.keys)),
|
|
part("features", CARD_TEXT.helpFeatures, pairs(help.features)),
|
|
part(
|
|
"steps",
|
|
CARD_TEXT.helpSteps,
|
|
el("ol", { children: help.steps.map((text) => el("li", { text })) }),
|
|
),
|
|
],
|
|
});
|
|
button.addEventListener("click", (event) => {
|
|
event.stopPropagation(); // 누름 ≠ 접기
|
|
box.hidden = !box.hidden;
|
|
});
|
|
shell.title.after(button);
|
|
shell.root.insertBefore(box, shell.body);
|
|
return button;
|
|
}
|