merge(sub_laptop_6): 552-3 묶음 부품 15 — 부품 모음 목록 import 충돌 양쪽 살림(552-2 기본 import + 552-3 부품 파일 import)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* 아이콘 = `ui_template_icons`(552-2 에 여섯을 옮김).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import { createButton, createTag } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { icon, type IconName } from "@ui/ui_template_icons";
|
||||
import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages";
|
||||
@@ -70,107 +70,16 @@ export function pageFrame(host: HTMLElement, title: string, desc: string): PageF
|
||||
};
|
||||
}
|
||||
|
||||
export type Cell = string | Node;
|
||||
|
||||
export interface RowMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** 삭제 · 빼기 = 빨강 + 확인창 */
|
||||
danger?: boolean;
|
||||
/** 확인창 글(danger 일 때) */
|
||||
confirm?: string;
|
||||
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
|
||||
ownConfirm?: boolean;
|
||||
}
|
||||
|
||||
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
|
||||
export function rowMenu(items: RowMenuItem[]): HTMLElement {
|
||||
const wrap = node("div", "b01-pg-menu");
|
||||
if (!items.length) return wrap;
|
||||
const trigger = node("button", "b01-pg-menu__btn");
|
||||
trigger.type = "button";
|
||||
trigger.title = trigger.ariaLabel = "더 보기";
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.append(kitIcon("dots", 18));
|
||||
const pop = node("div", "b01-pg-menu__pop");
|
||||
pop.setAttribute("role", "menu");
|
||||
pop.hidden = true;
|
||||
const setOpen = (open: boolean): void => {
|
||||
pop.hidden = !open;
|
||||
trigger.setAttribute("aria-expanded", String(open));
|
||||
wrap.classList.toggle("is-open", open);
|
||||
};
|
||||
for (const item of items) {
|
||||
const b = node("button", `b01-pg-menu__item${item.danger ? " is-danger" : ""}`, item.label);
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.addEventListener("click", () => {
|
||||
setOpen(false);
|
||||
if (!item.danger || item.ownConfirm) return item.onClick();
|
||||
void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then(
|
||||
(ok) => ok && item.onClick(),
|
||||
);
|
||||
});
|
||||
pop.append(b);
|
||||
}
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(pop.hidden === true);
|
||||
});
|
||||
const outside = (e: MouseEvent): void => {
|
||||
if (!wrap.isConnected) document.removeEventListener("click", outside);
|
||||
else if (!wrap.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("click", outside);
|
||||
wrap.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false));
|
||||
wrap.append(trigger, pop);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export interface Column {
|
||||
label: string;
|
||||
/** 칸 너비(CSS) — 없으면 나눠 가짐 */
|
||||
width?: string;
|
||||
align?: "end";
|
||||
}
|
||||
|
||||
/** 표 껍데기 — 머리 + 빈 tbody (줄은 호출 쪽이 채움 · 펼침 줄 같은 것을 끼우려고 떼어 둠) */
|
||||
export function tableShell(columns: Column[]): { root: HTMLElement; tbody: HTMLElement } {
|
||||
const table = node("table", "b01-pg-table");
|
||||
const head = node("thead", "");
|
||||
const hr = node("tr", "");
|
||||
for (const col of columns) {
|
||||
const th = node("th", col.align ? "is-end" : "", col.label);
|
||||
if (col.width) th.style.width = col.width;
|
||||
hr.append(th);
|
||||
}
|
||||
head.append(hr);
|
||||
const tbody = node("tbody", "");
|
||||
table.append(head, tbody);
|
||||
const root = node("div", "b01-pg-card b01-pg-scroll");
|
||||
root.append(table);
|
||||
return { root, tbody };
|
||||
}
|
||||
|
||||
/** 한 줄 — 칸 수는 columns 와 같게 · label "" 인 칸 = ⋯ 칸 */
|
||||
export function tableRow(columns: Column[], cells: Cell[]): HTMLElement {
|
||||
const tr = node("tr", "");
|
||||
cells.forEach((cell, i) => {
|
||||
const col = columns[i];
|
||||
const td = node("td", col?.align ? "is-end" : col?.label === "" ? "b01-pg-menucell" : "");
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
/** 표 — 줄 높이 40 · 세로선 없음 · 마지막 칸 = ⋯ (columns 에 label "" 로) */
|
||||
export function dataTable(columns: Column[], rows: Cell[][]): HTMLElement {
|
||||
const { root, tbody } = tableShell(columns);
|
||||
tbody.append(...rows.map((cells) => tableRow(columns, cells)));
|
||||
return root;
|
||||
}
|
||||
// 부품(552-3) — 표 · 줄 끝 ⋯ · 설정 줄 = ui_template 부품 · 부름 꼴 그대로
|
||||
export { createRowMenu as rowMenu, type RowMenuItem } from "@ui/ui_template_context_menu";
|
||||
export {
|
||||
createTable as dataTable,
|
||||
tableRow,
|
||||
tableShell,
|
||||
type Cell,
|
||||
type Column,
|
||||
} from "@ui/ui_template_table";
|
||||
export { settingCard, settingRow, valueText } from "@ui/ui_template_setting_row";
|
||||
|
||||
export interface EmptyOptions {
|
||||
icon: IconName | KitIconName;
|
||||
@@ -235,22 +144,6 @@ export function tabBar(
|
||||
};
|
||||
}
|
||||
|
||||
/** 설정 카드 한 줄(Linear 꼴) — 제목 · 설명 왼쪽 · 값 · 조작 오른쪽 */
|
||||
export function settingRow(title: string, desc: string, right: Node): HTMLElement {
|
||||
const row = node("div", "b01-pg-row");
|
||||
const left = node("div", "b01-pg-row__text");
|
||||
left.append(node("strong", "", title));
|
||||
if (desc) left.append(node("span", "b01-pg-row__desc", desc));
|
||||
const side = node("div", "b01-pg-row__side");
|
||||
side.append(right);
|
||||
row.append(left, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 값 글 — 없으면 「-」 */
|
||||
export const valueText = (v: string | number | null | undefined): HTMLElement =>
|
||||
node("span", "b01-pg-row__value", v === null || v === undefined || v === "" ? "-" : String(v));
|
||||
|
||||
/** 줄 첫 칸 — 이름 + (작은 글) 보조 */
|
||||
export function nameCell(name: string, sub?: string | null): HTMLElement {
|
||||
const cell = node("div", "b01-pg-name");
|
||||
@@ -258,10 +151,3 @@ export function nameCell(name: string, sub?: string | null): HTMLElement {
|
||||
if (sub) cell.append(node("span", "b01-pg-name__sub", sub));
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** 카드 묶음 — 설정 줄을 둥근 상자 한 개에 */
|
||||
export function settingCard(rows: HTMLElement[]): HTMLElement {
|
||||
const card = node("div", "b01-pg-card b01-pg-settings");
|
||||
card.append(...rows);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ function input(value: string, type = "text", label = ""): HTMLInputElement {
|
||||
/** 입력 줄 아래에 오류 글 — 비우면 지움 */
|
||||
function fieldError(row: HTMLElement, message?: string): void {
|
||||
row.querySelector(".b01-sys-error")?.remove();
|
||||
if (message) row.querySelector(".b01-pg-row__text")?.append(el("span", "b01-sys-error", message));
|
||||
if (message)
|
||||
row.querySelector(".ui-setting-row__text")?.append(el("span", "b01-sys-error", message));
|
||||
}
|
||||
|
||||
export function buildProfileCard(user: DashboardUser): HTMLElement {
|
||||
|
||||
@@ -45,53 +45,6 @@
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: var(--b01-radius);
|
||||
}
|
||||
.b01-pg-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.b01-pg-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.b01-pg-table th {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--b01-muted);
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b01-pg-table td {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.b01-pg-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.b01-pg-table tbody tr:hover {
|
||||
background: var(--b01-hover);
|
||||
}
|
||||
.b01-pg-table .is-end {
|
||||
text-align: right;
|
||||
}
|
||||
.b01-pg-table td.b01-pg-menucell {
|
||||
width: 44px;
|
||||
padding: 0 8px;
|
||||
text-align: right;
|
||||
}
|
||||
.b01-pg-table tr.is-sub > td {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
background: var(--b01-bg);
|
||||
}
|
||||
.b01-pg-table tr.is-sub:hover {
|
||||
background: var(--b01-bg);
|
||||
}
|
||||
|
||||
/* 이름 칸 */
|
||||
.b01-pg-name {
|
||||
display: flex;
|
||||
@@ -127,60 +80,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 줄 끝 ⋯ */
|
||||
.b01-pg-menu {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.b01-pg-menu__btn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--b01-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.b01-pg-menu__btn:hover,
|
||||
.b01-pg-menu.is-open .b01-pg-menu__btn {
|
||||
background: var(--b01-hover);
|
||||
color: var(--b01-text);
|
||||
}
|
||||
.b01-pg-menu__pop {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
z-index: 20;
|
||||
min-width: 132px;
|
||||
padding: 4px;
|
||||
background: var(--b01-card);
|
||||
border: 1px solid var(--b01-line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 12%);
|
||||
text-align: left;
|
||||
}
|
||||
.b01-pg-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--b01-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.b01-pg-menu__item:hover {
|
||||
background: var(--b01-hover);
|
||||
}
|
||||
.b01-pg-menu__item.is-danger {
|
||||
color: var(--b01-alert);
|
||||
}
|
||||
|
||||
/* 탭 */
|
||||
.b01-pg-tabs {
|
||||
display: flex;
|
||||
@@ -222,46 +121,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 설정 줄(Linear 꼴) */
|
||||
.b01-pg-settings {
|
||||
overflow: hidden;
|
||||
}
|
||||
.b01-pg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 52px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--b01-line);
|
||||
}
|
||||
.b01-pg-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.b01-pg-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.b01-pg-row__desc {
|
||||
font-size: 12px;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg-row__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
}
|
||||
.b01-pg-row__side .ui-field__label:empty {
|
||||
display: none;
|
||||
}
|
||||
.b01-pg-row__value {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.b01-pg-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
@@ -460,7 +319,7 @@
|
||||
outline: 2px solid var(--b01-accent);
|
||||
outline-offset: 0;
|
||||
}
|
||||
.b01-pg-row__side .b01-sys-input {
|
||||
.ui-setting-row__side .b01-sys-input {
|
||||
width: 240px;
|
||||
}
|
||||
.b01-sys-count {
|
||||
|
||||
@@ -236,6 +236,7 @@ const { loadTs } = makeLoader({
|
||||
openDrawing: (item) => API.fetchDesignDrawing("p1", item.id),
|
||||
syncDrawings: (_, list) => calls.push(["sync", list.length]),
|
||||
};
|
||||
if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request}`);
|
||||
},
|
||||
|
||||
@@ -94,6 +94,7 @@ const { loadTs } = makeLoader({
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
|
||||
};
|
||||
if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함
|
||||
if (request.startsWith(".")) {
|
||||
const target = path.join(path.dirname(full), `${request}.ts`);
|
||||
return fs.existsSync(target) ? loadTs(target) : {};
|
||||
|
||||
@@ -206,6 +206,7 @@ const { loadTs } = makeLoader({
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request === "@ui/ui_template_collapsible")
|
||||
return loadTs(path.join(UI, "ui_template_collapsible.ts"));
|
||||
if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함
|
||||
if (request.startsWith(".")) {
|
||||
const target = path.join(path.dirname(full), `${request}.ts`);
|
||||
return fs.existsSync(target) ? loadTs(target) : {};
|
||||
|
||||
@@ -140,6 +140,7 @@ const { loadTs } = makeLoader({
|
||||
if (request === "@ui/ui_template_collapsible")
|
||||
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함
|
||||
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return require(request);
|
||||
},
|
||||
|
||||
@@ -60,5 +60,7 @@ def test_빈_초안은_고침_아님_취향은_둠(out):
|
||||
def test_공용_이동_한_곳에서_부름():
|
||||
nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8")
|
||||
assert "return enterProject(" in nav and "./b_project_switch" in nav
|
||||
ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8")
|
||||
assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui
|
||||
ui = (ROOT / "ui_template/ui_template_modal.ts").read_text(
|
||||
encoding="utf-8"
|
||||
) # 확인창 = 부품(552-3)
|
||||
assert 'cancelLabel = "취소"' in ui and "okLabel: confirmLabel, cancelLabel" in ui
|
||||
|
||||
@@ -82,8 +82,8 @@ def test_1_가림막_저장도_도중_고침_검사() -> None:
|
||||
assert "const touched = !route && designSignature(ctx.projectId) !== sent;" in save
|
||||
assert "if (touched && locked)" in save and "저장 중 고친 값은 아직 저장 안 됨" in save
|
||||
assert "!locked && designSignature" not in save
|
||||
elements = _read("ui_template/ui_template_elements.ts")
|
||||
assert "keydown" not in elements[elements.index("function ensureOverlay") :][:1200]
|
||||
toast = _read("ui_template/ui_template_toast.ts") # 가림막 = 부품 「알림」(552-3)
|
||||
assert "keydown" not in toast[toast.index("function ensureOverlay") :][:1200]
|
||||
|
||||
|
||||
def test_8_저장_중_초기화_막음() -> None:
|
||||
|
||||
@@ -22,4 +22,4 @@ def test_banner():
|
||||
assert 'tone === "success" ? "✓ "' in ts # 색만으로 안 가름
|
||||
for tone in ("warning", "danger", "success"):
|
||||
assert f".ui-banner--{tone}" in css
|
||||
assert '"aria-label": "닫기"' in ts
|
||||
assert 'createIconButton({ icon: "x", title: "닫기", size: 20 })' in ts # 단추 부품(552-2)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""552-3 부품 「파일 놓기 칸」 지킴 시험 — 끌어 옴(.is-over) · 고르기 단추(단추 부품 28) · 진행 · 오류 · 막힘."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_drop():
|
||||
check_part(
|
||||
"drop",
|
||||
"ui_template_drop.ts",
|
||||
["ui_template_drop.css"],
|
||||
["createDrop"],
|
||||
["ui-drop", "ui-drop__text", "ui-drop__hint", "ui-drop__error", "is-over"],
|
||||
)
|
||||
ts, css = _text("ui_template_drop.ts"), _text("ui_template_drop.css")
|
||||
assert 'label: "파일 고르기"' in ts and "size: 28" in ts
|
||||
assert 'input.value = ""' in ts # 같은 파일 다시 고름
|
||||
assert "createProgressBar({ thick: 6 })" in ts
|
||||
assert '.ui-drop:is(.is-over, [data-state="hover"])' in css
|
||||
assert '.ui-drop:is(.is-error, [data-state="error"])' in css
|
||||
assert "min-height: 48px" in css
|
||||
@@ -0,0 +1,32 @@
|
||||
"""552-3 부품 「모달 · 확인창」 지킴 시험 — 폭 단 · 확인 · 위험 확인 · 글 묻기(prompt 대신) · 옛 부름 꼴 · 가림막 색."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_modal():
|
||||
check_part(
|
||||
"modal",
|
||||
"ui_template_modal.ts",
|
||||
["ui_template_modal.css"],
|
||||
["openModal", "buildModal", "confirmPanel", "askConfirm", "askText", "showConfirmDialog"],
|
||||
[
|
||||
"ui-modal",
|
||||
"ui-modal__top",
|
||||
"ui-modal__body",
|
||||
"ui-modal--wide",
|
||||
"ui-confirm",
|
||||
"ui-confirm__input",
|
||||
],
|
||||
)
|
||||
ts, css = _text("ui_template_modal.ts"), _text("ui_template_modal.css")
|
||||
assert 'width?: "24rem" | "40rem" | "960px"' in ts
|
||||
for w in ("24rem", "40rem", "960px"):
|
||||
assert f'.ui-modal[data-width="{w}"]' in css
|
||||
assert 'variant: opts.danger ? "danger" : "filled"' in ts
|
||||
assert "r.ok ? r.text : null" in ts # 취소 = null(prompt 와 같음)
|
||||
assert "background-color: var(--color-scrim);" in css and "rgba(" not in css
|
||||
elements = _text("ui_template_elements.ts")
|
||||
assert (
|
||||
'export { askConfirm, askText, showConfirmDialog } from "./ui_template_modal";' in elements
|
||||
)
|
||||
assert "export function showConfirmDialog" not in elements # 한 벌
|
||||
@@ -0,0 +1,25 @@
|
||||
"""552-3 부품 「⋯ · 오른쪽 메뉴」 지킴 시험 — 아이콘 단추 28 · 위험 = 확인창 · 한 번에 하나 장부 · 대시보드 rowMenu 가 부품을 씀."""
|
||||
|
||||
from test_552_3_card import ROOT, _text
|
||||
|
||||
|
||||
def test_row_menu():
|
||||
ts = _text("ui_template_context_menu.ts")
|
||||
head = ts[ts.index("부품 「⋯ · 오른쪽 메뉴」") :].split("*/", 1)[0]
|
||||
for word in ("꼴", "크기", "상태", "쓰는 법", "대신하는"):
|
||||
assert word in head, word
|
||||
assert "export function createRowMenu" in ts
|
||||
assert 'createIconButton({ icon: "dots", title: "더 보기", size: 28 })' in ts
|
||||
assert "if (open) enterMenu(entry);" in ts and "showConfirmDialog(" in ts
|
||||
css = _text("ui_template_context_menu.css")
|
||||
mine = css[css.index("(552-3)") :]
|
||||
assert "rgb" not in mine and "#" not in mine and "--b01-" not in mine
|
||||
assert '.ui-row-menu__item:is(:hover, [data-state="hover"]):not(:disabled)' in mine
|
||||
assert ".ui-row-menu__item.is-danger" in mine and ".ui-row-menu__item:disabled" in mine
|
||||
kit = (ROOT / "B01_Dashboard" / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8")
|
||||
assert "createRowMenu as rowMenu, type RowMenuItem" in kit
|
||||
from test_552_3_card import kit_line
|
||||
|
||||
line = kit_line("row-menu")
|
||||
assert 'file: "ui_template_context_menu.ts"' in line and "demo:" in line
|
||||
assert "legacy: true" not in line
|
||||
@@ -0,0 +1,22 @@
|
||||
"""552-3 부품 「설정 줄」 지킴 시험 — 줄 52 · 오른쪽 55 % · 빈 값 「-」 · 대시보드 settingRow 가 부품을 씀."""
|
||||
|
||||
from test_552_3_card import ROOT, _text, check_part
|
||||
|
||||
|
||||
def test_setting_row():
|
||||
check_part(
|
||||
"setting-row",
|
||||
"ui_template_setting_row.ts",
|
||||
["ui_template_setting_row.css"],
|
||||
["settingRow", "settingCard"],
|
||||
["ui-setting-row", "ui-setting-row__text", "ui-setting-row__side", "ui-setting-card"],
|
||||
)
|
||||
css = _text("ui_template_setting_row.css")
|
||||
assert "min-height: 52px" in css and "max-width: 55%" in css
|
||||
b01 = ROOT / "B01_Dashboard"
|
||||
kit = (b01 / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8")
|
||||
assert (
|
||||
'export { settingCard, settingRow, valueText } from "@ui/ui_template_setting_row";' in kit
|
||||
)
|
||||
profile = (b01 / "B01_Dashboard_UI_Profile.ts").read_text(encoding="utf-8")
|
||||
assert ".ui-setting-row__text" in profile and ".b01-pg-row" not in profile
|
||||
@@ -0,0 +1,23 @@
|
||||
"""552-3 부품 「표」 지킴 시험 — 일반 40 · 데이터 28 · ⋯ 칸 · 빈 줄 · 줄 상태 · 대시보드 dataTable 이 부품을 씀."""
|
||||
|
||||
from test_552_3_card import ROOT, _text, check_part
|
||||
|
||||
|
||||
def test_table():
|
||||
check_part(
|
||||
"table",
|
||||
"ui_template_table.ts",
|
||||
["ui_template_table.css"],
|
||||
["createTable", "tableShell", "tableRow", "emptyRow"],
|
||||
["ui-table", "ui-table--data", "ui-table-wrap", "ui-table__menucell", "ui-table__empty"],
|
||||
)
|
||||
css = _text("ui_template_table.css")
|
||||
assert "height: 40px" in css and "height: 28px" in css
|
||||
for state in (".is-active", ".is-changed", ".is-removed", "tr.is-sub"):
|
||||
assert state in css, state
|
||||
kit = (ROOT / "B01_Dashboard" / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8")
|
||||
assert "createTable as dataTable," in kit # 한 벌 · 부름 꼴 그대로
|
||||
pages = (ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Style_Pages.css").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert ".b01-pg-table" not in pages
|
||||
@@ -51,19 +51,4 @@
|
||||
}
|
||||
.ui-banner__close {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-banner__close:is(:hover, [data-state="hover"]) {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* ui_template_banner.ts
|
||||
* 부품 「띠 알림」(552-3 · 목록 25) — 화면 안에 머무는 알림 줄(토스트처럼 사라지지 않음).
|
||||
* 꼴 = 왼쪽 색 띠 + 옅은 바탕 · 갈래 넷(정보 · 경고 · 위험 · 성공 — 성공은 ✓ 같이) · 제목(굵게) · 펼침(`details` → 「자세히」)
|
||||
* · 안 단추(`actions`) · 닫기(`onClose` → ✕)
|
||||
* · 안 단추(`actions`) · 닫기(`onClose` → ✕ 아이콘 단추 20)
|
||||
* 크기 = 글 13 · 줄 최소 32 · 여백 8 / 12
|
||||
* 상태 = 기본만
|
||||
* 쓰는 법 = `createBanner({ tone: "warning", text: "저장 안 한 고침이 있음", actions: [createButton(…)] })`
|
||||
@@ -10,6 +10,7 @@
|
||||
* · M01 `m01-master__notice` · `m01lab__warn` · M02 `m02-main__notice` · `m02-sod__banner` …(약 20 벌 · 목록 4장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "./ui_template_button";
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_banner.css";
|
||||
|
||||
@@ -50,11 +51,8 @@ export function createBanner(opts: BannerOptions): HTMLElement {
|
||||
root.append(el("div", { className: "ui-banner__actions", children: opts.actions }));
|
||||
if (opts.onClose) {
|
||||
const onClose = opts.onClose;
|
||||
const close = el("button", {
|
||||
className: "ui-banner__close",
|
||||
text: "✕",
|
||||
attrs: { type: "button", title: "닫기", "aria-label": "닫기" },
|
||||
});
|
||||
const close = createIconButton({ icon: "x", title: "닫기", size: 20 });
|
||||
close.classList.add("ui-banner__close");
|
||||
close.addEventListener("click", () => {
|
||||
root.remove();
|
||||
onClose();
|
||||
|
||||
@@ -62,3 +62,57 @@
|
||||
margin: var(--spacing-4) 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* ⋯ 줄 끝 메뉴(552-3) — 대시보드 꼴(단추 28 · 판 132 · 항목 줄 32) */
|
||||
.ui-row-menu {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.ui-row-menu__btn {
|
||||
display: grid;
|
||||
--ui-size-icon: 18px;
|
||||
}
|
||||
.ui-row-menu.is-open .ui-row-menu__btn {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-row-menu__pop {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
z-index: var(--z-dropdown);
|
||||
min-width: 132px;
|
||||
padding: var(--spacing-4);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: left;
|
||||
}
|
||||
.ui-row-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-row-menu__item:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
.ui-row-menu__item:is(:active, [data-state="active"]):not(:disabled) {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
.ui-row-menu__item:disabled {
|
||||
color: var(--color-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ui-row-menu__item.is-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* 모양은 공용 CSS 한 벌(`ui_template_context_menu.css` · `.ui-ctx-menu`) — 나무 메뉴와 같음(계획서 39-9b).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "./ui_template_button";
|
||||
import "./ui_template_context_menu.css";
|
||||
import { showConfirmDialog } from "./ui_template_modal";
|
||||
|
||||
/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면
|
||||
* 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2).
|
||||
@@ -223,3 +225,74 @@ export function createMapContextMenu(blockClass: string): MapContextMenu {
|
||||
contains: (target) => target instanceof Node && element.contains(target),
|
||||
};
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* 부품 「⋯ · 오른쪽 메뉴」(552-3 · 목록 20)
|
||||
* 꼴 = 줄 끝 ⋯(`createRowMenu` · 아이콘 단추 28 → 아래 판) · 오른쪽 누름 메뉴(`createMapContextMenu` · 위 · 하위판 ▸ · 구분선)
|
||||
* · 위험 항목(`danger` → 빨강 + 확인창 · `ownConfirm` 이면 확인창 없이)
|
||||
* 크기 = ⋯ 단추 28(아이콘 18) · 항목 줄 32 · 판 최소 폭 132(⋯) / 160(오른쪽 누름)
|
||||
* 상태 = 기본 · 마우스(항목 바탕) · 눌림(.is-open · 단추 바탕) · 막힘(항목 disabled)
|
||||
* 쓰는 법 = `createRowMenu([{ label: "열기", onClick: open }, { label: "삭제", danger: true, onClick: drop }])`
|
||||
* 대신하는 옛 변종 = B01 `rowMenu`(`.b01-pg-menu`) · `.b01-proj-menu__context-menu` · 시트 `.ss-toolbar__popover` · B06 `b06-design__more` · CAD `.cad-ribbon-overflow`
|
||||
* 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)를 같이 씀
|
||||
* ========================================================================== */
|
||||
|
||||
export interface RowMenuItem {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** 삭제 · 빼기 = 빨강 + 확인창 */
|
||||
danger?: boolean;
|
||||
/** 확인창 글(danger 일 때) */
|
||||
confirm?: string;
|
||||
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
|
||||
ownConfirm?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */
|
||||
export function createRowMenu(items: RowMenuItem[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ui-row-menu";
|
||||
if (!items.length) return wrap;
|
||||
const trigger = createIconButton({ icon: "dots", title: "더 보기", size: 28 });
|
||||
trigger.classList.add("ui-row-menu__btn");
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "ui-row-menu__pop";
|
||||
pop.setAttribute("role", "menu");
|
||||
pop.hidden = true;
|
||||
const entry: MenuEntry = {
|
||||
close: () => setOpen(false),
|
||||
contains: (target) => target instanceof Node && wrap.contains(target),
|
||||
};
|
||||
function setOpen(open: boolean): void {
|
||||
pop.hidden = !open;
|
||||
trigger.setAttribute("aria-expanded", String(open));
|
||||
wrap.classList.toggle("is-open", open);
|
||||
if (open) enterMenu(entry);
|
||||
else leaveMenu(entry);
|
||||
}
|
||||
for (const item of items) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `ui-row-menu__item${item.danger ? " is-danger" : ""}`;
|
||||
b.textContent = item.label;
|
||||
b.disabled = !!item.disabled;
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.addEventListener("click", () => {
|
||||
setOpen(false);
|
||||
if (!item.danger || item.ownConfirm) return item.onClick();
|
||||
void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then(
|
||||
(ok) => ok && item.onClick(),
|
||||
);
|
||||
});
|
||||
pop.append(b);
|
||||
}
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(pop.hidden === true);
|
||||
});
|
||||
wrap.append(trigger, pop);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* 파일 놓기 칸(552-3) — 점선 칸 · 끌어 오면 강조 · 오류 빨강 */
|
||||
.ui-drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 48px;
|
||||
padding: var(--spacing-16);
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
.ui-drop__icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ui-drop__text,
|
||||
.ui-drop__hint,
|
||||
.ui-drop__error {
|
||||
margin: 0;
|
||||
}
|
||||
.ui-drop__text {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.ui-drop__hint {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ui-drop .ui-progress {
|
||||
align-self: stretch;
|
||||
}
|
||||
.ui-drop:is(.is-over, [data-state="hover"]) {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.ui-drop:is(.is-over, [data-state="hover"]) .ui-drop__icon {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.ui-drop[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.ui-drop:is(.is-error, [data-state="error"]) {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.ui-drop__error {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* =============================================================================
|
||||
* ui_template_drop.ts
|
||||
* 부품 「파일 놓기 칸」(552-3 · 목록 34) — 파일을 끌어 놓거나 [파일 고르기]로 고르는 점선 칸.
|
||||
* 꼴 = 점선 칸(올림 아이콘 · 한 줄 · 고르기 단추 · 받는 꼴 안내) · 진행(`setProgress` → 진행 막대) · 오류(`setError` → 빨간 글)
|
||||
* 크기 = 칸 최소 48 · 단추 28
|
||||
* 상태 = 기본 · 마우스(파일을 끌어 옴 = .is-over) · 막힘(`disabled`) · 오류(.is-error)
|
||||
* 쓰는 법 = `const drop = createDrop({ accept: ".las,.laz", onFiles: (files) => upload(files) }); host.append(drop.root)`
|
||||
* 대신하는 옛 변종 = B03 `b03-file__dropzone` · CAD `body.cad-file-dragging::after` · 숨은 file + 단추(B01 · B08 · M02 · cad_host · help_edit)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "./ui_template_button";
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { icon } from "./ui_template_icons";
|
||||
import { createProgressBar } from "./ui_template_progress";
|
||||
import "./ui_template_drop.css";
|
||||
|
||||
export interface DropOptions {
|
||||
onFiles: (files: File[]) => void;
|
||||
/** input accept 글(`.las,.laz` · `image/*`) */
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
text?: string;
|
||||
/** 받는 꼴 · 크기 안내 */
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DropHandle {
|
||||
root: HTMLElement;
|
||||
/** 진행 막대 — null 이면 숨김 */
|
||||
setProgress: (ratio: number | null, label?: string) => void;
|
||||
/** 오류 글 — null 이면 지움 */
|
||||
setError: (message: string | null) => void;
|
||||
setDisabled: (on: boolean) => void;
|
||||
}
|
||||
|
||||
export function createDrop(opts: DropOptions): DropHandle {
|
||||
const input = el("input", { attrs: { type: "file" } });
|
||||
input.hidden = true;
|
||||
if (opts.accept) input.accept = opts.accept;
|
||||
input.multiple = !!opts.multiple;
|
||||
const pick = createButton({
|
||||
label: "파일 고르기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
const bar = createProgressBar({ thick: 6 });
|
||||
bar.root.hidden = true;
|
||||
const error = el("p", { className: "ui-drop__error" });
|
||||
error.hidden = true;
|
||||
const root = el("div", {
|
||||
className: "ui-drop",
|
||||
children: [
|
||||
el("span", { className: "ui-drop__icon", children: [icon("upload", 24)] }),
|
||||
el("p", { className: "ui-drop__text", text: opts.text ?? "파일을 여기에 끌어 놓거나" }),
|
||||
pick,
|
||||
input,
|
||||
],
|
||||
});
|
||||
if (opts.hint) root.append(el("p", { className: "ui-drop__hint", text: opts.hint }));
|
||||
root.append(bar.root, error);
|
||||
|
||||
const give = (list: FileList | null | undefined): void => {
|
||||
const files = [...(list ?? [])];
|
||||
if (files.length && !pick.disabled) opts.onFiles(opts.multiple ? files : files.slice(0, 1));
|
||||
};
|
||||
input.addEventListener("change", () => {
|
||||
give(input.files);
|
||||
input.value = ""; // 같은 파일을 다시 골라도 change
|
||||
});
|
||||
const over = (on: boolean): boolean => root.classList.toggle("is-over", on && !pick.disabled);
|
||||
root.addEventListener("dragover", (ev) => {
|
||||
ev.preventDefault();
|
||||
over(true);
|
||||
});
|
||||
root.addEventListener("dragleave", (ev) => {
|
||||
if (!root.contains(ev.relatedTarget as Node | null)) over(false);
|
||||
});
|
||||
root.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
over(false);
|
||||
give(ev.dataTransfer?.files);
|
||||
});
|
||||
|
||||
const setDisabled = (on: boolean): void => {
|
||||
pick.disabled = on;
|
||||
root.setAttribute("aria-disabled", String(on));
|
||||
};
|
||||
setDisabled(!!opts.disabled);
|
||||
return {
|
||||
root,
|
||||
setProgress: (ratio, label) => {
|
||||
bar.root.hidden = ratio === null;
|
||||
if (ratio !== null) bar.set(ratio, label);
|
||||
},
|
||||
setError: (message) => {
|
||||
error.hidden = !message;
|
||||
error.textContent = message ?? "";
|
||||
root.classList.toggle("is-error", !!message);
|
||||
},
|
||||
setDisabled,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createButton } from "./ui_template_button";
|
||||
|
||||
// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다.
|
||||
@@ -69,43 +68,8 @@ export {
|
||||
* 확인을 받아 어느 실행 환경에서도 같은 동작을 보장한다.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */
|
||||
export function showConfirmDialog(
|
||||
message: string,
|
||||
confirmLabel = "확인",
|
||||
cancelLabel = "취소",
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const panel = el("div", { className: "ui-confirm__panel" });
|
||||
const text = el("p", { className: "ui-confirm__message", text: message });
|
||||
const actions = el("div", { className: "ui-confirm__actions" });
|
||||
const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리
|
||||
const done = (result: boolean): void => {
|
||||
overlay.remove();
|
||||
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
|
||||
resolve(result);
|
||||
};
|
||||
const cancel = createButton({
|
||||
label: cancelLabel,
|
||||
variant: "ghost",
|
||||
onClick: () => done(false),
|
||||
});
|
||||
actions.append(
|
||||
cancel,
|
||||
createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }),
|
||||
);
|
||||
overlay.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음
|
||||
done(false);
|
||||
});
|
||||
panel.append(text, actions);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음
|
||||
});
|
||||
}
|
||||
// 부품 「모달 · 확인창」(552-3) = ui_template_modal.ts — 부름 꼴 그대로
|
||||
export { askConfirm, askText, showConfirmDialog } from "./ui_template_modal";
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 7. 워크플로우 3단 레이아웃 셸 (frontend.md §2)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.ui-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
background-color: rgba(38, 17, 74, 0.35); /* 공용 확인 창(.ui-confirm)과 같이 뒤를 가림 */
|
||||
background-color: var(--color-scrim); /* 공용 확인 창(.ui-confirm)과 같이 뒤를 가림 */
|
||||
}
|
||||
|
||||
/* 위에서 고정 거리 — 최대 높이일 때 가운데 자리에 윗변을 붙이고 아래로만 늘고 줆(찾기 칸이 안 움직임).
|
||||
@@ -64,3 +64,25 @@
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 모달 · 확인창(552-3) — 폭 단 · 확인창 제목 · 글 묻기 칸 */
|
||||
.ui-modal[data-width="24rem"] {
|
||||
width: min(24rem, 92vw);
|
||||
}
|
||||
.ui-modal[data-width="40rem"] {
|
||||
width: min(40rem, 92vw);
|
||||
}
|
||||
.ui-modal[data-width="960px"] {
|
||||
width: min(960px, 92vw);
|
||||
}
|
||||
.ui-confirm__title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-confirm__input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-bottom: var(--spacing-16);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/* =============================================================================
|
||||
* ui_template_modal.ts
|
||||
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
|
||||
* 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 닫음(칸 끌기로 안 닫힘).
|
||||
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
|
||||
* 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh).
|
||||
* 안 넘긴 창은 지금 꼴 그대로.
|
||||
* 부품 「모달 · 확인창」(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, el } from "./ui_template_elements";
|
||||
import { createButton } from "./ui_template_button";
|
||||
import { el } from "./ui_template_elements_base";
|
||||
|
||||
export interface ModalOptions {
|
||||
title: string;
|
||||
@@ -20,32 +26,44 @@ export interface ModalOptions {
|
||||
actions?: HTMLElement[];
|
||||
/** 큰 창 — 96vw × 94vh · 몸 넘침만 스크롤 */
|
||||
wide?: boolean;
|
||||
/** 폭 단 — 없으면 720 */
|
||||
width?: "24rem" | "40rem" | "960px";
|
||||
backdropClass?: string;
|
||||
/** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */
|
||||
beforeClose?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export function openModal(opts: ModalOptions): { close: () => void } {
|
||||
/** 모달 판 — 뒤 가림막 없이 판만(부품 모음 견본도 씀) · 머리 단추는 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: () => void ask(),
|
||||
onClick: onClose,
|
||||
});
|
||||
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.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],
|
||||
@@ -69,3 +87,104 @@ export function openModal(opts: ModalOptions): { close: () => void } {
|
||||
(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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* 설정 줄(552-3) — 대시보드 꼴(줄 52 · 아래 선 · 카드 하나) */
|
||||
.ui-setting-card {
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
.ui-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
min-height: 52px;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.ui-setting-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.ui-setting-row__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-setting-row__desc {
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ui-setting-row__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
}
|
||||
.ui-setting-row__side .ui-field__label:empty {
|
||||
display: none;
|
||||
}
|
||||
.ui-setting-row__value {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* =============================================================================
|
||||
* ui_template_setting_row.ts
|
||||
* 부품 「설정 줄」(552-3 · 목록 33) — 왼쪽 제목 · 설명 / 오른쪽 값 · 조작 한 줄(Linear 꼴) · 여러 줄은 둥근 상자 하나에.
|
||||
* 꼴 = 값 줄(`valueText` · 없으면 「-」) · 조작 줄(오른쪽에 입력 · 고르기 · 켜기 · 단추) · 설명 없는 줄
|
||||
* 크기 = 줄 최소 52 · 여백 8 / 16 · 오른쪽 칸 최대 55 %
|
||||
* 상태 = 기본만(조작 부품이 저마다 상태를 가짐)
|
||||
* 쓰는 법 = `settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계"))])`
|
||||
* 대신하는 옛 변종 = B01 `settingRow` · `settingCard`(`.b01-pg-row` · `.b01-pg-settings`) · `.b01-dashboard__setting-card` · E02 `e02-field` · E06 `e06-setting` · B08 `b08-hydro__row`
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_setting_row.css";
|
||||
|
||||
/** 한 줄 — 제목 · 설명 왼쪽 · 값 · 조작 오른쪽 */
|
||||
export function settingRow(title: string, desc: string, right: Node): HTMLElement {
|
||||
const left = el("div", {
|
||||
className: "ui-setting-row__text",
|
||||
children: [el("strong", { text: title })],
|
||||
});
|
||||
if (desc) left.append(el("span", { className: "ui-setting-row__desc", text: desc }));
|
||||
const side = el("div", { className: "ui-setting-row__side" });
|
||||
side.append(right);
|
||||
return el("div", { className: "ui-setting-row", children: [left, side] });
|
||||
}
|
||||
|
||||
/** 값 글 — 없으면 「-」 */
|
||||
export const valueText = (v: string | number | null | undefined): HTMLElement =>
|
||||
el("span", {
|
||||
className: "ui-setting-row__value",
|
||||
text: v === null || v === undefined || v === "" ? "-" : String(v),
|
||||
});
|
||||
|
||||
/** 설정 줄 묶음 — 둥근 상자 한 개 */
|
||||
export function settingCard(rows: HTMLElement[]): HTMLElement {
|
||||
return el("div", { className: "ui-setting-card", children: rows });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* 표(552-3) — 대시보드 꼴(줄 40 · 세로선 없음 · 카드 테두리) · 데이터표 = 줄 28 */
|
||||
.ui-table-wrap {
|
||||
overflow-x: auto;
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
.ui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-table th {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-table td {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ui-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.ui-table tbody tr:is(:hover, [data-state="hover"]) {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
.ui-table .is-end {
|
||||
text-align: right;
|
||||
}
|
||||
.ui-table td.ui-table__menucell {
|
||||
width: 44px;
|
||||
padding: 0 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 데이터표 — 줄 28 · 글 12 */
|
||||
.ui-table--data {
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.ui-table--data th {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.ui-table--data td {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 줄 상태 — 고름 · 바뀜 · 지움 */
|
||||
.ui-table tbody tr:is(.is-active, [aria-selected="true"]) {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.ui-table tbody tr.is-changed > td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-warning);
|
||||
}
|
||||
.ui-table tbody tr.is-removed {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 빈 줄 · 펼침 줄 */
|
||||
.ui-table td.ui-table__empty {
|
||||
height: 64px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.ui-table tr.is-sub > td {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.ui-table tbody tr.is-sub:is(:hover, [data-state="hover"]) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =============================================================================
|
||||
* ui_template_table.ts
|
||||
* 부품 「표」(552-3 · 목록 18) — 머리 줄 + 줄들 · 세로선 없음 · 카드 테두리 · 넘치면 가로 스크롤.
|
||||
* 꼴 = 일반표(줄 40 · 기본) · 데이터표(`density: "data"` · 줄 28 · 글 12) · ⋯ 칸(columns 의 label "" — 줄 끝 `createRowMenu`)
|
||||
* · 한글 배지 칸(칸에 배지 요소) · 오른쪽 맞춤 칸(`align: "end"` · 수) · 빈 줄(`emptyRow`) · 펼침 줄(tr.is-sub)
|
||||
* 크기 = 줄 40 / 28 · 머리 36 / 28
|
||||
* 상태 = 줄 마우스(옅은 바탕) · 고름(tr.is-active / aria-selected) · 바뀜(tr.is-changed) · 지움(tr.is-removed · 줄 그음)
|
||||
* 쓰는 법 = `createTable([{ label: "이름" }, { label: "", width: "44px" }], [["임도 1", createRowMenu([…])]])`
|
||||
* · 줄을 나중에 채우면 `const { root, tbody } = tableShell(columns); tbody.append(tableRow(columns, cells))`
|
||||
* 대신하는 옛 변종 = B01 `dataTable` · `tableShell`(`.b01-pg-table`) · `.b01-proj-table` · `.ui-general-block__table` · D04 · E02 · E03 · E06 · E07 손 표 · M01 · M02 표(목록 4장)
|
||||
* 옛 `.ui-data-table`(끌어 고치는 큰 표 · `ui_template_data_table.ts`)은 따로 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_table.css";
|
||||
|
||||
export type Cell = string | Node;
|
||||
|
||||
export interface Column {
|
||||
label: string;
|
||||
/** 칸 너비(CSS) — 없으면 나눠 가짐 */
|
||||
width?: string;
|
||||
align?: "end";
|
||||
}
|
||||
|
||||
export interface TableOptions {
|
||||
/** 데이터표(줄 28 · 글 12) */
|
||||
density?: "data";
|
||||
}
|
||||
|
||||
/** 표 껍데기 — 머리 + 빈 tbody (줄은 호출 쪽이 채움 · 펼침 줄 같은 것을 끼우려고 떼어 둠) */
|
||||
export function tableShell(
|
||||
columns: Column[],
|
||||
opts: TableOptions = {},
|
||||
): { root: HTMLElement; tbody: HTMLElement } {
|
||||
const table = el("table", {
|
||||
className: `ui-table${opts.density === "data" ? " ui-table--data" : ""}`,
|
||||
});
|
||||
const hr = el("tr");
|
||||
for (const col of columns) {
|
||||
const th = el("th", { className: col.align ? "is-end" : "", text: col.label });
|
||||
if (col.width) th.style.width = col.width;
|
||||
hr.append(th);
|
||||
}
|
||||
const tbody = el("tbody");
|
||||
table.append(el("thead", { children: [hr] }), tbody);
|
||||
return { root: el("div", { className: "ui-table-wrap", children: [table] }), tbody };
|
||||
}
|
||||
|
||||
/** 한 줄 — 칸 수는 columns 와 같게 · label "" 인 칸 = ⋯ 칸 */
|
||||
export function tableRow(columns: Column[], cells: Cell[]): HTMLTableRowElement {
|
||||
const tr = el("tr");
|
||||
cells.forEach((cell, i) => {
|
||||
const col = columns[i];
|
||||
const td = el("td", {
|
||||
className: col?.align ? "is-end" : col?.label === "" ? "ui-table__menucell" : "",
|
||||
});
|
||||
td.append(cell);
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
/** 빈 줄 — 칸을 다 합쳐 한 줄 안내 */
|
||||
export function emptyRow(columns: Column[], text: string): HTMLTableRowElement {
|
||||
const td = el("td", { className: "ui-table__empty", text });
|
||||
td.colSpan = columns.length;
|
||||
return el("tr", { children: [td] });
|
||||
}
|
||||
|
||||
/** 표 — 줄이 없으면 `empty` 글 한 줄 */
|
||||
export function createTable(
|
||||
columns: Column[],
|
||||
rows: Cell[][],
|
||||
opts: TableOptions & { empty?: string } = {},
|
||||
): HTMLElement {
|
||||
const { root, tbody } = tableShell(columns, opts);
|
||||
tbody.append(...rows.map((cells) => tableRow(columns, cells)));
|
||||
if (!rows.length && opts.empty) tbody.append(emptyRow(columns, opts.empty));
|
||||
return root;
|
||||
}
|
||||
@@ -28,16 +28,13 @@ import { createSegment } from "./ui_template_segment";
|
||||
import { createTabs } from "./ui_template_tabs";
|
||||
import { createText } from "./ui_template_text";
|
||||
import { createToggle } from "./ui_template_toggle";
|
||||
import {
|
||||
badge,
|
||||
dataTable,
|
||||
rowMenu,
|
||||
settingCard,
|
||||
settingRow,
|
||||
valueText,
|
||||
} from "../B01_Dashboard/B01_Dashboard_UI_PageKit";
|
||||
// 552-3 묶음 부품
|
||||
import { createBanner } from "./ui_template_banner";
|
||||
import { createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
|
||||
import { createDrop } from "./ui_template_drop";
|
||||
import { buildModal, confirmPanel } from "./ui_template_modal";
|
||||
import { settingCard, settingRow, valueText } from "./ui_template_setting_row";
|
||||
import { createTable, type Column } from "./ui_template_table";
|
||||
import { createCard } from "./ui_template_card";
|
||||
import { createPanelCard } from "./ui_template_collapsible";
|
||||
import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart";
|
||||
@@ -110,6 +107,31 @@ const row = (...children: HTMLElement[]): HTMLElement => {
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
/** 552-3 견본 — 표 칸 · 줄 · ⋯ 메뉴 항목 · 펼친 ⋯ 판 */
|
||||
const KIT_COLS: Column[] = [{ label: "노선" }, { label: "", width: "44px" }];
|
||||
const KIT_DATA: Column[] = [{ label: "노선" }, { label: "연장", align: "end" }];
|
||||
const KIT_MENU: RowMenuItem[] = [
|
||||
{ label: "이름 바꾸기", onClick: noop },
|
||||
{ label: "삭제", danger: true, onClick: noop },
|
||||
];
|
||||
const kitRows = (): (string | HTMLElement)[][] => [
|
||||
["1", createRowMenu(KIT_MENU)],
|
||||
["2", createRowMenu(KIT_MENU)],
|
||||
];
|
||||
const dataRows = [
|
||||
["1", "1,080"],
|
||||
["2", "640"],
|
||||
];
|
||||
const openMenu = (s: KitState, items = KIT_MENU): HTMLElement => {
|
||||
const menu = createRowMenu(items);
|
||||
menu.classList.add("is-open");
|
||||
const pop = menu.querySelector<HTMLElement>(".ui-row-menu__pop")!;
|
||||
pop.hidden = false;
|
||||
pop.style.position = "static";
|
||||
markState(pop.querySelector<HTMLElement>(".ui-row-menu__item")!, s);
|
||||
return menu;
|
||||
};
|
||||
|
||||
/** 세로로 쌓기(552-3 견본) */
|
||||
const col = (...children: HTMLElement[]): HTMLElement => {
|
||||
const box = row(...children);
|
||||
@@ -230,13 +252,19 @@ export const KIT_PARTS: KitPart[] = [
|
||||
{ key: "collapsible", name: "접이 카드", group: "compound", kinds: "패널 카드 · 구역 · 캐럿 ▾ ▸ · ⚙ 곁값", file: "ui_template_collapsible.ts",
|
||||
demo: (s) => { if (s !== "default" && s !== "hover") return null; const open = createPanelCard("구조물 배치", false, noop); open.append(row(createInputField({ placeholder: "측점" }).root));
|
||||
if (s === "hover") { markState(open.querySelector<HTMLElement>(".ui-collapsible__title")!, s); return open; } return col(open, createPanelCard("접힌 카드")); } },
|
||||
{ key: "table", name: "표", group: "compound", kinds: "데이터표(28 · 밀도) · 일반표(40) · ⋯ 칸 · 한글 배지 · 빈 줄", file: "ui_template_table.ts", legacy: true,
|
||||
demo: (s) => (s === "default" ? dataTable([{ label: "이름" }, { label: "상태", width: "90px" }, { label: "연장", width: "80px", align: "end" }, { label: "", width: "44px" }], [["임도 1 노선", badge("진행 중", "accent"), "1,080 m", rowMenu([{ label: "열기", onClick: noop }])], ["임도 2 노선", badge("완료", "success"), "640 m", rowMenu([{ label: "열기", onClick: noop }])]]) : null) },
|
||||
{ key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯ · 오른쪽 누름 메뉴 · 위험 항목", file: "ui_template_context_menu.ts", legacy: true,
|
||||
demo: (s) => (s === "default" ? rowMenu([{ label: "이름 바꾸기", onClick: noop }, { label: "삭제", danger: true, onClick: noop }]) : null) },
|
||||
{ key: "table", name: "표", group: "compound", kinds: "일반표(줄 40) · 데이터표(줄 28) · ⋯ 칸 · 한글 배지 · 빈 줄 · 줄 고름 · 바뀜 · 지움", file: "ui_template_table.ts",
|
||||
demo: (s) => { if (s === "disabled" || s === "error") return null; const t = s === "default" ? createTable(KIT_COLS, kitRows()) : createTable(KIT_DATA, dataRows, { density: "data" });
|
||||
const first = t.querySelector<HTMLElement>("tbody tr")!; if (s === "hover") markState(first, s); if (s === "active") first.classList.add("is-active");
|
||||
if (s !== "default") return t; const data = createTable(KIT_DATA, dataRows, { density: "data" }); const rows = data.querySelectorAll("tbody tr"); rows[0].classList.add("is-changed"); rows[1].classList.add("is-removed");
|
||||
return col(t, data, createTable(KIT_DATA, [], { empty: "노선 없음" })); } },
|
||||
{ key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯(28) · 펼친 판 · 위험 항목(빨강 + 확인창) · 막힌 항목", file: "ui_template_context_menu.ts",
|
||||
demo: (s) => { if (s === "error") return null; if (s === "default") return createRowMenu(KIT_MENU); if (s === "hover") { const m = createRowMenu(KIT_MENU); markState(m.querySelector<HTMLElement>(".ui-row-menu__btn")!, s); return m; }
|
||||
return openMenu(s, s === "disabled" ? [{ label: "내보내기", onClick: noop, disabled: true }, ...KIT_MENU] : KIT_MENU); } },
|
||||
{ key: "empty", name: "빈 상태", group: "compound", kinds: "선 아이콘 · 한 줄 · 주 단추 · 사용법 · 작은 꼴", file: "ui_template_empty.ts",
|
||||
demo: (s) => (s === "default" ? col(createEmpty({ icon: "folder", text: "아직 프로젝트가 없음", hint: "새 프로젝트를 만들어 시작", action: createButton({ label: "새 프로젝트", onClick: noop }), onGuide: noop }), createEmpty({ text: "고른 측점 없음", small: true })) : null) },
|
||||
{ key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통 · 넓음 · 확인 · 위험 확인", file: "ui_template_modal.ts" },
|
||||
{ key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통(720) · 넓음 · 폭 단 24rem · 40rem · 960 · 확인 · 위험 확인 · 글 묻기(prompt 대신)", file: "ui_template_modal.ts",
|
||||
demo: (s) => { if (s !== "default") return null; const m = buildModal({ title: "구조물 고르기", closeLabel: "닫기", width: "40rem" }, noop); m.body.append("몸 — 표 · 양식이 들어감"); m.dialog.style.margin = "0";
|
||||
return col(m.dialog, confirmPanel({ message: "노선을 저장할까요?" }).panel, confirmPanel({ title: "삭제", message: "임도 2 노선을 지울까요? 되돌릴 수 없음", okLabel: "삭제", danger: true }).panel, confirmPanel({ message: "블록 이름", field: { placeholder: "예: 배수관" } }).panel); } },
|
||||
{ key: "toast", name: "알림", group: "compound", kinds: "토스트 4꼴(성공 · 오류 · 정보 · 경고) · 로딩 가림막 · 진행 가림막", file: "ui_template_toast.ts",
|
||||
demo: (s) => (s === "default" ? col(toastNode("저장됨", "success"), toastNode("저장 못 함 — 다시 시도", "error"), toastNode("노선을 불러옴", "info"), toastNode("측점 간격이 넓음", "warning")) : null) },
|
||||
{ key: "banner", name: "띠 알림", group: "compound", kinds: "왼쪽 띠 · 정보 · 경고 · 위험 · 성공(✓) · 펼침 · 안 단추 · 닫기", file: "ui_template_banner.ts",
|
||||
@@ -252,9 +280,12 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) },
|
||||
{ key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts",
|
||||
demo: (s) => (s === "default" ? col(createLegend([{ label: "임도", color: "var(--color-chart-0)" }, { label: "설계선", color: "var(--color-chart-2)", kind: "line" }, { label: "원지반선", color: "var(--color-chart-1)", kind: "dash" }, { label: "관", color: "var(--color-chart-3)", kind: "dot" }, { label: "꺼진 층", color: "var(--color-chart-0)", off: true }], { onToggle: noop }), createLegend([{ label: "절토", color: "var(--color-danger)" }, { label: "성토", color: "var(--color-info)" }], { vertical: true })) : null) },
|
||||
{ key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 · 오른쪽 값 · 조작", file: "ui_template_setting_row.ts", legacy: true,
|
||||
demo: (s) => (s === "default" ? settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null))]) : null) },
|
||||
{ key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 진행", file: "ui_template_drop.ts" },
|
||||
{ key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 왼쪽 · 오른쪽 값 · 조작 · 줄 52", file: "ui_template_setting_row.ts",
|
||||
demo: (s) => (s === "default" ? settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null)), settingRow("기본 노폭", "새 노선에 먼저 들어감", createInputField({ placeholder: "3.0 m" }).root), settingRow("글꼴 파일", "", createButton({ label: "바꾸기", variant: "ghost", size: 28, onClick: noop }))]) : null) },
|
||||
{ key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 받는 꼴 안내 · 진행 · 오류", file: "ui_template_drop.ts",
|
||||
demo: (s) => { if (s === "active") return null; const d = createDrop({ accept: ".las,.laz", hint: "LAS · LAZ · 2 GB 까지", onFiles: noop, disabled: s === "disabled" });
|
||||
if (s === "error") d.setError("LAS · LAZ 파일만 받음"); if (s === "hover" || s === "error") markState(d.root, s);
|
||||
if (s !== "default") return d.root; const busy = createDrop({ onFiles: noop }); busy.setProgress(0.4, "40 %"); return col(d.root, busy.root); } },
|
||||
// ── 552-4 틀 ───────────────────────────────────────────────
|
||||
{ key: "work-frame", name: "작업 화면 틀", group: "frame", kinds: "왼쪽 패널(흰 판 · 1 px 선) · 본문 · 아래 단추 줄 · [?][<][>] · 머리 줄 없음 · ☰ 서랍", file: "ui_template_overlay.ts",
|
||||
demo: FRAME_DEMOS["work-frame"] },
|
||||
|
||||
Reference in New Issue
Block a user