From 3ccb96fbdbc292c0f9a727e68e374acb6c6a3584 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:26:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui-kit):=20=EB=B6=80=ED=92=88=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=C2=B7=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=C2=B7=20=EC=95=84=EC=9D=B4=EC=BD=98=20=C2=B7=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=20=EC=97=AC=EB=8D=9F=20=EB=8B=A8=20(PLAN=205?= =?UTF-8?q?52-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_button.ts · .css 새로 — createButton 을 옮김(ui_template_elements 가 다시 내보냄 · 부르는 길 그대로) · createIconButton 새로(고스트 · 선 · 채움 · aria-pressed · 글자 기호 · title + aria-label) - 크기 = data-size 18 · 20 · 22 · 24 · 28 · 32 · 36 · 42 · 단마다 높이 · 글자 · 여백 · 아이콘 · 둥글기 값(--ui-size-*) · 입력 · 고르기 · 분할 · 탭이 같은 값을 읽음 - 단추 높이 = 글 한 줄 + 여백 + 테두리(높이를 박지 않음) — 크기를 안 준 옛 단추 · 페이지 여백 덮어쓰기 그대로 · 옛 size "sm" = 18 단(같은 꼴) · 글 링크 꼴 더함 - 상태 = 마우스 · 눌림을 data-state 로도 받음(부품 모음이 굳혀 보임) · 마우스 색 = --color-primary-hover · --color-accent-soft(옛 보라 별칭 대신) · 눌림 = 한 칸 내려앉음 - 아이콘 = Tabler 32 더함(지도 · 길 · 자 · 합 · 크레인 · 핀 · 나침반 · 설정 · 닫기 · 메뉴 · 되돌리기 …) + PageKit 여섯을 옮김 · .ui-icon 규칙 전역(ui_template_icons.css) - 부품 모음 = 단추 · 아이콘 단추 · 아이콘 견본 · 부품마다 여덟 단 한 줄 - 시험 test_552_2_button 새로(단 값 · 높이 식 · 옛 이름 · 상태 · 날 색 없음 · 아이콘) Co-Authored-By: Claude Opus 5.5 --- B01_Dashboard/B01_Dashboard_UI_PageKit.ts | 34 ++--- resources/tester/test_552_2_button.py | 102 ++++++++++++++ ui_template/ui_template_button.css | 152 +++++++++++++++++++++ ui_template/ui_template_button.ts | 113 +++++++++++++++ ui_template/ui_template_elements.ts | 37 +---- ui_template/ui_template_elements_styles.ts | 17 ++- ui_template/ui_template_icons.css | 6 + ui_template/ui_template_icons.ts | 62 +++++++++ ui_template/ui_template_ui_kit.css | 11 ++ ui_template/ui_template_ui_kit.ts | 7 +- ui_template/ui_template_ui_kit_parts.ts | 20 ++- 11 files changed, 486 insertions(+), 75 deletions(-) create mode 100644 resources/tester/test_552_2_button.py create mode 100644 ui_template/ui_template_button.css create mode 100644 ui_template/ui_template_button.ts create mode 100644 ui_template/ui_template_icons.css diff --git a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts index b08c8da0c..2b54af997 100644 --- a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts +++ b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts @@ -2,7 +2,7 @@ * B01_Dashboard_UI_PageKit.ts * 대시보드 세부 페이지 공용 조각(549) — 페이지 머리 · 표 · 줄 끝 ⋯ · 배지 · 탭 · 빈 상태. * 색 · 간격 = 셸 변수(`--b01-*`) 만 · 모양은 `B01_Dashboard_UI_Style_Pages.css`. - * 아이콘 중 `ui_template_icons` 에 없는 것(⋯ · + · 휴지통 · 상자)만 여기 · Tabler Icons(MIT) 선 모양. + * 아이콘 = `ui_template_icons`(552-2 에 여섯을 옮김). * ========================================================================== */ import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements"; @@ -10,28 +10,14 @@ 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"; -/** Tabler Icons(MIT · https://tabler.io/icons) — `ui_template_icons` 와 같은 24 × 24 · 선 2 */ -const KIT_PATHS = { - dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0", - plus: "M12 5l0 14|M5 12l14 0", - pencil: "M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4|M13.5 6.5l4 4", - upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12", - mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6", - inbox: - "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3", -} as const; -export type KitIconName = keyof typeof KIT_PATHS; +/** 아이콘 여섯 — 552-2 에 `ui_template_icons` 로 옮김(같은 Tabler 선) · 이름 · 기본 크기 16 그대로 */ +export type KitIconName = Extract< + IconName, + "dots" | "plus" | "pencil" | "upload" | "mail" | "inbox" +>; export function kitIcon(name: KitIconName, size = 16): HTMLSpanElement { - const span = document.createElement("span"); - span.className = "ui-icon"; - span.setAttribute("aria-hidden", "true"); - const paths = KIT_PATHS[name] - .split("|") - .map((d) => ``) - .join(""); - span.innerHTML = `${paths}`; - return span; + return icon(name, size); } function node( @@ -198,11 +184,7 @@ export interface EmptyOptions { 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), - ); + 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); diff --git a/resources/tester/test_552_2_button.py b/resources/tester/test_552_2_button.py new file mode 100644 index 000000000..9ef5d3955 --- /dev/null +++ b/resources/tester/test_552_2_button.py @@ -0,0 +1,102 @@ +"""552-2 부품 「단추」 · 「아이콘 단추」 · 「아이콘」 · 크기 여덟 단 지킴 시험 — 화면 확인은 ORCA. + +· 크기 = data-size 여덟 단(18 ~ 42) · 단마다 높이 · 글자 · 여백 · 아이콘 · 둥글기 · 옛 sm = 18 +· 단추 높이 = 글 한 줄 + 여백 + 테두리 = 단(높이를 박지 않음 — 페이지 여백 덮어쓰기 그대로) +· 상태 = :hover · :active 를 data-state 로도 받음 · 아이콘 단추 = title + aria-label +· 색 = 공통 값만(날 색 · --b01-* · --draw-* 없음) · 옛 클래스 이름 그대로 · 부르는 길(ui_template_elements) 그대로 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "ui_template" +SIZES = [18, 20, 22, 24, 28, 32, 36, 42] + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _rungs(css: str) -> dict[int, dict[str, str]]: + out = {} + for size, body in re.findall(r':where\(\[data-size="(\d+)"\]\) \{([^}]*)\}', css): + out[int(size)] = dict(re.findall(r"--ui-size-([\w-]+):\s*([^;]+);", body)) + return out + + +def test_여덟_단(): + src = _text(UI / "ui_template_button.ts") + assert "UI_SIZES = [18, 20, 22, 24, 28, 32, 36, 42] as const" in src + rungs = _rungs(_text(UI / "ui_template_button.css")) + assert sorted(rungs) == SIZES + for size, v in rungs.items(): + assert v["h"] == f"{size}px" + assert set(v) == {"h", "font", "px", "icon", "radius", "gap"} + # 글자 짝 — 18 ~ 24 = 12 · 28 이상 = 14 + assert v["font"] == ("var(--text-caption)" if size <= 24 else "var(--text-body-sm)") + # 아이콘 크기 — TS 짝과 같음 + px = dict(re.findall(r"(\d+): (\d+)", re.search(r"ICON_PX[^{]*\{([^}]*)\}", src).group(1))) + assert {int(k): f"{v}px" for k, v in px.items()} == {s: rungs[s]["icon"] for s in SIZES} + + +def test_단추_높이_식(): + css = _text(UI / "ui_template_button.css") + rule = re.search(r"\.ui-btn\[data-size\] \{([^}]*)\}", css).group(1) + assert "calc((var(--ui-size-h) - 2px - var(--ui-size-font)) / 2) var(--ui-size-px)" in rule + assert "height" not in rule # 높이를 박지 않음 + # 아이콘은 글 높이 안 — 단 높이가 아이콘 때문에 늘지 않음 + assert "margin-block: calc((var(--ui-size-font) - var(--ui-size-icon)) / 2)" in css + # 32 단 = 옛 기본(여백 8 · 글 14) · 18 단 = 옛 sm(여백 2 · 글 12) + rungs = _rungs(css) + assert (32 - 2 - 14) / 2 == 8 and rungs[32]["px"] == "16px" + assert (18 - 2 - 12) / 2 == 2 and rungs[18]["px"] == "8px" and rungs[18]["radius"] == "4px" + + +def test_옛_이름_부르는_길(): + src = _text(UI / "ui_template_button.ts") + assert 'opts.size === "sm" ? 18' in src + assert 'opts.size === "sm" ? " ui-btn--sm" : ""' in src # 옛 클래스 그대로 + for cls in ("ui-btn ui-btn--", "ui-btn__icon", "ui-btn__label", "ui-icon-btn ui-icon-btn--"): + assert cls in src, cls + elements = _text(UI / "ui_template_elements.ts") + assert 'export * from "./ui_template_button";' in elements + assert "export function createButton" not in elements # 한 벌 + + +def test_상태(): + styles = _text(UI / "ui_template_elements_styles.ts") + for variant in ("filled", "ghost", "pill", "danger", "danger-ghost", "glass"): + assert f'.ui-btn--{variant}:is(:hover, [data-state="hover"]):not(:disabled)' in styles + assert '.ui-btn:is(:active, [data-state="active"]):not(:disabled)' in styles + assert "--color-primary-hover" in styles + css = _text(UI / "ui_template_button.css") + assert '.ui-icon-btn:is(:hover, [data-state="hover"]):not(:disabled)' in css + assert '.ui-icon-btn:is(:active, [data-state="active"]):not(:disabled)' in css + assert '.ui-icon-btn[aria-pressed="true"]' in css and ".ui-icon-btn:disabled" in css + src = _text(UI / "ui_template_button.ts") + assert 'title: opts.title, "aria-label": opts.title' in src + + +def test_날_색_없음(): + for name in ("ui_template_button.css", "ui_template_icons.css"): + css = _text(UI / name) + assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(", css), name + assert "--b01-" not in css and "--draw-" not in css and "font-family" not in css + for name in ("ui_template_button.ts", "ui_template_button.css", "ui_template_icons.ts"): + assert len(_text(UI / name).splitlines()) <= 700 + + +def test_아이콘(): + src = _text(UI / "ui_template_icons.ts") + names = re.findall(r"^ (\w+):", src[src.index("const PATHS") : src.index("} as const")], re.M) + assert len(names) == len(set(names)) >= 60 + for n in ("settings", "x", "menu", "undo", "redo", "mapPin", "dots", "plus", "inbox"): + assert n in names, n + assert not re.search( + r"[\U0001F300-\U0001FAFF☀-➿]", src[src.index("const PATHS") :] + ) # 그림 이모지 없음 + kit = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_PageKit.ts") + assert "KIT_PATHS" not in kit and "return icon(name, size);" in kit # 한 벌 diff --git a/ui_template/ui_template_button.css b/ui_template/ui_template_button.css new file mode 100644 index 000000000..6a33afaed --- /dev/null +++ b/ui_template/ui_template_button.css @@ -0,0 +1,152 @@ +/* ============================================================================= + * ui_template_button.css — 크기 여덟 단 · 단추 단 맞춤 · 글 링크 · 아이콘 단추(552-2) + * 단 값(--ui-size-*)은 data-size 를 단 요소만 씀 — 입력 · 고르기 · 분할 · 탭도 같은 값을 읽음. + * 단추 기본 꼴 · 색 · 마우스는 `ui_template_elements_styles.ts`(옛 자리 · 페이지 덮어쓰기 차례 그대로). + * ========================================================================== */ + +:where([data-size="18"]) { + --ui-size-h: 18px; + --ui-size-font: var(--text-caption); + --ui-size-px: 8px; + --ui-size-icon: 12px; + --ui-size-radius: 4px; + --ui-size-gap: 4px; +} +:where([data-size="20"]) { + --ui-size-h: 20px; + --ui-size-font: var(--text-caption); + --ui-size-px: 8px; + --ui-size-icon: 14px; + --ui-size-radius: 4px; + --ui-size-gap: 4px; +} +:where([data-size="22"]) { + --ui-size-h: 22px; + --ui-size-font: var(--text-caption); + --ui-size-px: 8px; + --ui-size-icon: 14px; + --ui-size-radius: 4px; + --ui-size-gap: 4px; +} +:where([data-size="24"]) { + --ui-size-h: 24px; + --ui-size-font: var(--text-caption); + --ui-size-px: 10px; + --ui-size-icon: 14px; + --ui-size-radius: 6px; + --ui-size-gap: 4px; +} +:where([data-size="28"]) { + --ui-size-h: 28px; + --ui-size-font: var(--text-body-sm); + --ui-size-px: 12px; + --ui-size-icon: 16px; + --ui-size-radius: var(--radius-buttons); + --ui-size-gap: 6px; +} +:where([data-size="32"]) { + --ui-size-h: 32px; + --ui-size-font: var(--text-body-sm); + --ui-size-px: 16px; + --ui-size-icon: 16px; + --ui-size-radius: var(--radius-buttons); + --ui-size-gap: 6px; +} +:where([data-size="36"]) { + --ui-size-h: 36px; + --ui-size-font: var(--text-body-sm); + --ui-size-px: 16px; + --ui-size-icon: 18px; + --ui-size-radius: var(--radius-buttons); + --ui-size-gap: 8px; +} +:where([data-size="42"]) { + --ui-size-h: 42px; + --ui-size-font: var(--text-body-sm); + --ui-size-px: 20px; + --ui-size-icon: 20px; + --ui-size-radius: var(--radius-buttons); + --ui-size-gap: 8px; +} + +/* 단추 — 높이 = 글 한 줄 + 위아래 여백 + 테두리(아이콘은 글 높이 안에 맞춤) · 높이를 박지 않아 페이지 여백 덮어쓰기가 옛날처럼 먹음 */ +.ui-btn[data-size] { + gap: var(--ui-size-gap); + padding: calc((var(--ui-size-h) - 2px - var(--ui-size-font)) / 2) var(--ui-size-px); + font-size: var(--ui-size-font); + border-radius: var(--ui-size-radius); +} +.ui-btn[data-size] > .ui-btn__icon { + width: var(--ui-size-icon); + height: var(--ui-size-icon); + margin-block: calc((var(--ui-size-font) - var(--ui-size-icon)) / 2); +} +.ui-btn[data-size] > .ui-btn__icon svg { + width: 100%; + height: 100%; +} + +/* 글 링크 — 테두리 · 바탕 없음 · 강조 글 · 밑줄 */ +.ui-btn.ui-btn--link { + padding-inline: 0; + background: none; + color: var(--color-accent); + text-decoration: underline; +} +.ui-btn--link:is(:hover, [data-state="hover"]):not(:disabled) { + color: var(--color-primary-hover); +} + +/* 아이콘 단추 — 정사각 · 단 높이 = 너비 */ +.ui-icon-btn { + display: inline-grid; + place-items: center; + flex: none; + box-sizing: border-box; + width: var(--ui-size-h); + height: var(--ui-size-h); + padding: 0; + border: 1px solid transparent; + border-radius: var(--ui-size-radius); + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--ui-size-font); + line-height: 1; + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} +.ui-icon-btn svg { + width: var(--ui-size-icon); + height: var(--ui-size-icon); +} +.ui-icon-btn--outline { + border-color: var(--color-border); + background: var(--color-surface-raised); +} +.ui-icon-btn--filled { + background: var(--color-primary); + color: var(--color-primary-text); +} +.ui-icon-btn:is(:hover, [data-state="hover"]):not(:disabled) { + background: var(--color-hover); + color: var(--color-text); +} +.ui-icon-btn--filled:is(:hover, [data-state="hover"]):not(:disabled) { + background: var(--color-primary-hover); + color: var(--color-primary-text); +} +.ui-icon-btn[aria-pressed="true"] { + background: var(--color-accent-soft); + color: var(--color-accent); +} +.ui-icon-btn:is(:active, [data-state="active"]):not(:disabled) { + transform: translateY(1px); +} +.ui-icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/ui_template/ui_template_button.ts b/ui_template/ui_template_button.ts new file mode 100644 index 000000000..77bbe3240 --- /dev/null +++ b/ui_template/ui_template_button.ts @@ -0,0 +1,113 @@ +/* ============================================================================= + * ui_template_button.ts + * 부품 「단추」 · 「아이콘 단추」 · 크기 여덟 단(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * + * 꼴 = 단추: 주(filled) · 보조(ghost) · 고스트 글(pill) · 위험(danger) · 위험 고스트(danger-ghost) · 유리(glass · 그림 위) · 글 링크(link) + * 아이콘 단추: 고스트(ghost · 기본) · 선(outline) · 채움(filled) · 켜짐 = aria-pressed + * 크기 = `data-size="18|20|22|24|28|32|36|42"`(높이 px) — 단추 · 아이콘 단추 · 입력 · 고르기 · 분할 · 탭 같은 값 + * 옛 `size: "sm"` = 18 · 크기를 안 주면 data-size 없이 옛 꼴(높이 32 · 아이콘 붙으면 34) 그대로 + * 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` — CSS 는 `[data-state]` 로도 받아 부품 모음이 굳혀 보임 + * 쓰기 = `createButton({ label: "저장", size: 24, onClick })` · `createIconButton({ icon: "settings", title: "설정", size: 24 })` + * 글자 기호 단추(▲ ▼ ✕ ?)는 `glyph: "▲"` — 시험이 글자로 잡음 + * 대신 = 페이지마다 만든 16 ~ 26 짜리 작은 단추 · `b01-shell__icon-btn` · `b01-pg-menu__btn` · `b05-profile__zoom-btn` …(목록 4장) + * 클래스 = `ui-btn ui-btn--<꼴>`(+ 옛 `ui-btn--sm`) · `ui-btn__icon` · `ui-btn__label` · `ui-icon-btn ui-icon-btn--<꼴>` — 시험 · 가이드 촬영이 잡음 + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; +import { icon, type IconName } from "./ui_template_icons"; +import "./ui_template_button.css"; + +/** 크기 여덟 단(높이 px · 요구 377 · 378) */ +export const UI_SIZES = [18, 20, 22, 24, 28, 32, 36, 42] as const; +export type UiSize = (typeof UI_SIZES)[number]; + +/** 단마다 아이콘 크기(`ui_template_button.css` 의 `--ui-size-icon` 과 같은 값) */ +const ICON_PX: Record = { + 18: 12, + 20: 14, + 22: 14, + 24: 14, + 28: 16, + 32: 16, + 36: 18, + 42: 20, +}; + +/** 요소에 크기 단을 붙임 */ +export function setUiSize(node: T, size: UiSize): T { + node.dataset.size = String(size); + return node; +} + +export type ButtonVariant = + "filled" | "ghost" | "pill" | "danger" | "danger-ghost" | "glass" | "link"; +/** 크기 — 단(18 ~ 42) · 옛 "sm" = 18 · 옛 "md" = 단 없이 옛 꼴 */ +export type ButtonSize = "md" | "sm" | UiSize; + +export interface ButtonOptions { + /** 표시 텍스트 (i18n 결과 문자열을 전달) */ + label: string; + variant?: ButtonVariant; + size?: ButtonSize; + /** 이벤트 핸들러 (frontend.md §4: on[페이지]_[기능]_[액션] 명명) */ + onClick?: (ev: MouseEvent) => void; + disabled?: boolean; + type?: "button" | "submit" | "reset"; + /** 앞쪽 아이콘 요소 (선택) — 이름이면 단 크기 아이콘 */ + iconStart?: HTMLElement | IconName; +} + +export function createButton(opts: ButtonOptions): HTMLButtonElement { + const variant = opts.variant ?? "filled"; + const size: UiSize | null = + opts.size === "sm" ? 18 : opts.size === "md" || opts.size === undefined ? null : opts.size; + const btn = el("button", { + className: `ui-btn ui-btn--${variant}${opts.size === "sm" ? " ui-btn--sm" : ""}`, + attrs: { type: opts.type ?? "button" }, + }); + if (size) setUiSize(btn, size); + if (opts.iconStart) { + const mark = + typeof opts.iconStart === "string" + ? icon(opts.iconStart, size ? ICON_PX[size] : 16) + : opts.iconStart; + mark.classList.add("ui-btn__icon"); + btn.append(mark); + } + btn.append(el("span", { className: "ui-btn__label", text: opts.label })); + if (opts.disabled) btn.disabled = true; + if (opts.onClick) btn.addEventListener("click", opts.onClick); + return btn; +} + +export type IconButtonVariant = "ghost" | "outline" | "filled"; + +export interface IconButtonOptions { + /** 아이콘 이름 — 없으면 glyph */ + icon?: IconName; + /** 글자 기호(▲ ▼ ✕ ? …) — 시험이 글자로 잡는 단추 */ + glyph?: string; + /** 마우스 글 · 보조기기 이름(필수) */ + title: string; + variant?: IconButtonVariant; + /** 기본 32 */ + size?: UiSize; + /** 켜고 끄는 단추면 지금 값(aria-pressed) */ + pressed?: boolean; + disabled?: boolean; + onClick?: (ev: MouseEvent) => void; +} + +export function createIconButton(opts: IconButtonOptions): HTMLButtonElement { + const size = opts.size ?? 32; + const btn = el("button", { + className: `ui-icon-btn ui-icon-btn--${opts.variant ?? "ghost"}`, + attrs: { type: "button", title: opts.title, "aria-label": opts.title }, + }); + setUiSize(btn, size); + btn.append(opts.icon ? icon(opts.icon, ICON_PX[size]) : (opts.glyph ?? "")); + if (opts.pressed !== undefined) btn.setAttribute("aria-pressed", String(opts.pressed)); + if (opts.disabled) btn.disabled = true; + if (opts.onClick) btn.addEventListener("click", opts.onClick); + return btn; +} diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 43051d1c4..1049dfb43 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -10,6 +10,7 @@ * ========================================================================== */ 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). @@ -17,46 +18,14 @@ import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_p export * from "./ui_template_elements_chart"; export * from "./ui_template_elements_styles"; export { el } from "./ui_template_elements_base"; +export * from "./ui_template_button"; /** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ /* ----------------------------------------------------------------------------- - * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav + * 1. 버튼 — 부품 「단추」 · 「아이콘 단추」는 `ui_template_button.ts`(552-2) · 부르는 길은 그대로 * -------------------------------------------------------------------------- */ -export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "danger-ghost" | "glass"; -export type ButtonSize = "md" | "sm"; - -export interface ButtonOptions { - /** 표시 텍스트 (i18n 결과 문자열을 전달) */ - label: string; - variant?: ButtonVariant; - /** 크기 — 기본 md · sm = 표 칸·카드 머리용 작은 단추(글자 캡션 크기) */ - size?: ButtonSize; - /** 이벤트 핸들러 (frontend.md §4: on[페이지]_[기능]_[액션] 명명) */ - onClick?: (ev: MouseEvent) => void; - disabled?: boolean; - type?: "button" | "submit" | "reset"; - /** 앞쪽 아이콘 요소 (선택) */ - iconStart?: HTMLElement; -} - -export function createButton(opts: ButtonOptions): HTMLButtonElement { - const variant = opts.variant ?? "filled"; - const btn = el("button", { - className: `ui-btn ui-btn--${variant}${opts.size === "sm" ? " ui-btn--sm" : ""}`, - attrs: { type: opts.type ?? "button" }, - }); - if (opts.iconStart) { - opts.iconStart.classList.add("ui-btn__icon"); - btn.append(opts.iconStart); - } - btn.append(el("span", { className: "ui-btn__label", text: opts.label })); - if (opts.disabled) btn.disabled = true; - if (opts.onClick) btn.addEventListener("click", opts.onClick); - return btn; -} - /* ----------------------------------------------------------------------------- * 2. 입력 필드 (Input Field) — design.md: 1px mist border, 8px radius, focus ring * -------------------------------------------------------------------------- */ diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 811bdeef2..5df072a75 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -13,7 +13,7 @@ const BASE_STYLE_ID = "ui-template-elements-style"; const BASE_CSS = ` [hidden] { display: none !important; } -/* --- Button --- */ +/* --- Button — 부품 「단추」(552-2 · ui_template_button.ts) 기본 꼴 · 색 · 상태 · 크기 단은 ui_template_button.css --- */ .ui-btn { display: inline-flex; align-items: center; @@ -31,6 +31,8 @@ const BASE_CSS = ` border-color var(--transition-fast), color var(--transition-fast); } .ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } +/* 눌림 — 한 칸 내려앉음(꼴마다 같음) · 부품 모음은 data-state 로 굳힘 */ +.ui-btn:is(:active, [data-state="active"]):not(:disabled) { transform: translateY(1px); } .ui-btn__icon { display: inline-flex; width: 16px; height: 16px; } /* 작은 크기 — 다섯 모양 모두에 먹음(padding · 글자 · 모서리) · 낮은 키에 8px 모서리면 알약 꼴이라 반으로 */ @@ -45,27 +47,28 @@ const BASE_CSS = ` color: var(--color-primary-text); box-shadow: var(--shadow-sm); } -.ui-btn--filled:hover:not(:disabled) { background-color: var(--color-royal-amethyst); } +.ui-btn--filled:is(:hover, [data-state="hover"]):not(:disabled) { background-color: var(--color-primary-hover); } .ui-btn--ghost { background-color: transparent; border-color: var(--color-primary); color: var(--color-primary); } -.ui-btn--ghost:hover:not(:disabled) { background-color: var(--color-mist-violet); } +.ui-btn--ghost:is(:hover, [data-state="hover"]):not(:disabled) { background-color: var(--color-accent-soft); } .ui-btn--pill { background-color: transparent; color: var(--color-plum-velvet); border-radius: var(--radius-pills); } -.ui-btn--pill:hover:not(:disabled), -.ui-btn--pill.is-active { background-color: var(--color-mist-violet); } +.ui-btn--pill:is(:hover, [data-state="hover"]):not(:disabled), +.ui-btn--pill.is-active { background-color: var(--color-accent-soft); } .ui-btn--danger { background-color: var(--color-danger); color: var(--color-canvas); } +.ui-btn--danger:is(:hover, [data-state="hover"]):not(:disabled) { filter: brightness(0.92); } /* [초기화] 꼴 — 테두리 · 글만 빨강 · 올리면 채움 */ .ui-btn--danger-ghost { @@ -73,7 +76,7 @@ const BASE_CSS = ` border-color: var(--color-danger); color: var(--color-danger); } -.ui-btn--danger-ghost:hover:not(:disabled) { +.ui-btn--danger-ghost:is(:hover, [data-state="hover"]):not(:disabled) { background-color: var(--color-danger); color: var(--color-canvas); } @@ -86,7 +89,7 @@ const BASE_CSS = ` backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); } -.ui-btn--glass:hover:not(:disabled), +.ui-btn--glass:is(:hover, [data-state="hover"]):not(:disabled), .ui-btn--glass.is-active { border-color: var(--color-primary); background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); diff --git a/ui_template/ui_template_icons.css b/ui_template/ui_template_icons.css new file mode 100644 index 000000000..d9aac1e83 --- /dev/null +++ b/ui_template/ui_template_icons.css @@ -0,0 +1,6 @@ +/* 부품 「아이콘」(552-2) — 글 줄 안에 놓여도 아래로 처지지 않음 · 색 = 둘레 글 색 */ +.ui-icon { + display: inline-grid; + place-items: center; + flex: none; +} diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 296f2534a..aa72a5466 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -3,6 +3,12 @@ * 선 아이콘 한 묶음 — Tabler Icons(https://tabler.io/icons) 에서 필요한 것만 옮김 · npm 묶음 안 들임. * 쓰기 = `icon("home")` → · 색 = 글 색(currentColor) · 크기 = 인자. * + * 부품 「아이콘」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * 꼴 = Tabler 선(24 × 24 · 선 2) 한 꼴 · 크기 16 · 18(기본) · 24 · 32 · 단추 안은 단추 크기 단이 정함 + * 상태 = 없음(색은 둘레 글 색을 따름) + * 쓰기 = `icon("settings", 16)` · 아이콘만 단추 = `createIconButton({ icon, title })`(`ui_template_button.ts`) + * 대신 = 그림 이모지(⚙ 📍 🗺️ ✅ …) · PageKit `kitIcon` 여섯 · 글자 기호(→ ▾ ▲▼ ✓ ✗)는 시험이 글자로 잡아 그대로 + * * MIT License * * Copyright (c) 2020-2024 Paweł Kuna @@ -26,6 +32,8 @@ * SOFTWARE. * ========================================================================== */ +import "./ui_template_icons.css"; + /** 아이콘 이름 → 묶음(24 × 24 · 선 2) */ const PATHS = { home: "M5 12l-2 0l9 -9l9 9l-2 0|M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7|M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6", @@ -61,10 +69,64 @@ const PATHS = { world: "M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0|M3.6 9h16.8|M3.6 15h16.8|M11.5 3a17 17 0 0 0 0 18|M12.5 3a17 17 0 0 1 0 18", hand: "M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5|M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5|M14 5.5a1.5 1.5 0 0 1 3 0v6.5|M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47", + // 552-2 — 이모지 · 손 그림 대신(지도 · 단계 · 도구 · 방향) + map: "M3 7l6 -3l6 3l6 -3v13l-6 3l-6 -3l-6 3v-13|M9 4v13|M15 7v13", + road: "M4 19l4 -14|M16 5l4 14|M12 8v-2|M12 13v-2|M12 18v-2", + ruler: + "M17 3l4 4l-14 14l-4 -4z|M16 7l-1.5 -1.5|M13 10l-1.5 -1.5|M10 13l-1.5 -1.5|M7 16l-1.5 -1.5", + sum: "M18 16v2a1 1 0 0 1 -1 1h-11l6 -7l-6 -7h11a1 1 0 0 1 1 1v2", + crane: "M6 21h6|M9 21v-18l-6 6h18|M9 3l10 6|M17 9v4a2 2 0 1 1 -2 2", + mapPin: + "M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0|M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z", + compass: + "M8 16l2 -6l6 -2l-2 6l-6 2|M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0|M12 3l0 2|M12 19l0 2|M3 12l2 0|M19 12l2 0", + circleCheck: "M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0|M9 12l2 2l4 -4", + mountain: + "M3 20h18l-6.921 -14.612a2.3 2.3 0 0 0 -4.158 0l-6.921 14.612z|M7.5 11l2 2.5l2.5 -2.5l2 3l2.5 -2", + cube: "M21 16.008v-8.018a1.98 1.98 0 0 0 -1 -1.717l-7 -4.008a2.016 2.016 0 0 0 -2 0l-7 4.008c-.619 .355 -1 1.01 -1 1.718v8.018c0 .709 .381 1.363 1 1.717l7 4.008a2.016 2.016 0 0 0 2 0l7 -4.008c.619 -.355 1 -1.01 1 -1.718z|M12 22v-10|M12 12l8.73 -5.04|M3.27 6.96l8.73 5.04", + settings: + "M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z|M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0", + x: "M18 6l-12 12|M6 6l12 12", + chevronLeft: "M15 6l-6 6l6 6", + chevronRight: "M9 6l6 6l-6 6", + chevronUp: "M6 15l6 -6l6 6", + menu: "M4 6l16 0|M4 12l16 0|M4 18l16 0", + undo: "M9 14l-4 -4l4 -4|M5 10h11a4 4 0 1 1 0 8h-1", + redo: "M15 14l4 -4l-4 -4|M19 10h-11a4 4 0 1 0 0 8h1", + alert: + "M12 9v4|M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0z|M12 16h.01", + lock: "M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6z|M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M8 11v-4a4 4 0 1 1 8 0v4", + help: "M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0|M12 16v.01|M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483", + refresh: "M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4|M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4", + trash: + "M4 7l16 0|M10 11l0 6|M14 11l0 6|M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12|M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3", + check: "M5 12l5 5l10 -10", + download: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 11l5 5l5 -5|M12 4l0 12", + eye: "M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0|M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6", + eyeOff: + "M10.585 10.587a2 2 0 0 0 2.829 2.828|M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87|M3 3l18 18", + copy: "M7 7m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z|M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1", + externalLink: + "M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6|M11 13l9 -9|M15 4h5v5", + info: "M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0|M12 9h.01|M11 12h1v4h1", + minus: "M5 12l14 0", + filter: + "M4 4h16v2.172a2 2 0 0 1 -.586 1.414l-4.414 4.414v7l-6 2v-8.5l-4.48 -4.928a2 2 0 0 1 -.52 -1.345v-2.227z", + // 552-2 — 대시보드 PageKit 에서 옮김(글 그대로) + dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0", + plus: "M12 5l0 14|M5 12l14 0", + pencil: "M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4|M13.5 6.5l4 4", + upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12", + mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6", + inbox: + "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3", } as const; export type IconName = keyof typeof PATHS; +/** 모든 이름(부품 모음 견본) */ +export const ICON_NAMES = Object.keys(PATHS) as IconName[]; + export function icon(name: IconName, size = 18): HTMLSpanElement { const span = document.createElement("span"); span.className = "ui-icon"; diff --git a/ui_template/ui_template_ui_kit.css b/ui_template/ui_template_ui_kit.css index 53d60352c..178685ae2 100644 --- a/ui_template/ui_template_ui_kit.css +++ b/ui_template/ui_template_ui_kit.css @@ -144,3 +144,14 @@ font-size: 11px; color: var(--color-text-secondary); } + +/* 부품 여덟 단 한 줄 */ +.ui-kit__part-sizes { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--color-border); +} diff --git a/ui_template/ui_template_ui_kit.ts b/ui_template/ui_template_ui_kit.ts index 6f66ce6fd..7c36cc2e9 100644 --- a/ui_template/ui_template_ui_kit.ts +++ b/ui_template/ui_template_ui_kit.ts @@ -3,7 +3,7 @@ * 부품 모음 화면(552-1) — 대시보드 세부 페이지 `#/b01-account?view=ui-kit` · 시스템 관리자만. * 밝음 · 어두움을 나란히 — 칸마다 iframe(``)에 지금 페이지 CSS 를 옮겨 실제 테마 값으로 그림. * 부품 줄 = `ui_template_ui_kit_parts.ts` 목록 · 상태 다섯 칸(기본 · 마우스 · 눌림 · 막힘 · 오류) · 견본이 없으면 빈 자리. - * 크기 자 = 단추 · 입력 높이 여덟 단(18 ~ 42). + * 크기 자 = 단추 · 입력 높이 여덟 단(18 ~ 42) · 부품마다 `sizes` 가 있으면 여덟 단 한 줄. * ========================================================================== */ import { pageFrame } from "../B01_Dashboard/B01_Dashboard_UI_PageKit"; @@ -68,6 +68,11 @@ function partRow(part: KitPart): HTMLElement { states.append(cell); }); item.append(states); + if (part.sizes) { + const sizes = node("div", "ui-kit__part-sizes"); + for (const z of KIT_SIZES) sizes.append(part.sizes(z)); + item.append(sizes); + } return item; } diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 4f8323ba5..6161b8f28 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -18,8 +18,9 @@ * 몫 = 552-2 기본 · 552-3 묶음 · 552-4 틀 — 세 묶음이 떨어져 있어 서로 줄을 안 건드림. * ========================================================================== */ -import { createButton, createInputField, createSelectField } from "./ui_template_elements"; -import { icon } from "./ui_template_icons"; +import { createButton, createIconButton, type UiSize } from "./ui_template_button"; +import { createInputField, createSelectField } from "./ui_template_elements"; +import { ICON_NAMES, icon } from "./ui_template_icons"; import { badge, dataTable, @@ -62,6 +63,8 @@ export interface KitPart { file: string; /** 지금 견본 — 상태마다 하나 · null = 그 상태 없음 · 없으면 빈 자리 */ demo?: (state: KitState) => HTMLElement | null; + /** 크기 단 견본 — 여덟 단 한 줄(크기 없는 부품은 없음) */ + sizes?: (size: UiSize) => HTMLElement; /** 견본이 옮기기 전 PageKit · 옛 공용 부품인지 */ legacy?: boolean; } @@ -93,9 +96,12 @@ const noop = (): void => {}; // prettier-ignore export const KIT_PARTS: KitPart[] = [ // ── 552-2 기본 ───────────────────────────────────────────── - { key: "button", name: "단추", group: "basic", kinds: "주 · 보조 · 고스트 · 위험 · 위험 고스트 · 글 링크 · 여덟 단", file: "ui_template_button.ts", legacy: true, - demo: (s) => row(markState(createButton({ label: "저장", onClick: noop }), s), markState(createButton({ label: "취소", variant: "ghost", onClick: noop }), s), markState(createButton({ label: "삭제", variant: "danger", onClick: noop }), s)) }, - { key: "icon-button", name: "아이콘 단추", group: "basic", kinds: "고스트 · 선 · 채움 · 여덟 단 · title 필수", file: "ui_template_button.ts" }, + { key: "button", name: "단추", group: "basic", kinds: "주 · 보조 · 고스트 글 · 위험 · 위험 고스트 · 유리 · 글 링크 · 여덟 단", file: "ui_template_button.ts", + demo: (s) => (s === "error" ? null : row(...([["저장", "filled"], ["취소", "ghost"], ["보기", "pill"], ["삭제", "danger"], ["초기화", "danger-ghost"], ["사용법", "link"]] as const).map(([label, variant]) => markState(createButton({ label, variant, size: 28, onClick: noop }), s)))), + sizes: (z) => createButton({ label: String(z), size: z, iconStart: "plus", onClick: noop }) }, + { key: "icon-button", name: "아이콘 단추", group: "basic", kinds: "고스트 · 선 · 채움 · 켜짐(aria-pressed) · 글자 기호 · 여덟 단 · title 필수", file: "ui_template_button.ts", + demo: (s) => (s === "error" ? null : row(markState(createIconButton({ icon: "settings", title: "설정" }), s), markState(createIconButton({ icon: "refresh", title: "새로 고침", variant: "outline" }), s), markState(createIconButton({ icon: "plus", title: "더하기", variant: "filled" }), s), markState(createIconButton({ icon: "eye", title: "보기", pressed: true }), s), markState(createIconButton({ glyph: "▲", title: "위로", variant: "outline" }), s))), + sizes: (z) => createIconButton({ icon: "settings", title: `설정 ${z}`, size: z, variant: "outline" }) }, { key: "input", name: "입력", group: "basic", kinds: "글 · 수 · 단위 붙음 · 찾기 · 날짜 · 여덟 단", file: "ui_template_input.ts", legacy: true, demo: (s) => { const f = createInputField({ placeholder: "이름" }); markState(f.input, s); return f.root; } }, { key: "select", name: "고르기", group: "basic", kinds: "한 개 · 여덟 단", file: "ui_template_input.ts", legacy: true, @@ -108,8 +114,8 @@ export const KIT_PARTS: KitPart[] = [ { key: "badge", name: "배지", group: "basic", kinds: "상태 · 역할 · 종류 · 수 · 램프(✓ 같이)", file: "ui_template_badge.ts", legacy: true, demo: (s) => (s === "default" ? row(badge("진행 중", "accent"), badge("완료", "success"), badge("멈춤", "warning"), badge("오류", "danger"), badge("임시", "neutral")) : null) }, { key: "tooltip", name: "툴팁", group: "basic", kinds: "마우스 글 · 값 풍선", file: "ui_template_hover_info.ts" }, - { key: "icon", name: "아이콘", group: "basic", kinds: "Tabler 선 · 16 · 18 · 24 · 32", file: "ui_template_icons.ts", legacy: true, - demo: (s) => (s === "default" ? row(icon("home"), icon("folder"), icon("route"), icon("search"), icon("bell"), icon("users")) : null) }, + { key: "icon", name: "아이콘", group: "basic", kinds: "Tabler 선 · 16 · 18 · 24 · 32 · 이모지 대신", file: "ui_template_icons.ts", + demo: (s) => (s === "default" ? row(...ICON_NAMES.map((n) => { const i = icon(n); i.title = n; i.dataset.icon = n; return i; })) : null) }, { key: "slider", name: "슬라이더", group: "basic", kinds: "값 하나 · 이름 · 값 글", file: "ui_template_input.ts" }, { key: "text", name: "보조 글 · 안내 줄", group: "basic", kinds: "흐린 글 · 안내 · 상태(오류 · 경고) · 칸 오류", file: "ui_template_text.ts" }, // ── 552-3 묶음 ─────────────────────────────────────────────