Files
Aislo/ui_template/ui_template_modal.ts
T
eomsangdonandClaude Opus 5.5 54410dd26a fix(ui-kit): 부품 흠 둘 — 숨김이 부품 display 를 이김 · 모달 머리 [닫기] 한 줄 (PLAN 552-6)
- 부품 CSS 37 파일: display 를 주는 ui-* 클래스마다 같은 파일에 [hidden] { display: none !important } (공용 기본 꼴 없는 곳 · 페이지 꾸밈 위에서도) · 옛 숨김 규칙 일곱 파일 !important 로 · 접이 카드 머리는 collapsible.css
- 모달 머리: 제목 한 줄 말줄임(전체 글 = title) · [닫기] · 머리 단추 flex: none · nowrap
- 부품 모음 모달 견본에 긴 제목 24rem 한 칸
- 지킴 시험 test_552_6_part_fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:07:37 +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, 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 });
}