/* ============================================================================= * ui_template_modal.ts * 부품 「모달 · 확인창」(552-3 · 목록 22) — 화면을 가리고 위에 뜨는 창. * 꼴 = 모달(`openModal` · 제목줄 · 닫기 · 몸은 `mount`) · 넓음(`wide` · 96vw × 94vh) · 폭 단(`width`: 24rem · 40rem · 960px · 기본 720) * · 넓은 중간 창(`wide` + `width: "1100px"` · 1100 × 680 · 몸 넘침만 스크롤) * · 확인창(`askConfirm`) · 위험 확인(`danger` · 확인 단추 빨강 = `showConfirmDialog`) · 글 묻기(`askText` · window.prompt 대신) * 크기 = 모달 여백 16 · 둥글기 12 · 확인창 폭 300 ~ 420 · 단추 = 단추 부품 기본 * 상태 = 기본만 · Esc = 닫기(한글 조합 중 · 몸이 막은 Esc 는 안 닫음) · 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 * 쓰는 법 = `openModal({ title: "구조물", closeLabel: "닫기", mount: (body) => body.append(form) })` * · `if (await askConfirm({ message: "지울까요?", danger: true })) …` · `const name = await askText({ message: "블록 이름" })` * 대신하는 옛 변종 = 시트 `.ss-dialog` · `.ss-fmtdlg` · B03 `b03-file__modal` · M01 `m01-logic__pick` · `m01lab__modal` · CAD `window.confirm` · `window.prompt`(갈아 끼우기 = 553) * M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임 · 안 넘긴 창은 지금 꼴 그대로. * 확인창 모양 `.ui-confirm*` = `ui_template_elements_styles.ts`(주입 차례가 페이지 CSS 뒤라 그대로 둠) · 덧붙임 = 이 파일 CSS. * ========================================================================== */ import "./ui_template_modal.css"; import { createButton } from "./ui_template_button"; import { el } from "./ui_template_elements_base"; export interface ModalOptions { title: string; closeLabel: string; /** `actions` = 머리 단추 자리 — 몸에서 만든 단추([확인] 등)를 넣음 */ mount: (body: HTMLElement, close: () => void, actions: HTMLElement) => void; dialogClass?: string; /** 머리 [닫기] 바로 왼쪽에 둘 단추(넘긴 창만) */ actions?: HTMLElement[]; /** 큰 창 — 96vw × 94vh · 몸 넘침만 스크롤 */ wide?: boolean; /** 폭 단 — 없으면 720 · 1100px 은 `wide` 와 함께(높이 680) */ width?: "24rem" | "40rem" | "960px" | "1100px"; backdropClass?: string; /** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */ beforeClose?: () => boolean | Promise; } /** 모달 판 — 뒤 가림막 없이 판만(부품 모음 견본도 씀) · 머리 단추는 mount 뒤에 붙임 */ export function buildModal( opts: Pick, onClose: () => void, ): { dialog: HTMLElement; body: HTMLElement; actions: HTMLElement; closeButton: HTMLElement } { 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: onClose, }); const top = el("div", { className: "ui-modal__top", children: [el("h3", { text: opts.title, attrs: { title: opts.title } }), closeButton], }); 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], }); if (opts.width) dialog.dataset.width = opts.width; return { dialog, body, actions, closeButton }; } export function openModal(opts: ModalOptions): { close: () => void } { const { dialog, body, actions, closeButton } = buildModal(opts, () => void ask()); const close = (): void => backdrop.remove(); const ask = async (): Promise => { if (!opts.beforeClose || (await opts.beforeClose())) close(); }; const backdrop = el("div", { className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(), children: [dialog], }); // 바깥 누르기 = 누름 · 뗌 둘 다 바깥일 때만 — 칸 안에서 끌어 바깥에서 떼면(그 반대도) 안 닫음 let outside = false; backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop)); backdrop.addEventListener("pointerup", (ev) => (outside &&= ev.target === backdrop)); backdrop.addEventListener("click", (ev) => ev.target === backdrop && outside && void ask()); // 몸이 막은 Esc(글 고침 취소) · 한글 조합 중 Esc 는 안 닫음 backdrop.addEventListener( "keydown", (ev) => ev.key === "Escape" && !ev.defaultPrevented && !ev.isComposing && 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("input, textarea, select") ?? dialog).focus(); return { close }; } /* ----------------------------------------------------------------------------- * 확인창 · 글 묻기 — window.confirm · window.prompt 대신 * * 공용 헤드 브라우저(playwright 연결)는 네이티브 confirm/alert를 자동 취소해 * 버려 [초기화] 같은 버튼이 죽은 것처럼 보였다(2026-08-19 진단). 화면 안 창으로 * 확인을 받아 어느 실행 환경에서도 같은 동작을 보장한다. * -------------------------------------------------------------------------- */ export interface AskOptions { message: string; title?: string; okLabel?: string; cancelLabel?: string; /** 위험 확인 — 확인 단추 빨강 */ danger?: boolean; } export interface AskField { value?: string; placeholder?: string; } /** 확인창 판 — 가림막(.ui-confirm) 없이 판만(부품 모음 견본도 씀) · `field` = 글 묻기 칸 */ export function confirmPanel( opts: AskOptions & { field?: AskField }, done: (ok: boolean, text: string) => void = () => {}, ): { panel: HTMLElement; first: HTMLElement } { const panel = el("div", { className: "ui-confirm__panel", attrs: { role: "dialog" } }); if (opts.title) panel.append(el("h3", { className: "ui-confirm__title", text: opts.title })); panel.append(el("p", { className: "ui-confirm__message", text: opts.message })); let input: HTMLInputElement | null = null; if (opts.field) { const box = el("input", { className: "ui-input ui-confirm__input", attrs: { type: "text", placeholder: opts.field.placeholder ?? "" }, }); box.value = opts.field.value ?? ""; box.addEventListener("keydown", (ev) => { if (ev.key !== "Enter" || ev.isComposing) return; ev.preventDefault(); done(true, box.value); }); panel.append(box); input = box; } const cancel = createButton({ label: opts.cancelLabel ?? "취소", variant: "ghost", onClick: () => done(false, ""), }); const ok = createButton({ label: opts.okLabel ?? "확인", variant: opts.danger ? "danger" : "filled", onClick: () => done(true, input?.value ?? ""), }); panel.append(el("div", { className: "ui-confirm__actions", children: [cancel, ok] })); return { panel, first: input ?? cancel }; } function ask(opts: AskOptions, field?: AskField): Promise<{ ok: boolean; text: string }> { return new Promise((resolve) => { const overlay = el("div", { className: "ui-confirm" }); const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 const done = (ok: boolean, text: string): void => { overlay.remove(); if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); resolve({ ok, text }); }; const { panel, first } = confirmPanel({ ...opts, field }, done); overlay.addEventListener("keydown", (ev) => { if (ev.key !== "Escape" || ev.isComposing) return; ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 done(false, ""); }); overlay.append(panel); document.body.append(overlay); first.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 }); } /** 확인창 — 확인이면 true */ export function askConfirm(opts: AskOptions): Promise { return ask(opts).then((r) => r.ok); } /** 글 묻기 — 확인이면 칸 글 · 취소면 null */ export function askText(opts: AskOptions & AskField): Promise { return ask(opts, { value: opts.value, placeholder: opts.placeholder }).then((r) => r.ok ? r.text : null, ); } /** 위험 확인(옛 부름 꼴) — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ export function showConfirmDialog( message: string, confirmLabel = "확인", cancelLabel = "취소", ): Promise { return askConfirm({ message, okLabel: confirmLabel, cancelLabel, danger: true }); }