feat(ui): 공용 모달 머리 단추 자리 actions · 큰 창 wide 곁값(147-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-09 19:00:22 +09:00
co-authored by Claude Opus 5.5
parent 09b3b50efc
commit 0da6daab72
3 changed files with 93 additions and 13 deletions
@@ -0,0 +1,44 @@
"""공용 모달 머리 단추 · 큰 창 · M02 수량산출 창(PLAN 147) — 소스 검사 · 화면 판정은 사용자.
· 147-1 곁값 `actions`(머리 [닫기] 바로 왼쪽 · 넘긴 창만) · `wide`(96vw × 94vh · 몸만 스크롤)
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _text(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
found = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.M)
assert found, selector
return found.group(1)
def test_147_1_곁값_actions_wide():
ts = _text("ui_template/ui_template_modal.ts")
assert "actions?: HTMLElement[];" in ts
assert "wide?: boolean;" in ts
# 몸에서 만든 단추도 넣게 mount 셋째 인자 = 머리 자리
assert "opts.mount(body, close, actions);" in ts
# 단추가 있을 때만 [닫기] 바로 앞에 — 없으면 지금 꼴(h3 · [닫기])
assert "if (actions.childElementCount) closeButton.before(actions);" in ts
assert 'children: [el("h3", { text: opts.title }), closeButton]' in ts
assert '"ui-modal--wide "' in ts
def test_147_1_머리_자리_큰_창_css():
css = _text("ui_template/ui_template_modal.css")
assert "margin-right: auto;" in _block(css, ".ui-modal__top > h3")
assert "justify-content: space-between;" in _block(css, ".ui-modal__top")
wide = _block(css, ".ui-modal.ui-modal--wide")
for rule in ("width: 96vw;", "height: 94vh;", "--modal-max: 94vh;", "overflow: hidden;"):
assert rule in wide, rule
body = _block(css, ".ui-modal--wide > .ui-modal__body")
assert "overflow: auto;" in body and "min-height: 0;" in body
+26
View File
@@ -33,6 +33,32 @@
gap: var(--spacing-8);
}
/* 머리 단추 자리(곁값 actions) — 제목이 남는 폭을 먹고 단추는 [닫기] 바로 왼쪽 */
.ui-modal__top > h3 {
margin-right: auto;
}
.ui-modal__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 큰 창(곁값 wide) — 화면 거의 꽉 · 몸 넘침만 스크롤. 화면별 클래스보다 앞서게 두 클래스 */
.ui-modal.ui-modal--wide {
width: 96vw;
height: 94vh;
--modal-max: 94vh;
overflow: hidden;
}
.ui-modal--wide > .ui-modal__body {
flex: 1;
min-height: 0;
overflow: auto;
}
.ui-modal__body {
display: flex;
flex-direction: column;
+23 -13
View File
@@ -2,6 +2,8 @@
* ui_template_modal.ts
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
* 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh).
* 안 넘긴 창은 지금 꼴 그대로.
* ========================================================================== */
import "./ui_template_modal.css";
@@ -10,8 +12,13 @@ import { createButton, el } from "./ui_template_elements";
export interface ModalOptions {
title: string;
closeLabel: string;
mount: (body: HTMLElement, close: () => void) => void;
/** `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>;
@@ -19,23 +26,24 @@ export interface ModalOptions {
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.dialogClass ?? ""}`.trim(),
className: `ui-modal ${opts.wide ? "ui-modal--wide " : ""}${opts.dialogClass ?? ""}`.trim(),
attrs: { role: "dialog", "aria-label": opts.title },
children: [
el("div", {
className: "ui-modal__top",
children: [
el("h3", { text: opts.title }),
createButton({ label: opts.closeLabel, variant: "ghost", onClick: () => void ask() }),
],
}),
body,
],
children: [top, body],
});
const backdrop = el("div", {
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
@@ -43,7 +51,9 @@ export function openModal(opts: ModalOptions): { close: () => void } {
});
backdrop.addEventListener("click", (ev) => ev.target === backdrop && void ask());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask());
opts.mount(body, close);
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() 는 붙기 전이라 안 먹음