From 7e584c1e7bd68cca3ab6405dfe5890013d6dc0a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:28:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui-kit):=20=EB=AC=B6=EC=9D=8C=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EC=97=B4=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20=C2=B7?= =?UTF-8?q?=20=EC=A0=91=EC=9D=B4=20=EC=B9=B4=EB=93=9C=20=C2=B7=20=EB=B9=88?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=C2=B7=20=EC=95=8C=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EB=9D=A0=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=C2=B7=20=EA=B0=92=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EC=A7=84=ED=96=89=20=EB=A7=89=EB=8C=80=20=C2=B7=20?= =?UTF-8?q?=EC=9E=91=EC=9D=80=20=EA=B7=B8=EB=9E=98=ED=94=84=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20(PLAN=20552-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 부품 파일: ui_template_card · empty · banner · stat · kv · toast · flow_legend(.ts) · 진행 막대 · 막대 · 원 그래프 덧붙임 - createCard · showToast · 로딩 가림막은 부품 파일로 옮기고 본체(ui_template_elements)가 다시 내보냄 — 부름 꼴 그대로 - 대시보드 emptyState = 빈 상태 부품을 씀(한 벌) · 선 카드 그림자 0 · 가림막 옛 보라 → --color-scrim - 부품 모음 화면 견본(밝음 · 어두움 · 상태) · 지킴 시험 test_552_3_* 열 - 전후 촬영: B01 여덟 화면 자리 · 화소 같음(실시간 CPU 값만 다름) · A01 · B08 카드 그림자만 다름 Co-Authored-By: Claude Opus 5.5 --- B01_Dashboard/B01_Dashboard_UI_PageKit.ts | 25 +--- .../B01_Dashboard_UI_Style_Pages.css | 27 ---- resources/tester/test_552_3_banner.py | 25 ++++ resources/tester/test_552_3_card.py | 74 ++++++++++ resources/tester/test_552_3_collapsible.py | 15 ++ resources/tester/test_552_3_empty.py | 19 +++ resources/tester/test_552_3_kv.py | 16 +++ resources/tester/test_552_3_legend.py | 24 ++++ resources/tester/test_552_3_mini_chart.py | 16 +++ resources/tester/test_552_3_progress.py | 23 ++++ resources/tester/test_552_3_stat.py | 23 ++++ resources/tester/test_552_3_toast.py | 27 ++++ ui_template/ui_template_banner.css | 69 ++++++++++ ui_template/ui_template_banner.ts | 65 +++++++++ ui_template/ui_template_card.css | 68 ++++++++++ ui_template/ui_template_card.ts | 68 ++++++++++ ui_template/ui_template_collapsible.css | 4 + ui_template/ui_template_collapsible.ts | 10 ++ ui_template/ui_template_elements.ts | 128 ++---------------- ui_template/ui_template_elements_chart.css | 32 +++++ ui_template/ui_template_elements_chart.ts | 105 ++++++++++++++ ui_template/ui_template_elements_styles.ts | 6 +- ui_template/ui_template_empty.css | 55 ++++++++ ui_template/ui_template_empty.ts | 44 ++++++ ui_template/ui_template_flow_legend.css | 57 ++++++++ ui_template/ui_template_flow_legend.ts | 49 +++++++ ui_template/ui_template_kv.css | 43 ++++++ ui_template/ui_template_kv.ts | 47 +++++++ ui_template/ui_template_progress.css | 49 +++++++ ui_template/ui_template_progress.ts | 58 ++++++++ ui_template/ui_template_stat.css | 70 ++++++++++ ui_template/ui_template_stat.ts | 55 ++++++++ ui_template/ui_template_toast.ts | 116 ++++++++++++++++ ui_template/ui_template_ui_kit_parts.ts | 54 ++++++-- 34 files changed, 1387 insertions(+), 179 deletions(-) create mode 100644 resources/tester/test_552_3_banner.py create mode 100644 resources/tester/test_552_3_card.py create mode 100644 resources/tester/test_552_3_collapsible.py create mode 100644 resources/tester/test_552_3_empty.py create mode 100644 resources/tester/test_552_3_kv.py create mode 100644 resources/tester/test_552_3_legend.py create mode 100644 resources/tester/test_552_3_mini_chart.py create mode 100644 resources/tester/test_552_3_progress.py create mode 100644 resources/tester/test_552_3_stat.py create mode 100644 resources/tester/test_552_3_toast.py create mode 100644 ui_template/ui_template_banner.css create mode 100644 ui_template/ui_template_banner.ts create mode 100644 ui_template/ui_template_card.css create mode 100644 ui_template/ui_template_card.ts create mode 100644 ui_template/ui_template_collapsible.css create mode 100644 ui_template/ui_template_elements_chart.css create mode 100644 ui_template/ui_template_empty.css create mode 100644 ui_template/ui_template_empty.ts create mode 100644 ui_template/ui_template_flow_legend.ts create mode 100644 ui_template/ui_template_kv.css create mode 100644 ui_template/ui_template_kv.ts create mode 100644 ui_template/ui_template_stat.css create mode 100644 ui_template/ui_template_stat.ts create mode 100644 ui_template/ui_template_toast.ts diff --git a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts index b08c8da0c..3722f88f6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts +++ b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts @@ -6,7 +6,8 @@ * ========================================================================== */ import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements"; -import { icon, type IconName } from "@ui/ui_template_icons"; +import { createEmpty } from "@ui/ui_template_empty"; +import { type IconName } from "@ui/ui_template_icons"; import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages"; import "./B01_Dashboard_UI_Style_Pages.css"; @@ -194,25 +195,11 @@ export interface EmptyOptions { onGuide?: () => void; } -/** 빈 상태 — 선 아이콘 + 한 줄 + 주 단추 + 가이드 링크 */ +/** 빈 상태 — 부품 「빈 상태」(ui_template_empty.ts) · ⋯ 같은 PageKit 아이콘만 여기서 그림 */ 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( - opts.icon in KIT_PATHS - ? kitIcon(opts.icon as KitIconName, 32) - : icon(opts.icon as IconName, 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; + const glyph = + opts.icon in KIT_PATHS ? kitIcon(opts.icon as KitIconName, 32) : (opts.icon as IconName); + return createEmpty({ ...opts, icon: glyph }); } export interface TabsHandle { diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css index 5cda5616a..98ad71bd5 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css @@ -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; diff --git a/resources/tester/test_552_3_banner.py b/resources/tester/test_552_3_banner.py new file mode 100644 index 000000000..f3f7691eb --- /dev/null +++ b/resources/tester/test_552_3_banner.py @@ -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 diff --git a/resources/tester/test_552_3_card.py b/resources/tester/test_552_3_card.py new file mode 100644 index 000000000..3d4d7ec88 --- /dev/null +++ b/resources/tester/test_552_3_card.py @@ -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") diff --git a/resources/tester/test_552_3_collapsible.py b/resources/tester/test_552_3_collapsible.py new file mode 100644 index 000000000..b8e2ab6e2 --- /dev/null +++ b/resources/tester/test_552_3_collapsible.py @@ -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 diff --git a/resources/tester/test_552_3_empty.py b/resources/tester/test_552_3_empty.py new file mode 100644 index 000000000..9c60c8021 --- /dev/null +++ b/resources/tester/test_552_3_empty.py @@ -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, icon: glyph });" 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") diff --git a/resources/tester/test_552_3_kv.py b/resources/tester/test_552_3_kv.py new file mode 100644 index 000000000..006703a87 --- /dev/null +++ b/resources/tester/test_552_3_kv.py @@ -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") diff --git a/resources/tester/test_552_3_legend.py b/resources/tester/test_552_3_legend.py new file mode 100644 index 000000000..0737d3e35 --- /dev/null +++ b/resources/tester/test_552_3_legend.py @@ -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") diff --git a/resources/tester/test_552_3_mini_chart.py b/resources/tester/test_552_3_mini_chart.py new file mode 100644 index 000000000..288057c49 --- /dev/null +++ b/resources/tester/test_552_3_mini_chart.py @@ -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 diff --git a/resources/tester/test_552_3_progress.py b/resources/tester/test_552_3_progress.py new file mode 100644 index 000000000..55320ae33 --- /dev/null +++ b/resources/tester/test_552_3_progress.py @@ -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") diff --git a/resources/tester/test_552_3_stat.py b/resources/tester/test_552_3_stat.py new file mode 100644 index 000000000..5693cc6db --- /dev/null +++ b/resources/tester/test_552_3_stat.py @@ -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") diff --git a/resources/tester/test_552_3_toast.py b/resources/tester/test_552_3_toast.py new file mode 100644 index 000000000..5db9f360a --- /dev/null +++ b/resources/tester/test_552_3_toast.py @@ -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 diff --git a/ui_template/ui_template_banner.css b/ui_template/ui_template_banner.css new file mode 100644 index 000000000..6955ea3c9 --- /dev/null +++ b/ui_template/ui_template_banner.css @@ -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); +} diff --git a/ui_template/ui_template_banner.ts b/ui_template/ui_template_banner.ts new file mode 100644 index 000000000..58b2d10e9 --- /dev/null +++ b/ui_template/ui_template_banner.ts @@ -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; +} diff --git a/ui_template/ui_template_card.css b/ui_template/ui_template_card.css new file mode 100644 index 000000000..dc650ccc9 --- /dev/null +++ b/ui_template/ui_template_card.css @@ -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; +} diff --git a/ui_template/ui_template_card.ts b/ui_template/ui_template_card.ts new file mode 100644 index 000000000..8c766f78d --- /dev/null +++ b/ui_template/ui_template_card.ts @@ -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)); +} diff --git a/ui_template/ui_template_collapsible.css b/ui_template/ui_template_collapsible.css new file mode 100644 index 000000000..ec576db86 --- /dev/null +++ b/ui_template/ui_template_collapsible.css @@ -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); +} diff --git a/ui_template/ui_template_collapsible.ts b/ui_template/ui_template_collapsible.ts index ae341d60d..836c998e6 100644 --- a/ui_template/ui_template_collapsible.ts +++ b/ui_template/ui_template_collapsible.ts @@ -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)` + * 대신하는 옛 변종 = 날 `
` — 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) — 누름은 접힘을 안 일으킴. diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 43051d1c4..9fe9f0c93 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -10,7 +10,6 @@ * ========================================================================== */ import { el } from "./ui_template_elements_base"; -import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress"; // 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04). // 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다. @@ -188,26 +187,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 @@ -224,103 +205,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 대체 diff --git a/ui_template/ui_template_elements_chart.css b/ui_template/ui_template_elements_chart.css new file mode 100644 index 000000000..f170d106b --- /dev/null +++ b/ui_template/ui_template_elements_chart.css @@ -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); +} diff --git a/ui_template/ui_template_elements_chart.ts b/ui_template/ui_template_elements_chart.ts index 0685caa44..2ceb01f7e 100644 --- a/ui_template/ui_template_elements_chart.ts +++ b/ui_template/ui_template_elements_chart.ts @@ -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( + tag: K, + attrs: Record, +): 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], + }); +} diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 811bdeef2..9748fb5c0 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -167,7 +167,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 { @@ -200,7 +200,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; } @@ -250,7 +250,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 { diff --git a/ui_template/ui_template_empty.css b/ui_template/ui_template_empty.css new file mode 100644 index 000000000..87aa49641 --- /dev/null +++ b/ui_template/ui_template_empty.css @@ -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); +} diff --git a/ui_template/ui_template_empty.ts b/ui_template/ui_template_empty.ts new file mode 100644 index 000000000..c8c8843ae --- /dev/null +++ b/ui_template/ui_template_empty.ts @@ -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; +} diff --git a/ui_template/ui_template_flow_legend.css b/ui_template/ui_template_flow_legend.css index bf1091873..22250fb79 100644 --- a/ui_template/ui_template_flow_legend.css +++ b/ui_template/ui_template_flow_legend.css @@ -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%; +} diff --git a/ui_template/ui_template_flow_legend.ts b/ui_template/ui_template_flow_legend.ts new file mode 100644 index 000000000..70eb58035 --- /dev/null +++ b/ui_template/ui_template_flow_legend.ts @@ -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; +} diff --git a/ui_template/ui_template_kv.css b/ui_template/ui_template_kv.css new file mode 100644 index 000000000..1d7fb757b --- /dev/null +++ b/ui_template/ui_template_kv.css @@ -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); +} diff --git a/ui_template/ui_template_kv.ts b/ui_template/ui_template_kv.ts new file mode 100644 index 000000000..bdbff289c --- /dev/null +++ b/ui_template/ui_template_kv.ts @@ -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; +} diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index 498ad60b5..e3a9480b5 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -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; +} diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index 048116cca..b2f2bdfe9 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -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 }; +} diff --git a/ui_template/ui_template_stat.css b/ui_template/ui_template_stat.css new file mode 100644 index 000000000..d7bd77053 --- /dev/null +++ b/ui_template/ui_template_stat.css @@ -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); +} diff --git a/ui_template/ui_template_stat.ts b/ui_template/ui_template_stat.ts new file mode 100644 index 000000000..e07bc09cc --- /dev/null +++ b/ui_template/ui_template_stat.ts @@ -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 }); +} diff --git a/ui_template/ui_template_toast.ts b/ui_template/ui_template_toast.ts new file mode 100644 index 000000000..4d73ea50a --- /dev/null +++ b/ui_template/ui_template_toast.ts @@ -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; +} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 4f8323ba5..e1e054131 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -23,13 +23,23 @@ import { 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"; /** 상태 줄 다섯 칸 */ export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const; @@ -90,6 +100,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 기본 ───────────────────────────────────────────── @@ -113,28 +131,40 @@ 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: "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: "float-panel", name: "떠 있는 판", group: "compound", kinds: "그림 위 판 · 끌기 머리 · 닫기 · 팝오버", file: "ui_template_float_panel.ts" }, - { key: "collapsible", name: "접이 카드", group: "compound", kinds: "패널 카드 · 구역 · 캐럿 셰브런", file: "ui_template_collapsible.ts" }, + { 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(".ui-collapsible__title")!, s); return open; } return col(open, createPanelCard("접힌 카드")); } }, { key: "panel-head", name: "패널 머리", group: "compound", kinds: "제목 · 부제 · 오른쪽 단추", file: "ui_template_panel_head.ts" }, { 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: "list-row", name: "목록 줄", group: "compound", kinds: "고르는 줄 · 두 줄 · 끌기 손잡이 · 색 띠", file: "ui_template_list_row.ts" }, { 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: "drawer", name: "오른쪽 서랍", group: "compound", kinds: "상세 · 설정", file: "ui_template_side_panels.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: "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: "toolbar", name: "도구줄", group: "compound", kinds: "단추 묶음 · 나눔선 · 찾기 · 고르기", file: "ui_template_toolbar.ts" }, { key: "steps", name: "단계 줄", group: "compound", kinds: "가로 막대 · 세로 목록 · compact", file: "ui_template_workflow_layout.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: "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" },