Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
63 lines
2.9 KiB
TypeScript
63 lines
2.9 KiB
TypeScript
/* =============================================================================
|
||
* ui_template_modal.ts
|
||
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
|
||
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
|
||
* 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh).
|
||
* 안 넘긴 창은 지금 꼴 그대로.
|
||
* ========================================================================== */
|
||
|
||
import "./ui_template_modal.css";
|
||
import { createButton, el } from "./ui_template_elements";
|
||
|
||
export interface ModalOptions {
|
||
title: string;
|
||
closeLabel: string;
|
||
/** `actions` = 머리 단추 자리 — 몸에서 만든 단추([확인] 등)를 넣음 */
|
||
mount: (body: HTMLElement, close: () => void, actions: HTMLElement) => void;
|
||
dialogClass?: string;
|
||
/** 머리 [닫기] 바로 왼쪽에 둘 단추(넘긴 창만) */
|
||
actions?: HTMLElement[];
|
||
/** 큰 창 — 96vw × 94vh · 몸 넘침만 스크롤 */
|
||
wide?: boolean;
|
||
backdropClass?: string;
|
||
/** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */
|
||
beforeClose?: () => boolean | Promise<boolean>;
|
||
}
|
||
|
||
export function openModal(opts: ModalOptions): { close: () => void } {
|
||
const body = el("div", { className: "ui-modal__body" });
|
||
const actions = el("div", { className: "ui-modal__actions", children: opts.actions ?? [] });
|
||
const closeButton = createButton({
|
||
label: opts.closeLabel,
|
||
variant: "ghost",
|
||
onClick: () => void ask(),
|
||
});
|
||
const top = el("div", {
|
||
className: "ui-modal__top",
|
||
children: [el("h3", { text: opts.title }), closeButton],
|
||
});
|
||
const close = (): void => backdrop.remove();
|
||
const ask = async (): Promise<void> => {
|
||
if (!opts.beforeClose || (await opts.beforeClose())) close();
|
||
};
|
||
const dialog = el("div", {
|
||
className: `ui-modal ${opts.wide ? "ui-modal--wide " : ""}${opts.dialogClass ?? ""}`.trim(),
|
||
attrs: { role: "dialog", "aria-label": opts.title },
|
||
children: [top, body],
|
||
});
|
||
const backdrop = el("div", {
|
||
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
|
||
children: [dialog],
|
||
});
|
||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && void ask());
|
||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
|
||
opts.mount(body, close, actions);
|
||
// 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기)
|
||
if (actions.childElementCount) closeButton.before(actions);
|
||
document.body.append(backdrop);
|
||
dialog.tabIndex = -1;
|
||
// 몸의 첫 입력 칸에 커서 — mount 안의 focus() 는 붙기 전이라 안 먹음
|
||
(body.querySelector<HTMLElement>("input, textarea, select") ?? dialog).focus();
|
||
return { close };
|
||
}
|