diff --git a/resources/tester/test_modal_head_actions.py b/resources/tester/test_modal_head_actions.py new file mode 100644 index 000000000..45f9091b1 --- /dev/null +++ b/resources/tester/test_modal_head_actions.py @@ -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 diff --git a/ui_template/ui_template_modal.css b/ui_template/ui_template_modal.css index 725c285f7..f3de6f41d 100644 --- a/ui_template/ui_template_modal.css +++ b/ui_template/ui_template_modal.css @@ -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; diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index f02bb5a7e..580d2d762 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -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; @@ -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 => { 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() 는 붙기 전이라 안 먹음