Files
Aislo/ui_template/ui_template_modal.ts
T
eomsangdonandClaude Opus 5.5 47b396b53d feat(ui-kit): 묶음 부품 다섯 — 표 · ⋯ 메뉴 · 모달 · 확인창 · 설정 줄 · 파일 놓기 칸 (PLAN 552-3)
- 새 부품 파일: ui_template_table · setting_row · drop · createRowMenu(context_menu) · 모달 폭 단 · askConfirm · askText(window.prompt 대신) · confirmPanel
- 단추 = 552-2 단추 부품(⋯ 아이콘 단추 28 · 띠 알림 닫기 20 · 놓기 칸 [파일 고르기] 28)
- 대시보드 PageKit 의 dataTable · tableShell · tableRow · rowMenu · settingRow · settingCard · valueText = 부품을 다시 내보냄(한 벌 · 부름 꼴 그대로) · B01 옛 CSS 걷음
- showConfirmDialog 는 모달 부품으로 옮기고 본체가 다시 내보냄 · 모달 가림막 옛 보라 → --color-scrim
- 시험: test_552_3_* 다섯 더함 · 옮긴 함수 자리를 읽던 test_290_1 · test_505_2 고침 · 부품 CSS import 를 못 읽던 시험 도우미 넷(130 · 153 · 280_5 · 311)이 .css 를 건너뜀
- 전후 촬영: B01 여덟 화면 자리 같음 · ⋯ 아이콘만 1 px 왼쪽(단추 부품 가운데 맞춤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:55:11 +09:00

191 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* ui_template_modal.ts
* 부품 「모달 · 확인창」(552-3 · 목록 22) — 화면을 가리고 위에 뜨는 창.
* 꼴 = 모달(`openModal` · 제목줄 · 닫기 · 몸은 `mount`) · 넓음(`wide` · 96vw × 94vh) · 폭 단(`width`: 24rem · 40rem · 960px · 기본 720)
* · 확인창(`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 */
width?: "24rem" | "40rem" | "960px";
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 }), 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 });
}