- 위험 채움 토큰 밝음 #e5484d → #d6363c(흰 글 3.91 → 4.72) · 어두움 5.6 · 위험 글 #c0392b 그대로 - 새 부품 계산 줄(ui_template_calc_row) — B08 수리집수면적표 b08-hydro__row · 식 칸 배치를 대신함 - 표 부품 칸 램프(ui-data-table__lamp · --only · ✓) — B08 집계표 · 거리이정표 · B09 설계내역 램프 CSS 세 벌을 걷음(표지 class 는 풍선 · 가이드용으로 남김) - 모달 넓은 중간 창(wide + width 1100px · 1100 × 680) — B09 운반 창 손 인라인 크기를 걷음 - 값 목록 규칙 꼴(rule) — ☰ 설정 규칙 줄 ul/li 를 걷음 - 접이 구역 머리(createCollapsibleHead = 패널 머리 + 접는 제목) — B08 구조물도 「조합 연결」 제목(옛 b08-sheets__title)을 대신함 - 일반 화면 틀 좁은 폭 · 입력 칸 2열 · 끝 단추 줄 — D01 · E01 이 B02 CSS 를 빌리던 것을 끊음(가이드 장면 선택자 맞춤) - 부품 모음 견본 · 지킴 시험 test_557_2 · 낡은 시험(198_1 · 247_1 · 251_1 · 257_1 · 276_1 · 551_1)은 부품 CSS · 새 class 로 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
9.1 KiB
TypeScript
192 lines
9.1 KiB
TypeScript
/* =============================================================================
|
||
* 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<boolean>;
|
||
}
|
||
|
||
/** 모달 판 — 뒤 가림막 없이 판만(부품 모음 견본도 씀) · 머리 단추는 mount 뒤에 붙임 */
|
||
export function buildModal(
|
||
opts: Pick<ModalOptions, "title" | "closeLabel" | "actions" | "wide" | "width" | "dialogClass">,
|
||
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<void> => {
|
||
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<HTMLElement>("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<boolean> {
|
||
return ask(opts).then((r) => r.ok);
|
||
}
|
||
|
||
/** 글 묻기 — 확인이면 칸 글 · 취소면 null */
|
||
export function askText(opts: AskOptions & AskField): Promise<string | null> {
|
||
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<boolean> {
|
||
return askConfirm({ message, okLabel: confirmLabel, cancelLabel, danger: true });
|
||
}
|