Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, showConfirmDialog } 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";
|
||||
import "./B01_Dashboard_UI_Style_Pages.css";
|
||||
@@ -180,21 +181,9 @@ export interface EmptyOptions {
|
||||
onGuide?: () => void;
|
||||
}
|
||||
|
||||
/** 빈 상태 — 선 아이콘 + 한 줄 + 주 단추 + 가이드 링크 */
|
||||
/** 빈 상태 — 부품 「빈 상태」(ui_template_empty.ts) */
|
||||
export function emptyState(opts: EmptyOptions): HTMLElement {
|
||||
const box = node("div", "b01-pg-card b01-pg-empty");
|
||||
const glyph = node("div", "b01-pg-empty__icon");
|
||||
glyph.append(icon(opts.icon, 32));
|
||||
box.append(glyph, node("p", "b01-pg-empty__text", opts.text));
|
||||
if (opts.hint) box.append(node("p", "b01-pg-empty__hint", opts.hint));
|
||||
if (opts.action) box.append(opts.action);
|
||||
if (opts.onGuide) {
|
||||
const link = node("button", "b01-pg-link", "사용법 보기");
|
||||
link.type = "button";
|
||||
link.addEventListener("click", opts.onGuide);
|
||||
box.append(link);
|
||||
}
|
||||
return box;
|
||||
return createEmpty(opts);
|
||||
}
|
||||
|
||||
export interface TabsHandle {
|
||||
|
||||
@@ -262,33 +262,6 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.b01-pg-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.b01-pg-empty__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--b01-accent-soft);
|
||||
color: var(--b01-accent);
|
||||
}
|
||||
.b01-pg-empty__text {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.b01-pg-empty__hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--b01-muted);
|
||||
}
|
||||
.b01-pg-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""552-3 부품 「띠 알림」 지킴 시험 — 갈래 넷 · 성공 ✓ · 펼침 · 닫기."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_banner():
|
||||
check_part(
|
||||
"banner",
|
||||
"ui_template_banner.ts",
|
||||
["ui_template_banner.css"],
|
||||
["createBanner"],
|
||||
[
|
||||
"ui-banner",
|
||||
"ui-banner__title",
|
||||
"ui-banner__text",
|
||||
"ui-banner__actions",
|
||||
"ui-banner__close",
|
||||
],
|
||||
)
|
||||
ts, css = _text("ui_template_banner.ts"), _text("ui_template_banner.css")
|
||||
assert 'BannerTone = "info" | "warning" | "danger" | "success"' in ts
|
||||
assert 'tone === "success" ? "✓ "' in ts # 색만으로 안 가름
|
||||
for tone in ("warning", "danger", "success"):
|
||||
assert f".ui-banner--{tone}" in css
|
||||
assert '"aria-label": "닫기"' in ts
|
||||
@@ -0,0 +1,74 @@
|
||||
"""552-3 부품 「카드」 지킴 시험 + 묶음 부품 공용 규칙(`check_part` — 552-3 시험 파일들이 가져다 씀).
|
||||
|
||||
공용 규칙 = 파일 머리(꼴 · 크기 · 상태 · 쓰는 법 · 대신하는) · 만들기 함수 · 클래스 이름 · 날 색 · `--b01-*` · `--draw-*` 없음
|
||||
· font-family 없음 · 마우스 · 눌림 = `:is(:hover, [data-state="hover"])` 꼴 · 부품 모음 줄(file · demo) · 700 줄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
UI = ROOT / "ui_template"
|
||||
RAW_COLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(")
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (UI / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def kit_line(key: str) -> str:
|
||||
"""부품 모음 목록의 그 부품 줄(이어지는 demo 줄까지)."""
|
||||
src = _text("ui_template_ui_kit_parts.ts")
|
||||
m = re.search(r' \{ key: "%s",[^\n]*\n(?: [^\n]*\n)*' % re.escape(key), src)
|
||||
assert m, key
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def check_part(key: str, ts: str, css: list[str], create: list[str], classes: list[str]) -> None:
|
||||
head = _text(ts).split("*/", 1)[0]
|
||||
for word in ("꼴", "크기", "상태", "쓰는 법", "대신하는"):
|
||||
assert word in head, (ts, word)
|
||||
body = _text(ts)
|
||||
for fn in create:
|
||||
assert re.search(rf"export function {fn}\b", body), fn
|
||||
for cls in classes:
|
||||
assert cls in body + "".join(_text(c) for c in css), cls
|
||||
for name in [ts, *css]:
|
||||
text = _text(name)
|
||||
assert len(text.splitlines()) <= 700, name
|
||||
# 옛 파일에 덧붙인 CSS 는 「(552-3)」 표 뒤만 · 주석(머리 설명 · 옛 변종 이름)은 빼고 봄
|
||||
if name.endswith(".css") and "(552-3)" in text:
|
||||
text = text[text.index("(552-3)") :]
|
||||
code = re.sub(r"/\*[\s\S]*?\*/", "", text)
|
||||
assert not RAW_COLOR.search(code), (name, RAW_COLOR.search(code))
|
||||
assert "--b01-" not in code and "--draw-" not in code, name
|
||||
if name.endswith(".css"):
|
||||
assert "font-family" not in code, name
|
||||
for m in re.finditer(r":(hover|active)\b", code):
|
||||
around = code[max(0, m.start() - 4) : m.end() + 30]
|
||||
assert f'[data-state="{m.group(1)}"]' in around, (name, around)
|
||||
line = kit_line(key)
|
||||
assert f'file: "{ts}"' in line and "demo:" in line, key
|
||||
assert "legacy: true" not in line, key
|
||||
|
||||
|
||||
def test_card():
|
||||
check_part(
|
||||
"card",
|
||||
"ui_template_card.ts",
|
||||
["ui_template_card.css"],
|
||||
["createCard", "setCardSelected"],
|
||||
["ui-card", "ui-card--raised", "ui-card__title", "ui-card__body", "ui-card--select"],
|
||||
)
|
||||
css = _text("ui_template_card.css")
|
||||
assert '.ui-card[data-pad="12"]' in css and '.ui-card[data-pad="16"]' in css
|
||||
assert '[aria-pressed="true"]' in css and ":disabled" in css
|
||||
# 옛 부름 꼴 그대로 — 본체가 다시 내보냄 · 선 카드 그림자 0
|
||||
elements = _text("ui_template_elements.ts")
|
||||
assert (
|
||||
'export { createCard, setCardSelected, type CardOptions } from "./ui_template_card"'
|
||||
in elements
|
||||
)
|
||||
assert "box-shadow: none; /* 선 카드(552-3)" in _text("ui_template_elements_styles.ts")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""552-3 부품 「접이 카드」 지킴 시험 — 옛 클래스 · ⚙ 곁값 · 마우스 상태 · 캐럿 글자."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_collapsible():
|
||||
check_part(
|
||||
"collapsible",
|
||||
"ui_template_collapsible.ts",
|
||||
["ui_template_collapsible.css"],
|
||||
["createPanelCard", "attachCollapsible"],
|
||||
["ui-collapsible", "ui-collapsible__title", "is-collapsed", "ui-collapsible__settings"],
|
||||
)
|
||||
theme = _text("ui_template_theme.css")
|
||||
assert 'content: "▾"' in theme and 'content: "▸"' in theme
|
||||
@@ -0,0 +1,19 @@
|
||||
"""552-3 부품 「빈 상태」 지킴 시험 — 큰 꼴 · 작은 꼴 · 대시보드 emptyState 가 부품을 씀(한 벌)."""
|
||||
|
||||
from test_552_3_card import ROOT, _text, check_part
|
||||
|
||||
|
||||
def test_empty():
|
||||
check_part(
|
||||
"empty",
|
||||
"ui_template_empty.ts",
|
||||
["ui_template_empty.css"],
|
||||
["createEmpty"],
|
||||
["ui-empty", "ui-empty__icon", "ui-empty__text", "ui-empty__hint", "ui-empty--small"],
|
||||
)
|
||||
b01 = ROOT / "B01_Dashboard"
|
||||
kit = (b01 / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8")
|
||||
assert "return createEmpty(opts);" in kit
|
||||
pages_css = (b01 / "B01_Dashboard_UI_Style_Pages.css").read_text(encoding="utf-8")
|
||||
assert ".b01-pg-empty" not in pages_css
|
||||
assert "사용법 보기" in _text("ui_template_empty.ts")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""552-3 부품 「값 목록」 지킴 시험 — dl · 판정 ✓ ✗ · 빈 값 「-」 · 줄 24."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_kv():
|
||||
check_part(
|
||||
"kv",
|
||||
"ui_template_kv.ts",
|
||||
["ui_template_kv.css"],
|
||||
["createKv"],
|
||||
["ui-kv", "ui-kv__row", "ui-kv__label", "ui-kv__value", "ui-kv__mark", "ui-kv__row--big"],
|
||||
)
|
||||
ts = _text("ui_template_kv.ts")
|
||||
assert 'el("dl"' in ts and 'row.ok ? "✓" : "✗"' in ts and '"-"' in ts
|
||||
assert "min-height: 24px" in _text("ui_template_kv.css")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""552-3 부품 「범례」 지킴 시험 — 칩 14 × 10 · 선 · 점선 · 점 · 꺼짐 · 색은 부르는 쪽."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_legend():
|
||||
check_part(
|
||||
"legend",
|
||||
"ui_template_flow_legend.ts",
|
||||
["ui_template_flow_legend.css"],
|
||||
["createLegend"],
|
||||
[
|
||||
"ui-legend",
|
||||
"ui-legend__item",
|
||||
"ui-legend__mark",
|
||||
"ui-legend--vertical",
|
||||
"ui-flow-legend",
|
||||
],
|
||||
)
|
||||
css = _text("ui_template_flow_legend.css")
|
||||
for kind in ("line", "dash", "dot"):
|
||||
assert f".ui-legend__mark--{kind}" in css
|
||||
assert "width: 14px;\n height: 10px;" in css and ".is-off" in css
|
||||
assert 'kind?: "chip" | "line" | "dash" | "dot"' in _text("ui_template_flow_legend.ts")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""552-3 부품 「작은 그래프」 지킴 시험 — 막대 · 원 · 선 · 갈래 색 = --color-chart-N · 원 옆 범례 = 범례 부품."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_mini_chart():
|
||||
check_part(
|
||||
"mini-chart",
|
||||
"ui_template_elements_chart.ts",
|
||||
["ui_template_elements_chart.css"],
|
||||
["createBarChart", "createDonut", "createLineChart"],
|
||||
["ui-mini-bars", "ui-mini-donut", "ui-chart__empty", "ui-chart--mini"],
|
||||
)
|
||||
ts = _text("ui_template_elements_chart.ts")
|
||||
assert "`var(--color-chart-${i % 4})`" in ts
|
||||
assert "createLegend(" in ts
|
||||
@@ -0,0 +1,23 @@
|
||||
"""552-3 부품 「진행 막대」 지킴 시험 — 막대 6 · 8(data-size) · 쌓임 · 오류 상태 · 원 그대로."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_progress():
|
||||
check_part(
|
||||
"progress",
|
||||
"ui_template_progress.ts",
|
||||
["ui_template_progress.css"],
|
||||
["createProgressBar", "createProgressCircle"],
|
||||
[
|
||||
"ui-progress",
|
||||
"ui-progress__track",
|
||||
"ui-progress__fill",
|
||||
"ui-progress__label",
|
||||
"ui-progress-circle",
|
||||
],
|
||||
)
|
||||
css = _text("ui_template_progress.css")
|
||||
assert '.ui-progress[data-size="6"]' in css
|
||||
assert '.ui-progress:is(.is-error, [data-state="error"])' in css
|
||||
assert "root.dataset.size = String(opts.thick ?? 8)" in _text("ui_template_progress.ts")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""552-3 부품 「숫자 카드」 지킴 시험 — 값 22 · 변화 ▲▼ · 게이지 · 판정."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_stat():
|
||||
check_part(
|
||||
"stat",
|
||||
"ui_template_stat.ts",
|
||||
["ui_template_stat.css"],
|
||||
["createStat", "statGrid"],
|
||||
[
|
||||
"ui-stat",
|
||||
"ui-stat__label",
|
||||
"ui-stat__value",
|
||||
"ui-stat__delta",
|
||||
"ui-stat__bar",
|
||||
"ui-stat-grid",
|
||||
],
|
||||
)
|
||||
css = _text("ui_template_stat.css")
|
||||
assert "font-size: 22px" in css and ".ui-stat.is-warn" in css and ".ui-stat.is-bad" in css
|
||||
assert '"▼" : "▲"' in _text("ui_template_stat.ts")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""552-3 부품 「알림」 지킴 시험 — 토스트 넷 · 가림막 · 본체 다시 내보냄 · 가림막 색 = 공통 값."""
|
||||
|
||||
from test_552_3_card import _text, check_part
|
||||
|
||||
|
||||
def test_toast():
|
||||
check_part(
|
||||
"toast",
|
||||
"ui_template_toast.ts",
|
||||
[],
|
||||
[
|
||||
"showToast",
|
||||
"showLoadingOverlay",
|
||||
"hideLoadingOverlay",
|
||||
"setLoadingOverlayProgress",
|
||||
"toastNode",
|
||||
],
|
||||
["ui-toast", "ui-toast-container", "ui-overlay", "ui-spinner"],
|
||||
)
|
||||
ts = _text("ui_template_toast.ts")
|
||||
assert 'type ToastKind = "success" | "error" | "info" | "warning"' in ts
|
||||
assert ' showToast,\n type ToastKind,\n} from "./ui_template_toast";' in _text(
|
||||
"ui_template_elements.ts"
|
||||
)
|
||||
styles = _text("ui_template_elements_styles.ts")
|
||||
assert "rgba(38, 17, 74" not in styles # 옛 보라 가림막
|
||||
assert styles.count("background-color: var(--color-scrim);") >= 2
|
||||
@@ -0,0 +1,69 @@
|
||||
/* 띠 알림(552-3) — 왼쪽 색 띠 · 옅은 바탕 · 갈래 넷 */
|
||||
.ui-banner {
|
||||
--banner-tone: var(--color-info);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 32px;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--banner-tone);
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--banner-tone) 8%, var(--color-surface-raised));
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ui-banner--warning {
|
||||
--banner-tone: var(--color-warning);
|
||||
}
|
||||
.ui-banner--danger {
|
||||
--banner-tone: var(--color-danger);
|
||||
}
|
||||
.ui-banner--success {
|
||||
--banner-tone: var(--color-success);
|
||||
}
|
||||
.ui-banner__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-banner__title {
|
||||
color: var(--banner-tone);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ui-banner__text {
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.ui-banner__details > summary {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-banner__actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* ui_template_banner.ts
|
||||
* 부품 「띠 알림」(552-3 · 목록 25) — 화면 안에 머무는 알림 줄(토스트처럼 사라지지 않음).
|
||||
* 꼴 = 왼쪽 색 띠 + 옅은 바탕 · 갈래 넷(정보 · 경고 · 위험 · 성공 — 성공은 ✓ 같이) · 제목(굵게) · 펼침(`details` → 「자세히」)
|
||||
* · 안 단추(`actions`) · 닫기(`onClose` → ✕)
|
||||
* 크기 = 글 13 · 줄 최소 32 · 여백 8 / 12
|
||||
* 상태 = 기본만
|
||||
* 쓰는 법 = `createBanner({ tone: "warning", text: "저장 안 한 고침이 있음", actions: [createButton(…)] })`
|
||||
* 대신하는 옛 변종 = B01 `.b01-home__stale` · B03 `b03-file__resume` · `__error-message` · B06 `b06-section__notice-bar` · E05 · E06 `__stale`
|
||||
* · M01 `m01-master__notice` · `m01lab__warn` · M02 `m02-main__notice` · `m02-sod__banner` …(약 20 벌 · 목록 4장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_banner.css";
|
||||
|
||||
export type BannerTone = "info" | "warning" | "danger" | "success";
|
||||
|
||||
export interface BannerOptions {
|
||||
tone?: BannerTone;
|
||||
text: string;
|
||||
title?: string;
|
||||
/** 펼쳐 보는 자세한 글 · 목록 */
|
||||
details?: string | HTMLElement;
|
||||
actions?: HTMLElement[];
|
||||
/** 주면 오른쪽 ✕ — 누르면 띠를 지우고 부름 */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function createBanner(opts: BannerOptions): HTMLElement {
|
||||
const tone = opts.tone ?? "info";
|
||||
const root = el("div", {
|
||||
className: `ui-banner ui-banner--${tone}`,
|
||||
attrs: { role: tone === "danger" ? "alert" : "status" },
|
||||
});
|
||||
const body = el("div", { className: "ui-banner__body" });
|
||||
if (opts.title) body.append(el("strong", { className: "ui-banner__title", text: opts.title }));
|
||||
body.append(
|
||||
el("span", {
|
||||
className: "ui-banner__text",
|
||||
text: `${tone === "success" ? "✓ " : ""}${opts.text}`,
|
||||
}),
|
||||
);
|
||||
if (opts.details) {
|
||||
const more = el("details", { className: "ui-banner__details" });
|
||||
more.append(el("summary", { text: "자세히" }), opts.details);
|
||||
body.append(more);
|
||||
}
|
||||
root.append(body);
|
||||
if (opts.actions?.length)
|
||||
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": "닫기" },
|
||||
});
|
||||
close.addEventListener("click", () => {
|
||||
root.remove();
|
||||
onClose();
|
||||
});
|
||||
root.append(close);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* 카드(552-3) — 바탕 모양 `.ui-card` 는 ui_template_elements_styles.ts · 여기는 여백 단 · 머리 카드 · 고르는 카드 */
|
||||
.ui-card[data-pad="12"] {
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
.ui-card[data-pad="16"] {
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
/* 머리 카드 — 제목 줄 + 오른쪽 조각 · 아래 선 */
|
||||
.ui-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.ui-card__head > .ui-card__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ui-card__actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 고르는 카드 — 큰 단추 꼴 */
|
||||
.ui-card.ui-card--select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
.ui-card--select > .ui-card__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.ui-card--select:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.ui-card--select:is(:active, [data-state="active"]):not(:disabled) {
|
||||
background-color: var(--color-hover);
|
||||
}
|
||||
.ui-card--select:is(.is-active, [aria-pressed="true"]) {
|
||||
border-color: var(--color-accent);
|
||||
background-color: var(--color-accent-soft);
|
||||
}
|
||||
.ui-card--select:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ui-card--select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* =============================================================================
|
||||
* ui_template_card.ts
|
||||
* 부품 「카드」(552-3 · 목록 14) — 내용을 담는 흰 판.
|
||||
* 꼴 = 선 카드(기본 · 1 px 선 · 둥글기 10 · 그림자 0) · 떠 있는 카드(`raised`) · 머리 카드(`actions` — 제목 줄 + 오른쪽 조각 · 아래 선)
|
||||
* · 고르는 카드(`onSelect` — 큰 단추 꼴 · 누르면 고름)
|
||||
* 크기 = 여백 `pad: 12 | 16`(→ data-pad · 없으면 24)
|
||||
* 상태 = 고르는 카드만 마우스 · 눌림 · 고름(aria-pressed · .is-active) · 막힘(disabled)
|
||||
* 쓰는 법 = `createCard({ title: "개요", body: [table], pad: 16 })` · `createCard({ title: "새 노선", onSelect: go })`
|
||||
* 대신하는 옛 변종 = `.b01-pg-card` · `.b01-proj-card` · `.b01-new-project__card` · `b03-file__card` · `e02-card` · `m01lab__box` …(목록 4장)
|
||||
* 옛 이름 `ui-card` · `ui-card--raised` · `ui-card__title` · `ui-card__body` 그대로 냄 · 바탕 모양은 `ui_template_elements_styles.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_card.css";
|
||||
|
||||
export interface CardOptions {
|
||||
title?: string;
|
||||
/** 본문 자식 요소들 */
|
||||
body?: (HTMLElement | string)[];
|
||||
/** 그림자 강조 여부 (shadow-lg 사용) */
|
||||
raised?: boolean;
|
||||
/** 머리 카드 — 제목 줄 오른쪽 조각(단추 · 배지) */
|
||||
actions?: HTMLElement[];
|
||||
/** 안 여백(px) — 없으면 24 */
|
||||
pad?: 12 | 16;
|
||||
/** 고르는 카드 — 누르면 부름 */
|
||||
onSelect?: () => void;
|
||||
/** 고르는 카드가 지금 골라졌는지 */
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export function createCard(opts: CardOptions & { onSelect: () => void }): HTMLButtonElement;
|
||||
export function createCard(opts: CardOptions): HTMLDivElement;
|
||||
export function createCard(opts: CardOptions): HTMLDivElement | HTMLButtonElement {
|
||||
const pick = opts.onSelect;
|
||||
const card = el(pick ? "button" : "div", {
|
||||
className: `ui-card${opts.raised ? " ui-card--raised" : ""}${pick ? " ui-card--select" : ""}`,
|
||||
});
|
||||
if (opts.pad) card.dataset.pad = String(opts.pad);
|
||||
const title = opts.title ? el("h3", { className: "ui-card__title", text: opts.title }) : null;
|
||||
if (opts.actions?.length) {
|
||||
card.classList.add("ui-card--head");
|
||||
card.append(
|
||||
el("div", {
|
||||
className: "ui-card__head",
|
||||
children: [
|
||||
title ?? el("span"),
|
||||
el("div", { className: "ui-card__actions", children: opts.actions }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else if (title) card.append(title);
|
||||
if (opts.body) {
|
||||
card.append(el("div", { className: "ui-card__body", children: opts.body }));
|
||||
}
|
||||
if (pick && card instanceof HTMLButtonElement) {
|
||||
card.type = "button";
|
||||
setCardSelected(card, !!opts.selected);
|
||||
card.addEventListener("click", pick);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 고르는 카드 고름 표시 */
|
||||
export function setCardSelected(card: HTMLElement, on: boolean): void {
|
||||
card.classList.toggle("is-active", on);
|
||||
card.setAttribute("aria-pressed", String(on));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 접이 카드(552-3) — 마우스 상태 · 바탕 꼴은 ui_template_theme.css · ui_template_overlay.css */
|
||||
.ui-collapsible__title:is(:hover, [data-state="hover"])::after {
|
||||
color: var(--color-text);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/* =============================================================================
|
||||
* ui_template_collapsible.ts
|
||||
* 부품 「접이 카드」(552-3 · 목록 16) — 제목 줄을 누르면 몸이 접히고 펴지는 카드.
|
||||
* 꼴 = 패널 카드(`createPanelCard` · 왼쪽 패널 · 공용 양식 붙음 · ⚙ 곁값) · 구역(아무 상자에 클래스만 + `attachCollapsible`)
|
||||
* 크기 = 제목 줄 32(글 14 · 캐럿 ▾ ▸ 글자 1.7 배)
|
||||
* 상태 = 기본 · 마우스(캐럿 진해짐) · 펼침 / 접힘(`.is-collapsed`)
|
||||
* 쓰는 법 = `const card = createPanelCard("구조물 배치", false); card.append(form); attachCollapsible(panel)`
|
||||
* 대신하는 옛 변종 = 날 `<details>` — B06 넷 · E01 `e-missing` · E03 `e03-prob-src` · M01 넷 · M02 일곱 · A07 약관
|
||||
* 모양 = 제목 · 캐럿 `ui_template_theme.css` · 카드 `ui_template_overlay.css` · 마우스 `ui_template_collapsible.css`
|
||||
*
|
||||
* 페이지 공용 "접기 컨테이너" 동작. (B05 컨테이너 템플릿 기준으로 재사용)
|
||||
*
|
||||
* 규칙:
|
||||
@@ -11,6 +19,8 @@
|
||||
* 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_collapsible.css";
|
||||
|
||||
/**
|
||||
* 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임 · 기본 닫힘(389-3).
|
||||
* `onSettings` 를 넘기면 캐럿 바로 왼쪽 ⚙(280-5) — 누름은 접힘을 안 일으킴.
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createButton } from "./ui_template_button";
|
||||
import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress";
|
||||
|
||||
// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다.
|
||||
@@ -157,26 +156,8 @@ export function createSelectField(opts: SelectFieldOptions): SelectFieldHandle {
|
||||
* 3. 카드 / 패널 (Card) — design.md: white surface, 8px radius, shadow
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface CardOptions {
|
||||
title?: string;
|
||||
/** 본문 자식 요소들 */
|
||||
body?: (HTMLElement | string)[];
|
||||
/** 그림자 강조 여부 (shadow-lg 사용) */
|
||||
raised?: boolean;
|
||||
}
|
||||
|
||||
export function createCard(opts: CardOptions): HTMLDivElement {
|
||||
const card = el("div", {
|
||||
className: `ui-card${opts.raised ? " ui-card--raised" : ""}`,
|
||||
});
|
||||
if (opts.title) {
|
||||
card.append(el("h3", { className: "ui-card__title", text: opts.title }));
|
||||
}
|
||||
if (opts.body) {
|
||||
card.append(el("div", { className: "ui-card__body", children: opts.body }));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
// 부품 「카드」(552-3) = ui_template_card.ts — 부름 꼴 그대로
|
||||
export { createCard, setCardSelected, type CardOptions } from "./ui_template_card";
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 4. 태그 / 배지 (Tag / Pill) — design.md: 1440px radius pill
|
||||
@@ -193,103 +174,14 @@ export function createTag(label: string, variant: TagVariant = "neutral"): HTMLS
|
||||
* 싱글턴 오버레이. show/hide로 참조 카운트 관리.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
let overlayEl: HTMLDivElement | null = null;
|
||||
let overlayCount = 0;
|
||||
let overlayProgress: ProgressCircleHandle | null = null;
|
||||
|
||||
function ensureOverlay(): HTMLDivElement {
|
||||
if (overlayEl) return overlayEl;
|
||||
const spinner = el("div", { className: "ui-spinner", attrs: { "aria-hidden": "true" } });
|
||||
overlayEl = el("div", {
|
||||
className: "ui-overlay",
|
||||
attrs: { role: "status", "aria-live": "polite" },
|
||||
children: [spinner],
|
||||
});
|
||||
document.body.append(overlayEl);
|
||||
return overlayEl;
|
||||
}
|
||||
|
||||
/** 로딩 스피너 오버레이 표시 (중첩 호출 안전) */
|
||||
export function showLoadingOverlay(): void {
|
||||
overlayCount += 1;
|
||||
ensureOverlay().classList.add("is-active");
|
||||
}
|
||||
|
||||
/** 로딩 스피너 오버레이 해제 (모든 참조 종료 시 실제 숨김) */
|
||||
export function hideLoadingOverlay(): void {
|
||||
overlayCount = Math.max(0, overlayCount - 1);
|
||||
if (overlayCount === 0 && overlayEl) {
|
||||
overlayEl.classList.remove("is-active", "ui-overlay--progress");
|
||||
overlayProgress?.remove();
|
||||
overlayProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 띄운 잠금판에 진행 원 · 글(520-3) — 스피너 자리 · 모든 참조가 풀리면 함께 걷힘.
|
||||
* ratio null = 진행률 미상(회전만). 잠금판이 안 떠 있으면 아무것도 안 함. */
|
||||
export function setLoadingOverlayProgress(ratio: number | null, label?: string): void {
|
||||
if (overlayCount === 0) return;
|
||||
const overlay = ensureOverlay();
|
||||
if (!overlayProgress) {
|
||||
overlayProgress = createProgressCircle();
|
||||
overlay.append(overlayProgress.root);
|
||||
overlay.classList.add("ui-overlay--progress");
|
||||
}
|
||||
overlayProgress.set(ratio, label);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 6. 토스트 알림 (Toast) — 성공/에러 배너
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
export type ToastKind = "success" | "error" | "info" | "warning";
|
||||
|
||||
let toastContainer: HTMLDivElement | null = null;
|
||||
|
||||
function ensureToastContainer(): HTMLDivElement {
|
||||
if (toastContainer) return toastContainer;
|
||||
toastContainer = el("div", { className: "ui-toast-container" });
|
||||
document.body.append(toastContainer);
|
||||
return toastContainer;
|
||||
}
|
||||
|
||||
/** 직전에 띄운 문구와 그 소멸 시각 — 같은 문구가 연달아 오면 무시한다. */
|
||||
let lastToast: { key: string; until: number } | null = null;
|
||||
|
||||
/**
|
||||
* 토스트 표시. durationMs 후 자동 소멸.
|
||||
*
|
||||
* **같은 문구 연속 무시**(2026-08-30 사용자 지시 2) — 한계 절삭 안내처럼 한 조작에서
|
||||
* 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는
|
||||
* 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다.
|
||||
*/
|
||||
export function showToast(
|
||||
message: string,
|
||||
kind: ToastKind = "info",
|
||||
durationMs = 3000,
|
||||
/** 누르면 실행할 동작 — 지정하면 토스트가 클릭 대상이 된다(CAD 백업 되살리기 등). */
|
||||
onClick?: () => void,
|
||||
): void {
|
||||
const key = `${kind}::${message}`;
|
||||
const now = Date.now();
|
||||
if (lastToast && lastToast.key === key && now < lastToast.until) return;
|
||||
lastToast = { key, until: now + durationMs };
|
||||
const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message });
|
||||
if (onClick) {
|
||||
toast.style.cursor = "pointer";
|
||||
toast.addEventListener("click", () => {
|
||||
onClick();
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
ensureToastContainer().append(toast);
|
||||
// 진입 애니메이션 트리거
|
||||
requestAnimationFrame(() => toast.classList.add("is-visible"));
|
||||
window.setTimeout(() => {
|
||||
toast.classList.remove("is-visible");
|
||||
window.setTimeout(() => toast.remove(), 200);
|
||||
}, durationMs);
|
||||
}
|
||||
// 부품 「알림」(552-3) = ui_template_toast.ts — 부름 꼴 그대로
|
||||
export {
|
||||
hideLoadingOverlay,
|
||||
setLoadingOverlayProgress,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
type ToastKind,
|
||||
} from "./ui_template_toast";
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 6-1. 확인 대화상자 (Confirm) — window.confirm 대체
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* 작은 그래프(552-3) — 막대 · 원 · 대시보드 `.b01-home__bars` · `__donut` 꼴 · 선 그래프 꼴은 ui_template_elements_styles.ts */
|
||||
.ui-chart--mini .ui-chart__empty {
|
||||
padding: var(--spacing-16) 0;
|
||||
}
|
||||
.ui-mini-bars {
|
||||
display: block;
|
||||
max-width: 400px;
|
||||
}
|
||||
.ui-mini-bars text {
|
||||
font-size: 10px;
|
||||
fill: var(--color-text);
|
||||
}
|
||||
.ui-mini-donut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
.ui-mini-donut {
|
||||
flex: none;
|
||||
}
|
||||
.ui-mini-donut circle {
|
||||
fill: none;
|
||||
stroke-width: 6;
|
||||
}
|
||||
.ui-mini-donut .ui-mini-donut__track {
|
||||
stroke: var(--color-hover);
|
||||
}
|
||||
.ui-mini-donut text {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
fill: var(--color-text);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
/* =============================================================================
|
||||
* ui_template_elements_chart.ts
|
||||
* 부품 「작은 그래프」(552-3 · 목록 31) — 막대 · 원(도넛) · 선.
|
||||
* 꼴 = 가로 막대(`createBarChart` · 이름 · 막대 · 값) · 원(`createDonut` · 가운데 합 · 옆 범례) · 선(`createLineChart` · 아래)
|
||||
* 크기 = 막대 줄 22 · 원 지름 96 · 선 640 × 200 가상 폭(폭 100 %)
|
||||
* 상태 = 기본 · 빈 값이면 「자료 없음」(`.ui-chart__empty`)
|
||||
* 색 = 갈래 0~3 = `--color-chart-N` · 그 밖 색은 부르는 쪽이 CSS 값 글(`var(--…)`)로 줌
|
||||
* 쓰는 법 = `createBarChart([{ label: "산림설계", value: 12 }])` · `createDonut([{ label: "임도", value: 5, color: "var(--color-chart-1)" }])`
|
||||
* 대신하는 옛 변종 = B01 `.b01-home__bars` · `__donut` · `.b01-sys-chart` · `.b01-home__thumb`
|
||||
*
|
||||
* 공통 라인 차트 — 대시보드 리소스 그래프에 쓰는 SVG 꺾은선.
|
||||
*
|
||||
* `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 그대로
|
||||
@@ -7,6 +15,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createLegend } from "./ui_template_flow_legend";
|
||||
import "./ui_template_elements_chart.css";
|
||||
|
||||
export interface LineChartSeries {
|
||||
/** 범례에 표시할 이름 (i18n 결과 문자열) */
|
||||
@@ -183,3 +193,98 @@ export function createLineChart(opts: LineChartOptions): HTMLDivElement {
|
||||
* theme.css 변수만 참조. 앱 진입 시 1회 호출.
|
||||
* ========================================================================== */
|
||||
|
||||
const SVG_NS_CHART = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svgNode<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attrs: Record<string, string | number>,
|
||||
): SVGElementTagNameMap[K] {
|
||||
const n = document.createElementNS(SVG_NS_CHART, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, String(v));
|
||||
return n;
|
||||
}
|
||||
|
||||
export interface ChartItem {
|
||||
label: string;
|
||||
value: number;
|
||||
/** CSS 색 값 글(`var(--color-chart-2)`) — 없으면 갈래 차례(--color-chart-N) */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const chartColor = (item: ChartItem, i: number): string =>
|
||||
item.color ?? `var(--color-chart-${i % 4})`;
|
||||
|
||||
const emptyChart = (): HTMLDivElement =>
|
||||
el("div", {
|
||||
className: "ui-chart ui-chart--mini",
|
||||
children: [el("div", { className: "ui-chart__empty", text: "자료 없음" })],
|
||||
});
|
||||
|
||||
/** 가로 막대 — 이름 · 막대 · 값 · 줄 22 */
|
||||
export function createBarChart(items: ChartItem[], color?: string): HTMLDivElement {
|
||||
if (!items.length) return emptyChart();
|
||||
const max = Math.max(1, ...items.map((r) => r.value));
|
||||
const rowH = 22;
|
||||
const svg = svgNode("svg", {
|
||||
class: "ui-mini-bars",
|
||||
viewBox: `0 0 320 ${items.length * rowH}`,
|
||||
width: "100%",
|
||||
preserveAspectRatio: "xMinYMin meet",
|
||||
role: "img",
|
||||
});
|
||||
items.forEach((r, i) => {
|
||||
const y = i * rowH;
|
||||
const width = (r.value / max) * 180;
|
||||
const name = svgNode("text", { x: 0, y: y + 14 });
|
||||
name.textContent = r.label.length > 9 ? `${r.label.slice(0, 9)}…` : r.label;
|
||||
const count = svgNode("text", { x: 96 + width, y: y + 14 });
|
||||
count.textContent = String(r.value);
|
||||
const bar = svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3 });
|
||||
bar.style.fill = r.color ?? color ?? "var(--color-accent)";
|
||||
svg.append(name, bar, count);
|
||||
});
|
||||
return el("div", {
|
||||
className: "ui-chart ui-chart--mini",
|
||||
children: [svg as unknown as HTMLElement],
|
||||
});
|
||||
}
|
||||
|
||||
/** 원(도넛) — 가운데 = 합 · 옆 = 범례 */
|
||||
export function createDonut(items: ChartItem[]): HTMLDivElement {
|
||||
const total = items.reduce((a, r) => a + Math.max(0, r.value), 0);
|
||||
if (!total) return emptyChart();
|
||||
const svg = svgNode("svg", {
|
||||
class: "ui-mini-donut",
|
||||
viewBox: "0 0 42 42",
|
||||
width: 96,
|
||||
height: 96,
|
||||
});
|
||||
svg.append(svgNode("circle", { cx: 21, cy: 21, r: 15.9155, class: "ui-mini-donut__track" }));
|
||||
let start = 0;
|
||||
items.forEach((r, i) => {
|
||||
const pct = (Math.max(0, r.value) / total) * 100;
|
||||
const arc = svgNode("circle", {
|
||||
cx: 21,
|
||||
cy: 21,
|
||||
r: 15.9155,
|
||||
"stroke-dasharray": `${pct} ${100 - pct}`,
|
||||
"stroke-dashoffset": 25 - start,
|
||||
});
|
||||
arc.style.stroke = chartColor(r, i);
|
||||
svg.append(arc);
|
||||
start += pct;
|
||||
});
|
||||
const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" });
|
||||
sum.textContent = String(total);
|
||||
svg.append(sum);
|
||||
const legend = createLegend(
|
||||
items.map((r, i) => ({ label: `${r.label} ${r.value}`, color: chartColor(r, i) })),
|
||||
{
|
||||
vertical: true,
|
||||
},
|
||||
);
|
||||
return el("div", {
|
||||
className: "ui-chart ui-chart--mini ui-mini-donut-row",
|
||||
children: [svg as unknown as HTMLElement, legend],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ const BASE_CSS = `
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
padding: var(--card-padding);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: none; /* 선 카드(552-3) — 그림자 0 */
|
||||
}
|
||||
.ui-card--raised { box-shadow: var(--shadow-lg); border-color: transparent; }
|
||||
.ui-card__title {
|
||||
@@ -203,7 +203,7 @@ const BASE_CSS = `
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(38, 17, 74, 0.24);
|
||||
background-color: var(--color-scrim);
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
.ui-overlay.is-active { display: flex; }
|
||||
@@ -253,7 +253,7 @@ const BASE_CSS = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(38, 17, 74, 0.35);
|
||||
background-color: var(--color-scrim);
|
||||
z-index: var(--z-confirm);
|
||||
}
|
||||
.ui-confirm__panel {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* 빈 상태(552-3) — 대시보드 꼴(카드 · 아이콘 원 · 한 줄) */
|
||||
.ui-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-48) var(--spacing-16);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
.ui-empty__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.ui-empty__text {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ui-empty__hint {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ui-empty__link {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-accent);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 작은 꼴 — 패널 안 한 줄 */
|
||||
.ui-empty--small {
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.ui-empty--small .ui-empty__text {
|
||||
font-weight: 400;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* ui_template_empty.ts
|
||||
* 부품 「빈 상태」(552-3 · 목록 21) — 보일 것이 없을 때 그 자리에 놓는 안내.
|
||||
* 꼴 = 큰 꼴(카드 · 선 아이콘 원 · 한 줄 · 보조 글 · 주 단추 · 「사용법 보기」) · 작은 꼴(`small` — 패널 안 한 줄 · 테두리 없음)
|
||||
* 크기 = 아이콘 원 56 · 아이콘 32 · 글 14 / 보조 12
|
||||
* 상태 = 기본만
|
||||
* 쓰는 법 = `createEmpty({ icon: "folder", text: "아직 프로젝트가 없음", action: createButton(…) })` · `createEmpty({ text: "고른 측점 없음", small: true })`
|
||||
* 대신하는 옛 변종 = B01 `emptyState`(`.b01-pg-empty`) · `.ui-general-block__empty` · `.ui-chart__empty` · `.b01-proj-empty` · 페이지마다 `__empty` 약 25 벌
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { icon, type IconName } from "./ui_template_icons";
|
||||
import "./ui_template_empty.css";
|
||||
|
||||
export interface EmptyOptions {
|
||||
/** 선 아이콘 이름 또는 만든 아이콘 요소 — 작은 꼴은 안 씀 */
|
||||
icon?: IconName | HTMLElement;
|
||||
text: string;
|
||||
hint?: string;
|
||||
/** 주 단추 */
|
||||
action?: HTMLElement;
|
||||
/** 「사용법 보기」 링크 — 누르면 */
|
||||
onGuide?: () => void;
|
||||
/** 패널 안 한 줄 */
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
export function createEmpty(opts: EmptyOptions): HTMLElement {
|
||||
const box = el("div", { className: `ui-empty${opts.small ? " ui-empty--small" : ""}` });
|
||||
if (opts.icon && !opts.small) {
|
||||
const glyph = typeof opts.icon === "string" ? icon(opts.icon, 32) : opts.icon;
|
||||
box.append(el("div", { className: "ui-empty__icon", children: [glyph] }));
|
||||
}
|
||||
box.append(el("p", { className: "ui-empty__text", text: opts.text }));
|
||||
if (opts.hint) box.append(el("p", { className: "ui-empty__hint", text: opts.hint }));
|
||||
if (opts.action) box.append(opts.action);
|
||||
if (opts.onGuide) {
|
||||
const link = el("button", { className: "ui-empty__link", text: "사용법 보기" });
|
||||
link.type = "button";
|
||||
link.addEventListener("click", opts.onGuide);
|
||||
box.append(link);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
@@ -49,3 +49,60 @@
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
||||
/* 범례(552-3) — 색 칩 · 선 · 점선 · 점 · 꺼짐 */
|
||||
.ui-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4) var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.ui-legend--vertical {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ui-legend__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button.ui-legend__item {
|
||||
cursor: pointer;
|
||||
}
|
||||
button.ui-legend__item:is(:hover, [data-state="hover"]) {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-legend__item:is(.is-off, [data-state="off"]) {
|
||||
opacity: 0.45;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.ui-legend__mark {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--legend-color);
|
||||
}
|
||||
.ui-legend__mark--line,
|
||||
.ui-legend__mark--dash {
|
||||
height: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
border-top: 2px solid var(--legend-color);
|
||||
}
|
||||
.ui-legend__mark--dash {
|
||||
border-top-style: dashed;
|
||||
}
|
||||
.ui-legend__mark--dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* =============================================================================
|
||||
* ui_template_flow_legend.ts
|
||||
* 부품 「범례」(552-3 · 목록 32) — 그림 · 그래프 색이 무엇인지 한 줄씩.
|
||||
* 꼴 = 색 칩(14 × 10 · 기본) · 선(`line`) · 점선(`dash`) · 점(`dot`) · 가로 줄(기본) / 세로 목록(`vertical`)
|
||||
* 색 띠 눈금(지도 유입 강도)은 지금 B04 `createFlowLegend`(`.ui-flow-legend` · 이 파일 CSS)가 맡음
|
||||
* 크기 = 글 12 · 칩 14 × 10
|
||||
* 상태 = 기본 · 꺼짐(`off` → .is-off · 흐림 · 줄 그음) · `onToggle` 을 주면 항목이 단추(aria-pressed)
|
||||
* 색 = 부르는 쪽이 CSS 값 글로 줌(`var(--color-chart-1)` · 그림 색이면 `var(--draw-…)`) — 범례 자신은 색을 안 정함
|
||||
* 쓰는 법 = `createLegend([{ label: "설계선", color: "var(--draw-profile-design)", kind: "line" }])`
|
||||
* 대신하는 옛 변종 = B05 `b05-routeedit__legend` · `b05-routerec__legend` · B06 `b06-design__seg-legend` · `b06-std__legend` · E03 · E04 · E06 · E07 `__legend` · M02 `m02-dfdg__legend` · `.ui-chart__legend`
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_flow_legend.css";
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
/** CSS 색 값 글 */
|
||||
color: string;
|
||||
kind?: "chip" | "line" | "dash" | "dot";
|
||||
off?: boolean;
|
||||
}
|
||||
|
||||
export function createLegend(
|
||||
items: LegendItem[],
|
||||
opts: { vertical?: boolean; onToggle?: (index: number, on: boolean) => void } = {},
|
||||
): HTMLElement {
|
||||
const root = el("div", { className: `ui-legend${opts.vertical ? " ui-legend--vertical" : ""}` });
|
||||
items.forEach((item, i) => {
|
||||
const mark = el("i", { className: `ui-legend__mark ui-legend__mark--${item.kind ?? "chip"}` });
|
||||
mark.style.setProperty("--legend-color", item.color);
|
||||
const row = el(opts.onToggle ? "button" : "span", {
|
||||
className: `ui-legend__item${item.off ? " is-off" : ""}`,
|
||||
children: [mark, item.label],
|
||||
});
|
||||
if (opts.onToggle && row instanceof HTMLButtonElement) {
|
||||
const toggle = opts.onToggle;
|
||||
row.type = "button";
|
||||
row.setAttribute("aria-pressed", String(!item.off));
|
||||
row.addEventListener("click", () => {
|
||||
const on = row.classList.toggle("is-off") === false;
|
||||
row.setAttribute("aria-pressed", String(on));
|
||||
toggle(i, on);
|
||||
});
|
||||
}
|
||||
root.append(row);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* 값 목록(552-3) — 이름 왼쪽 · 값 오른쪽 · 줄 24 */
|
||||
.ui-kv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-kv__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-12);
|
||||
min-height: 24px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.ui-kv__row + .ui-kv__row {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.ui-kv__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.ui-kv__value {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ui-kv__row--big .ui-kv__value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ui-kv__mark {
|
||||
margin-right: var(--spacing-4);
|
||||
font-weight: 700;
|
||||
}
|
||||
.ui-kv__mark.is-ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.ui-kv__mark.is-bad {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* ui_template_kv.ts
|
||||
* 부품 「값 목록」(552-3 · 목록 29) — 「이름 : 값」 줄 여럿(dl).
|
||||
* 꼴 = 보통 줄 · 판정 줄(`ok: true` = ✓ · `false` = ✗ — 색만으로 안 가름) · 큰 값 줄(`big`)
|
||||
* 크기 = 줄 24 · 글 12(이름) / 14(값) · 큰 값 18
|
||||
* 상태 = 기본만
|
||||
* 쓰는 법 = `createKv([{ label: "연장", value: "1,080 m" }, { label: "종단 경사", value: "12 %", ok: true }])`
|
||||
* 대신하는 옛 변종 = B04 `b04-surface__line` · B07 `.b07-info__row` · CAD `.cad-properties dl` · E04 `e04-result__list` · E07 `e07-summary` · B08 `b08-hydro__row`
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_kv.css";
|
||||
|
||||
export interface KvRow {
|
||||
label: string;
|
||||
/** 없으면 「-」 */
|
||||
value: string | number | Node | null | undefined;
|
||||
/** 판정 — true ✓ · false ✗ */
|
||||
ok?: boolean;
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
export function createKv(rows: KvRow[]): HTMLDListElement {
|
||||
const list = el("dl", { className: "ui-kv" });
|
||||
for (const row of rows) {
|
||||
const value = el("dd", { className: "ui-kv__value" });
|
||||
const v = row.value;
|
||||
value.append(
|
||||
v instanceof Node ? v : v === null || v === undefined || v === "" ? "-" : String(v),
|
||||
);
|
||||
if (row.ok !== undefined) {
|
||||
value.prepend(
|
||||
el("span", {
|
||||
className: `ui-kv__mark ${row.ok ? "is-ok" : "is-bad"}`,
|
||||
text: row.ok ? "✓" : "✗",
|
||||
}),
|
||||
);
|
||||
}
|
||||
list.append(
|
||||
el("div", {
|
||||
className: `ui-kv__row${row.big ? " ui-kv__row--big" : ""}`,
|
||||
children: [el("dt", { className: "ui-kv__label", text: row.label }), value],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -90,3 +90,52 @@
|
||||
.ui-progress-circle__label:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 진행 막대(552-3) — 대시보드 `.b01-sys-gauge__bar` 꼴 · 굵기 = data-size */
|
||||
.ui-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
.ui-progress__track {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ui-progress[data-size="6"] .ui-progress__track {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ui-progress__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
.ui-progress__fill--soft {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.ui-progress__fill--warn,
|
||||
.ui-progress.is-warn .ui-progress__fill {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
.ui-progress.is-bad .ui-progress__fill,
|
||||
.ui-progress:is(.is-error, [data-state="error"]) .ui-progress__fill {
|
||||
background: var(--color-danger-fill);
|
||||
}
|
||||
.ui-progress:is(.is-error, [data-state="error"]) .ui-progress__label {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.ui-progress__label {
|
||||
flex: none;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ui-progress__label:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ import "./ui_template_progress.css";
|
||||
|
||||
/* =============================================================================
|
||||
* ui_template_progress.ts
|
||||
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
|
||||
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
|
||||
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
|
||||
* 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad)
|
||||
* 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })`
|
||||
* 대신하는 옛 변종 = B01 `.b01-proj-bar` · `.b01-pg-left__bar` · `.b01-pg-progress__bar` · `.b01-sys-gauge__bar` · `.b01-home__stack` · B03 `b03-file__progress-bar` · D03 날 progress · E04 · E07 `__progress`
|
||||
*
|
||||
* 공통 프로그레스 서클 (원형 진행 표시)
|
||||
*
|
||||
* 뷰포트 위에 얹어 "지금 무엇을 얼마나 불러왔는지"를 보여 주는 용도.
|
||||
@@ -94,3 +101,54 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr
|
||||
remove: () => root.remove(),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProgressBarOptions {
|
||||
/** 비율 0~1 — 쌓임 막대면 안 씀 */
|
||||
value?: number;
|
||||
/** 쌓임 막대 — 값은 서로의 비(합 = 막대 전체) */
|
||||
parts?: { value: number; tone?: "accent" | "soft" | "warn" }[];
|
||||
/** 굵기 px — 기본 8 */
|
||||
thick?: 6 | 8;
|
||||
tone?: "warn" | "bad";
|
||||
/** 막대 오른쪽 글(「40 %」 · 「3 / 7」) */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ProgressBarHandle {
|
||||
root: HTMLDivElement;
|
||||
/** 값 하나 막대 갱신 */
|
||||
set: (value: number, label?: string) => void;
|
||||
}
|
||||
|
||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
|
||||
/** 진행 막대 — 값 하나 또는 쌓임 */
|
||||
export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = `ui-progress${opts.tone ? ` is-${opts.tone}` : ""}`;
|
||||
root.dataset.size = String(opts.thick ?? 8);
|
||||
const track = document.createElement("div");
|
||||
track.className = "ui-progress__track";
|
||||
root.setAttribute("role", "progressbar");
|
||||
const text = document.createElement("span");
|
||||
text.className = "ui-progress__label";
|
||||
root.append(track, text);
|
||||
const total = (opts.parts ?? []).reduce((a, p) => a + Math.max(0, p.value), 0);
|
||||
for (const part of opts.parts ?? []) {
|
||||
const seg = document.createElement("i");
|
||||
seg.className = `ui-progress__fill ui-progress__fill--${part.tone ?? "accent"}`;
|
||||
seg.style.width = `${total ? (Math.max(0, part.value) / total) * 100 : 0}%`;
|
||||
track.append(seg);
|
||||
}
|
||||
const fill = document.createElement("i");
|
||||
fill.className = "ui-progress__fill";
|
||||
if (!opts.parts) track.append(fill);
|
||||
const set = (value: number, label?: string): void => {
|
||||
const v = clamp01(value);
|
||||
fill.style.width = `${v * 100}%`;
|
||||
root.setAttribute("aria-valuenow", String(Math.round(v * 100)));
|
||||
if (label !== undefined) text.textContent = label;
|
||||
};
|
||||
set(opts.value ?? 0, opts.label ?? "");
|
||||
return { root, set };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* 숫자 카드(552-3) — 대시보드 `.b01-sys-stat` · `.b01-sys-gauge` 꼴 */
|
||||
.ui-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
.ui-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-16);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-stat--compact {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
.ui-stat__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.ui-stat__value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ui-stat__unit {
|
||||
margin-left: var(--spacing-4);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ui-stat__delta {
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ui-stat__delta.is-up {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.ui-stat__delta.is-down {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.ui-stat__bar {
|
||||
height: 8px;
|
||||
margin-top: var(--spacing-4);
|
||||
border-radius: 4px;
|
||||
background: var(--color-hover);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ui-stat__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
.ui-stat.is-warn :is(.ui-stat__value, .ui-stat__unit) {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.ui-stat.is-warn .ui-stat__fill {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
.ui-stat.is-bad :is(.ui-stat__value, .ui-stat__unit) {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.ui-stat.is-bad .ui-stat__fill {
|
||||
background: var(--color-danger-fill);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* ui_template_stat.ts
|
||||
* 부품 「숫자 카드」(552-3 · 목록 28) — 큰 숫자 하나를 이름 · 단위 · 변화와 함께 보임.
|
||||
* 꼴 = 값 · 단위 · 변화(▲ 늘어남 · ▼ 줄어듦) · 게이지(`gauge` 0~1 → 막대 8) · 판정 색(`tone`: warn · bad)
|
||||
* 크기 = 값 글 22 · 이름 12 · 카드 여백 12 / 16
|
||||
* 상태 = 기본만(판정은 is-warn · is-bad)
|
||||
* 쓰는 법 = `createStat({ label: "진행 중", value: 12, unit: "건", delta: "+3" })` · `createStat({ label: "디스크", value: "71 %", gauge: 0.71, tone: "warn" })`
|
||||
* 여러 장은 `.ui-stat-grid` 에 담음(폭 맞춰 줄바꿈)
|
||||
* 대신하는 옛 변종 = B01 `.b01-home__kpi` · `.b01-pd-kpi` · `.b01-sys-stat` · `.b01-sys-gauge` · `.b01-dashboard__metric` · E03 `e03-kpi`
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import "./ui_template_stat.css";
|
||||
|
||||
export interface StatOptions {
|
||||
label: string;
|
||||
value: string | number;
|
||||
unit?: string;
|
||||
/** 변화 글 — 「+」로 시작하면 ▲ · 「-」면 ▼ */
|
||||
delta?: string;
|
||||
/** 게이지 비율 0~1 */
|
||||
gauge?: number;
|
||||
tone?: "warn" | "bad";
|
||||
/** 작은 카드(여백 12 · 숫자 · 이름만 한 덩어리) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function createStat(opts: StatOptions): HTMLElement {
|
||||
const card = el("div", {
|
||||
className: `ui-stat${opts.compact ? " ui-stat--compact" : ""}${opts.tone ? ` is-${opts.tone}` : ""}`,
|
||||
});
|
||||
const value = el("strong", { className: "ui-stat__value", text: String(opts.value) });
|
||||
if (opts.unit) value.append(el("span", { className: "ui-stat__unit", text: opts.unit }));
|
||||
card.append(el("span", { className: "ui-stat__label", text: opts.label }), value);
|
||||
if (opts.delta) {
|
||||
const down = opts.delta.startsWith("-");
|
||||
card.append(
|
||||
el("span", {
|
||||
className: `ui-stat__delta ${down ? "is-down" : "is-up"}`,
|
||||
text: `${down ? "▼" : "▲"} ${opts.delta}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (opts.gauge !== undefined) {
|
||||
const fill = el("i", { className: "ui-stat__fill" });
|
||||
fill.style.width = `${Math.round(Math.min(1, Math.max(0, opts.gauge)) * 100)}%`;
|
||||
card.append(el("div", { className: "ui-stat__bar", children: [fill] }));
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 숫자 카드 여러 장 — 폭에 맞춰 줄바꿈 */
|
||||
export function statGrid(cards: HTMLElement[]): HTMLElement {
|
||||
return el("div", { className: "ui-stat-grid", children: cards });
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* =============================================================================
|
||||
* ui_template_toast.ts
|
||||
* 부품 「알림」(552-3 · 목록 24) — 잠깐 떴다 사라지는 토스트 · 화면을 잠그는 로딩 가림막.
|
||||
* 꼴 = 토스트 넷(성공 · 오류 · 정보 · 경고 — 오른쪽 위 · 3 초) · 누르면 동작(`onClick`) · 로딩 가림막(스피너) · 진행 가림막(진행 원 · 단계 글)
|
||||
* 크기 = 토스트 글 14 · 여백 16 / 24
|
||||
* 상태 = 기본만(같은 문구가 떠 있는 동안 또 오면 버림)
|
||||
* 쓰는 법 = `showToast("저장됨", "success")` · `showLoadingOverlay()` … `hideLoadingOverlay()` · `setLoadingOverlayProgress(0.4, "지형 읽는 중")`
|
||||
* 부품 모음 견본 = `toastNode(글, 갈래)`(떠 있지 않은 토스트 한 장)
|
||||
* 대신하는 옛 변종 = 시트 `.ss-toast` · CAD `.aislo-cad-toast` · `.design-dock__busy` · `.cad-host[data-loading]`
|
||||
* 모양 = `ui_template_elements_styles.ts`(.ui-toast · .ui-overlay · 주입 차례가 페이지 CSS 뒤라 그대로 둠)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress";
|
||||
|
||||
/* 로딩 가림막 — 싱글턴 · show/hide 로 참조 수를 셈 */
|
||||
|
||||
let overlayEl: HTMLDivElement | null = null;
|
||||
let overlayCount = 0;
|
||||
let overlayProgress: ProgressCircleHandle | null = null;
|
||||
|
||||
function ensureOverlay(): HTMLDivElement {
|
||||
if (overlayEl) return overlayEl;
|
||||
const spinner = el("div", { className: "ui-spinner", attrs: { "aria-hidden": "true" } });
|
||||
overlayEl = el("div", {
|
||||
className: "ui-overlay",
|
||||
attrs: { role: "status", "aria-live": "polite" },
|
||||
children: [spinner],
|
||||
});
|
||||
document.body.append(overlayEl);
|
||||
return overlayEl;
|
||||
}
|
||||
|
||||
/** 로딩 스피너 오버레이 표시 (중첩 호출 안전) */
|
||||
export function showLoadingOverlay(): void {
|
||||
overlayCount += 1;
|
||||
ensureOverlay().classList.add("is-active");
|
||||
}
|
||||
|
||||
/** 로딩 스피너 오버레이 해제 (모든 참조 종료 시 실제 숨김) */
|
||||
export function hideLoadingOverlay(): void {
|
||||
overlayCount = Math.max(0, overlayCount - 1);
|
||||
if (overlayCount === 0 && overlayEl) {
|
||||
overlayEl.classList.remove("is-active", "ui-overlay--progress");
|
||||
overlayProgress?.remove();
|
||||
overlayProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 띄운 잠금판에 진행 원 · 글(520-3) — 스피너 자리 · 모든 참조가 풀리면 함께 걷힘.
|
||||
* ratio null = 진행률 미상(회전만). 잠금판이 안 떠 있으면 아무것도 안 함. */
|
||||
export function setLoadingOverlayProgress(ratio: number | null, label?: string): void {
|
||||
if (overlayCount === 0) return;
|
||||
const overlay = ensureOverlay();
|
||||
if (!overlayProgress) {
|
||||
overlayProgress = createProgressCircle();
|
||||
overlay.append(overlayProgress.root);
|
||||
overlay.classList.add("ui-overlay--progress");
|
||||
}
|
||||
overlayProgress.set(ratio, label);
|
||||
}
|
||||
|
||||
export type ToastKind = "success" | "error" | "info" | "warning";
|
||||
|
||||
let toastContainer: HTMLDivElement | null = null;
|
||||
|
||||
function ensureToastContainer(): HTMLDivElement {
|
||||
if (toastContainer) return toastContainer;
|
||||
toastContainer = el("div", { className: "ui-toast-container" });
|
||||
document.body.append(toastContainer);
|
||||
return toastContainer;
|
||||
}
|
||||
|
||||
/** 직전에 띄운 문구와 그 소멸 시각 — 같은 문구가 연달아 오면 무시한다. */
|
||||
let lastToast: { key: string; until: number } | null = null;
|
||||
|
||||
/**
|
||||
* 토스트 표시. durationMs 후 자동 소멸.
|
||||
*
|
||||
* **같은 문구 연속 무시**(2026-08-30 사용자 지시 2) — 한계 절삭 안내처럼 한 조작에서
|
||||
* 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는
|
||||
* 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다.
|
||||
*/
|
||||
export function showToast(
|
||||
message: string,
|
||||
kind: ToastKind = "info",
|
||||
durationMs = 3000,
|
||||
/** 누르면 실행할 동작 — 지정하면 토스트가 클릭 대상이 된다(CAD 백업 되살리기 등). */
|
||||
onClick?: () => void,
|
||||
): void {
|
||||
const key = `${kind}::${message}`;
|
||||
const now = Date.now();
|
||||
if (lastToast && lastToast.key === key && now < lastToast.until) return;
|
||||
lastToast = { key, until: now + durationMs };
|
||||
const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message });
|
||||
if (onClick) {
|
||||
toast.style.cursor = "pointer";
|
||||
toast.addEventListener("click", () => {
|
||||
onClick();
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
ensureToastContainer().append(toast);
|
||||
// 진입 애니메이션 트리거
|
||||
requestAnimationFrame(() => toast.classList.add("is-visible"));
|
||||
window.setTimeout(() => {
|
||||
toast.classList.remove("is-visible");
|
||||
window.setTimeout(() => toast.remove(), 200);
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
/** 부품 모음 견본 — 떠 있지 않은 토스트 한 장 */
|
||||
export function toastNode(message: string, kind: ToastKind = "info"): HTMLElement {
|
||||
const toast = el("div", { className: `ui-toast ui-toast--${kind} is-visible`, text: message });
|
||||
return toast;
|
||||
}
|
||||
@@ -24,13 +24,23 @@ import { ICON_NAMES, icon } from "./ui_template_icons";
|
||||
import {
|
||||
badge,
|
||||
dataTable,
|
||||
emptyState,
|
||||
rowMenu,
|
||||
settingCard,
|
||||
settingRow,
|
||||
tabBar,
|
||||
valueText,
|
||||
} from "../B01_Dashboard/B01_Dashboard_UI_PageKit";
|
||||
// 552-3 묶음 부품
|
||||
import { createBanner } from "./ui_template_banner";
|
||||
import { createCard } from "./ui_template_card";
|
||||
import { createPanelCard } from "./ui_template_collapsible";
|
||||
import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart";
|
||||
import { createEmpty } from "./ui_template_empty";
|
||||
import { createLegend } from "./ui_template_flow_legend";
|
||||
import { createKv } from "./ui_template_kv";
|
||||
import { createProgressBar, createProgressCircle } from "./ui_template_progress";
|
||||
import { createStat, statGrid } from "./ui_template_stat";
|
||||
import { toastNode } from "./ui_template_toast";
|
||||
import { FRAME_DEMOS } from "./ui_template_ui_kit_frames";
|
||||
|
||||
/** 상태 줄 다섯 칸 */
|
||||
@@ -94,6 +104,14 @@ const row = (...children: HTMLElement[]): HTMLElement => {
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
/** 세로로 쌓기(552-3 견본) */
|
||||
const col = (...children: HTMLElement[]): HTMLElement => {
|
||||
const box = row(...children);
|
||||
box.style.flexDirection = "column";
|
||||
box.style.alignItems = "stretch";
|
||||
return box;
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
export const KIT_PARTS: KitPart[] = [
|
||||
// ── 552-2 기본 ─────────────────────────────────────────────
|
||||
@@ -120,22 +138,34 @@ export const KIT_PARTS: KitPart[] = [
|
||||
{ key: "slider", name: "슬라이더", group: "basic", kinds: "값 하나 · 이름 · 값 글", file: "ui_template_input.ts" },
|
||||
{ key: "text", name: "보조 글 · 안내 줄", group: "basic", kinds: "흐린 글 · 안내 · 상태(오류 · 경고) · 칸 오류", file: "ui_template_text.ts" },
|
||||
// ── 552-3 묶음 ─────────────────────────────────────────────
|
||||
{ key: "card", name: "카드", group: "compound", kinds: "선 카드 · 머리 카드 · 고를 수 있는 카드", file: "ui_template_card.ts" },
|
||||
{ key: "collapsible", name: "접이 카드", group: "compound", kinds: "패널 카드 · 구역 · 캐럿 셰브런", file: "ui_template_collapsible.ts" },
|
||||
{ key: "card", name: "카드", group: "compound", kinds: "선 카드 · 머리 카드 · 고르는 카드 · 여백 12 · 16", file: "ui_template_card.ts",
|
||||
demo: (s) => { const pick = (): HTMLElement => markState(createCard({ title: "새 노선", body: ["노선을 그려 시작"], pad: 12, onSelect: noop }), s);
|
||||
return s === "error" ? null : s === "default" ? col(createCard({ title: "개요", body: ["임도 1 노선 · 1,080 m"], pad: 16 }), createCard({ title: "머리 카드", body: ["몸"], pad: 12, actions: [createButton({ label: "열기", variant: "ghost", size: "sm", onClick: noop })] }), createCard({ title: "고른 카드", body: ["골라짐"], pad: 12, onSelect: noop, selected: true })) : pick(); } },
|
||||
{ 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: "empty", name: "빈 상태", group: "compound", kinds: "선 아이콘 · 한 줄 · 주 단추 · 사용법", file: "ui_template_empty.ts", legacy: true,
|
||||
demo: (s) => (s === "default" ? emptyState({ icon: "folder", text: "아직 프로젝트가 없음" }) : null) },
|
||||
{ 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: "toast", name: "알림", group: "compound", kinds: "토스트 4꼴 · 로딩 가림막", file: "ui_template_toast.ts" },
|
||||
{ key: "banner", name: "띠 알림", group: "compound", kinds: "왼쪽 띠 · 정보 · 경고 · 위험 · 펼침 · 안 단추", file: "ui_template_banner.ts" },
|
||||
{ key: "stat", name: "숫자 카드", group: "compound", kinds: "값 · 단위 · 변화", file: "ui_template_stat.ts" },
|
||||
{ key: "kv", name: "값 목록", group: "compound", kinds: "이름 : 값 줄 · 판정(✓ ✗) · 큰 값", file: "ui_template_kv.ts" },
|
||||
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 · 원", file: "ui_template_progress.ts" },
|
||||
{ key: "mini-chart", name: "작은 그래프", group: "compound", kinds: "막대 · 원 · 선", file: "ui_template_elements_chart.ts" },
|
||||
{ key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 색 띠 눈금", file: "ui_template_flow_legend.ts" },
|
||||
{ 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",
|
||||
demo: (s) => (s === "default" ? col(createBanner({ title: "안내", text: "초기값에서 시작함" }), createBanner({ tone: "warning", text: "저장 안 한 고침이 있음", actions: [createButton({ label: "저장", size: "sm", onClick: noop })] }), createBanner({ tone: "danger", text: "횡단 3 곳 계산 못 함", details: "No.12 · No.15 · No.20" }), createBanner({ tone: "success", text: "검토 끝", onClose: noop })) : null) },
|
||||
{ key: "stat", name: "숫자 카드", group: "compound", kinds: "값 · 단위 · 변화 · 게이지 · 판정", file: "ui_template_stat.ts",
|
||||
demo: (s) => (s === "default" ? statGrid([createStat({ label: "진행 중", value: 12, unit: "건", delta: "+3" }), createStat({ label: "디스크", value: "71 %", gauge: 0.71, tone: "warn" }), createStat({ label: "오류", value: 2, delta: "-1", tone: "bad" }), createStat({ label: "회사", value: 8, compact: true })]) : null) },
|
||||
{ key: "kv", name: "값 목록", group: "compound", kinds: "이름 : 값 줄 · 판정(✓ ✗) · 큰 값", file: "ui_template_kv.ts",
|
||||
demo: (s) => (s === "default" ? createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]) : null) },
|
||||
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 오류", file: "ui_template_progress.ts",
|
||||
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);
|
||||
return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root); } },
|
||||
{ key: "mini-chart", name: "작은 그래프", group: "compound", kinds: "막대 · 원 · 선", file: "ui_template_elements_chart.ts",
|
||||
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" },
|
||||
|
||||
Reference in New Issue
Block a user