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:
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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() 는 붙기 전이라 안 먹음
|
||||
|
||||
Reference in New Issue
Block a user