From 2c51bb53a64c6e078c20b9200a7a2d445ccd897f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:39:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui-kit):=20=EA=B8=B0=EB=B3=B8=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=8D=94=20?= =?UTF-8?q?=C2=B7=20=EC=B2=B4=ED=81=AC=20=C2=B7=20=EC=BC=9C=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B6=84=ED=95=A0=20=C2=B7=20=ED=83=AD=20=C2=B7=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=C2=B7=20=ED=88=B4=ED=8C=81=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=EA=B8=80=20(PLAN=20552-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_input.ts · .css 새로 — createInputField · createSelectField 를 옮김(ui_template_elements 가 다시 내보냄 · 부르는 길 그대로) · size(data-size 단 높이) · unit(칸 안 단위) · multiline · createSlider(이름 | 막대 | 값 글 3열) - 옛 compact 는 단을 안 붙임 — 옛 작은 칸 30 그대로(32 로 맞추면 페이지 칸이 바뀜 · 553 에서 단으로 옮김) - 입력 · 고르기 상태 = 마우스 선 짙게 · 막힘 흐림 · 오류 빨강(aria-invalid 같이) · 고르기 화살표 = 보라 → 보조 글 색(밝음 · 어두움 따로) - 체크 = 라디오 · 칩 꼴 더함(createCheckbox 부름 그대로) · 켜기(스위치 · 색 점 칩) · 분할 고르기(대시보드 .b01-sys-seg 꼴) · 탭(밑줄 · 보조 알약 = 옛 .ui-subtabs · 수 배지) - 배지(알약 · 수 · 램프 ✓ ✗ · 점 · 옛 .ui-tag 같이) · 툴팁 제자리 상자(tooltipBox) · 보조 글(흐린 · 안내 · 오류 ✗ · 경고 ! · 성공 ✓) - 부품 모음 기본 묶음 13 줄 모두 새 부품 견본 · 상태 다섯 칸 · 여덟 단 한 줄 - 시험 test_552_2_parts 새로(머리 · 옛 이름 · 크기 단 · 상태 · 날 색 없음 · 부품 모음 13) Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_552_2_parts.py | 130 +++++++++++++ ui_template/ui_template_badge.css | 81 ++++++++ ui_template/ui_template_badge.ts | 44 +++++ ui_template/ui_template_checkbox.css | 41 ++++ ui_template/ui_template_checkbox.ts | 38 +++- ui_template/ui_template_elements.ts | 126 +----------- ui_template/ui_template_elements_styles.ts | 6 +- ui_template/ui_template_hover_info.css | 8 +- ui_template/ui_template_hover_info.ts | 16 ++ ui_template/ui_template_input.css | 102 ++++++++++ ui_template/ui_template_input.ts | 213 +++++++++++++++++++++ ui_template/ui_template_segment.css | 49 +++++ ui_template/ui_template_segment.ts | 72 +++++++ ui_template/ui_template_tabs.css | 68 +++++++ ui_template/ui_template_tabs.ts | 88 +++++++++ ui_template/ui_template_text.css | 29 +++ ui_template/ui_template_text.ts | 39 ++++ ui_template/ui_template_toggle.css | 86 +++++++++ ui_template/ui_template_toggle.ts | 70 +++++++ ui_template/ui_template_ui_kit.css | 7 + ui_template/ui_template_ui_kit_parts.ts | 120 ++++++++++-- 21 files changed, 1281 insertions(+), 152 deletions(-) create mode 100644 resources/tester/test_552_2_parts.py create mode 100644 ui_template/ui_template_badge.css create mode 100644 ui_template/ui_template_badge.ts create mode 100644 ui_template/ui_template_input.css create mode 100644 ui_template/ui_template_input.ts create mode 100644 ui_template/ui_template_segment.css create mode 100644 ui_template/ui_template_segment.ts create mode 100644 ui_template/ui_template_tabs.css create mode 100644 ui_template/ui_template_tabs.ts create mode 100644 ui_template/ui_template_text.css create mode 100644 ui_template/ui_template_text.ts create mode 100644 ui_template/ui_template_toggle.css create mode 100644 ui_template/ui_template_toggle.ts diff --git a/resources/tester/test_552_2_parts.py b/resources/tester/test_552_2_parts.py new file mode 100644 index 000000000..e3dc61219 --- /dev/null +++ b/resources/tester/test_552_2_parts.py @@ -0,0 +1,130 @@ +"""552-2 기본 부품(입력 · 고르기 · 슬라이더 · 체크 · 켜기 · 분할 · 탭 · 배지 · 툴팁 · 보조 글) 지킴 시험 — 화면 확인은 ORCA. + +· 부품마다 파일 머리(꼴 · 크기 · 상태 · 쓰기 · 대신) · 클래스 `ui-<부품>` · 옛 공용 이름 그대로 +· 크기 = data-size(setUiSize) · 단 값(--ui-size-*)만 읽음 · 상태 = :hover · :active 를 data-state 로도 받음 +· 색 = 공통 값만 · 부품 모음 기본 묶음 13 줄 모두 새 파일 견본(옛 PageKit 견본 없음) +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "ui_template" + +# 파일 · 만들기 함수 · 클래스 +PARTS = { + "input": ("createInputField", "ui-input"), + "input ": ("createSelectField", "ui-select"), + "input ": ("createSlider", "ui-slider"), + "checkbox": ("createCheckbox", "ui-checkbox"), + "toggle": ("createToggle", "ui-toggle"), + "segment": ("createSegment", "ui-segment"), + "tabs": ("createTabs", "ui-tabs"), + "badge": ("createBadge", "ui-badge"), + "hover_info": ("tooltipBox", "ui-hover-info"), + "text": ("createText", "ui-text"), +} + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_파일_머리_이름(): + for name, (fn, cls) in PARTS.items(): + ts = _text(UI / f"ui_template_{name.strip()}.ts") + assert f"export function {fn}(" in ts, fn + assert cls in ts, cls + head = ts[: ts.index("=====", 100)] + for word in ("꼴", "크기", "상태", "쓰기", "대신"): + assert word in head, (name, word) + + +def test_옛_이름_그대로(): + inp = _text(UI / "ui_template_input.ts") + for cls in ( + '"ui-field"', + '"ui-field__label"', + "ui-input ui-input--sm", + "ui-select ui-select--sm", + ): + assert cls in inp, cls + assert '"ui-field__error"' in inp and "ui-input--error" in inp + elements = _text(UI / "ui_template_elements.ts") + assert 'export * from "./ui_template_input";' in elements + assert "export function createInputField" not in elements # 한 벌 + # 보조 탭 = 옛 .ui-subtabs + 알약 단추 · 배지 = 옛 .ui-tag 같이 + assert "ui-tabs ui-tabs--pill ui-subtabs" in _text(UI / "ui_template_tabs.ts") + assert "ui-tag ui-tag--${tone}" in _text(UI / "ui_template_badge.ts") + # 체크 클래스 그대로 + assert "ui-checkbox__input" in _text(UI / "ui_template_checkbox.ts") + + +def test_크기_단(): + for name in ("input", "checkbox", "toggle", "segment", "tabs", "badge"): + assert "setUiSize(" in _text(UI / f"ui_template_{name}.ts"), name + for name in ("toggle", "segment", "tabs", "badge", "checkbox"): + css = _text(UI / f"ui_template_{name}.css") + assert "var(--ui-size-h)" in css, name + inp = _text(UI / "ui_template_input.css") + assert ".ui-input[data-size]:not(textarea)" in inp and "height: var(--ui-size-h)" in inp + # 옛 compact 는 단을 안 붙임(옛 30 그대로) — 크기를 준 칸만 단 + ts = _text(UI / "ui_template_input.ts") + assert "if (opts.size) setUiSize(input, opts.size);" in ts + + +def test_상태(): + need = { + "input": ['[data-state="hover"]', '[aria-invalid="true"]', ":disabled"], + "checkbox": ['[data-state="hover"]', ":disabled"], + "toggle": [ + '[data-state="hover"]', + '[data-state="active"]', + ":disabled", + 'aria-pressed="true"', + ], + "segment": [ + '[data-state="hover"]', + '[data-state="active"]', + ":disabled", + 'aria-checked="true"', + ], + "tabs": ['[data-state="hover"]', ":disabled", ".is-active"], + } + for name, words in need.items(): + css = _text(UI / f"ui_template_{name}.css") + for w in words: + assert w in css, (name, w) + # 램프 · 상태 글 = 기호 같이(색만으로 안 가름) + assert 'success: "✓", danger: "✗"' in _text(UI / "ui_template_badge.ts") + assert 'error: "✗", warning: "!", success: "✓"' in _text(UI / "ui_template_text.ts") + + +def test_날_색_없음(): + for name in ("input", "checkbox", "toggle", "segment", "tabs", "badge", "text", "hover_info"): + css = _text(UI / f"ui_template_{name}.css") + 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, name + assert ( + len(css.splitlines()) <= 700 + and len(_text(UI / f"ui_template_{name}.ts").splitlines()) <= 700 + ) + # 고르기 화살표 = 보조 글 색 값(그림 파일 속) · 옛 보라 없음 + styles = _text(UI / "ui_template_elements_styles.ts") + assert "%233e0079" not in styles + assert "stroke='%236b7280'" in styles and "stroke='%239aa1ab'" in styles + theme = _text(UI / "ui_template_theme.css") + assert "--color-slate: #6b7280;" in theme and "--color-slate: #9aa1ab;" in theme + + +def test_부품_모음_기본_13(): + src = _text(UI / "ui_template_ui_kit_parts.ts") + rows = re.findall(r'\{ key: "([^"]+)", name: "[^"]+", group: "basic",([^\n]*)', src) + assert len(rows) == 13 # 목록 3장 1 ~ 13(체크 · 라디오 한 줄) + for key, rest in rows: + assert "legacy" not in rest, key + block = src[src.index(f'{{ key: "{key}"') :].split("\n { key:")[0] + assert "demo" in block, key + assert "tabBar" not in src diff --git a/ui_template/ui_template_badge.css b/ui_template/ui_template_badge.css new file mode 100644 index 000000000..711aaa803 --- /dev/null +++ b/ui_template/ui_template_badge.css @@ -0,0 +1,81 @@ +/* 부품 「배지」(552-2) — 대시보드 알약 꼴 · 색 = 공통 값 · 옛 .ui-tag 꼴 위에 덮음(.ui-badge 붙은 것만) */ +.ui-badge { + --badge-tone: var(--color-text-secondary); + display: inline-flex; + align-items: center; + gap: 4px; + box-sizing: border-box; + white-space: nowrap; + line-height: 1; +} +.ui-badge--accent { + --badge-tone: var(--color-accent); +} +.ui-badge--success { + --badge-tone: var(--color-success); +} +.ui-badge--warning { + --badge-tone: var(--color-warning); +} +.ui-badge--danger { + --badge-tone: var(--color-danger); +} +.ui-badge--info { + --badge-tone: var(--color-info); +} + +/* 알약 — 높이 = 단 · 연한 바탕 + 진한 글(진행 = 선만) */ +.ui-badge.ui-badge--pill { + height: var(--ui-size-h); + padding: 0 8px; + border: 1px solid transparent; + border-radius: var(--radius-pills); + background: color-mix(in srgb, var(--badge-tone) 14%, transparent); + color: var(--badge-tone); + font-size: var(--ui-size-font); + font-weight: var(--font-weight-medium); +} +.ui-badge.ui-badge--pill.ui-badge--neutral { + background: var(--color-hover); +} +.ui-badge.ui-badge--pill.ui-badge--accent { + border-color: var(--color-accent); + background: transparent; +} + +/* 수 — 빨강 동그라미(색은 tone 과 상관없이 위험 채움) */ +.ui-badge--count { + justify-content: center; + min-width: var(--ui-size-h); + height: var(--ui-size-h); + padding: 0 5px; + border-radius: var(--radius-pills); + background: var(--color-danger-fill); + color: var(--color-canvas); + font-size: 11px; + font-weight: var(--font-weight-medium); +} + +/* 램프 — 점 + 글 */ +.ui-badge--lamp { + height: var(--ui-size-h); + color: var(--badge-tone); + font-size: var(--ui-size-font); + font-weight: var(--font-weight-medium); +} +.ui-badge--lamp::before { + content: ""; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--badge-tone); +} + +/* 점 — 글 없음 */ +.ui-badge--dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--badge-tone); +} diff --git a/ui_template/ui_template_badge.ts b/ui_template/ui_template_badge.ts new file mode 100644 index 000000000..fc710a5c1 --- /dev/null +++ b/ui_template/ui_template_badge.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * ui_template_badge.ts + * 부품 「배지」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) — 대시보드 `.b01-pg-badge` 꼴 + * + * 꼴 = 알약(pill · 상태 · 역할 · 종류 — 한글 글 + 색) · 수(count · 빨강 동그라미 · 알림) · 램프(lamp · 점 + 글 · 성공 ✓ · 실패 ✗ 같이) + * · 점(dot · 글 없음 · title 필수) + * 색 = accent(진행 · 선) · success · warning · danger · info · neutral + * 크기 = data-size 18 · 20(기본 20) · 점 8 + * 상태 = 없음(눌리지 않음) + * 쓰기 = `createBadge("진행 중", "accent")` · `createBadge("3", "danger", { kind: "count" })` · `createBadge("통과", "success", { kind: "lamp" })` + * 대신 = `.ui-tag--*` 손 조립 · B01 `.b01-pg-badge` · `.b01-proj-badge` · 램프 `b08-summary__lamp` · `b09-bill__lamp` · E03 `e03-verdict` · + * M02 알약 여섯 …(목록 4장) + * 클래스 = 알약은 옛 `ui-tag ui-tag--<색>` 도 같이 냄(시험 · B09 표 PDF 가 잡음) + * ========================================================================== */ + +import { setUiSize } from "./ui_template_button"; +import "./ui_template_badge.css"; + +export type BadgeTone = "accent" | "success" | "warning" | "danger" | "info" | "neutral"; +export type BadgeKind = "pill" | "count" | "lamp" | "dot"; + +/** 램프 글자 기호 — 색만으로 안 가름(시험이 글자로 잡음) */ +const LAMP_MARK: Partial> = { success: "✓", danger: "✗" }; + +export function createBadge( + text: string, + tone: BadgeTone = "neutral", + opts: { kind?: BadgeKind; size?: 18 | 20 } = {}, +): HTMLSpanElement { + const kind = opts.kind ?? "pill"; + const span = document.createElement("span"); + span.className = `ui-badge ui-badge--${kind} ui-badge--${tone}${kind === "pill" ? ` ui-tag ui-tag--${tone}` : ""}`; + if (kind !== "dot") setUiSize(span, opts.size ?? 20); + if (kind === "dot") { + span.title = text; + span.setAttribute("aria-label", text); + } else if (kind === "lamp") { + const mark = LAMP_MARK[tone]; + span.textContent = mark ? `${mark} ${text}` : text; + } else { + span.textContent = text; + } + return span; +} diff --git a/ui_template/ui_template_checkbox.css b/ui_template/ui_template_checkbox.css index c896db75b..554ca3a84 100644 --- a/ui_template/ui_template_checkbox.css +++ b/ui_template/ui_template_checkbox.css @@ -32,3 +32,44 @@ color: var(--color-text-muted); cursor: default; } + +/* 상태 — 마우스(552-2) */ +.ui-checkbox:is(:hover, [data-state="hover"]):not(:has(.ui-checkbox__input:disabled)) { + color: var(--color-text); +} +.ui-checkbox:is(:hover, [data-state="hover"]) .ui-checkbox__input:not(:disabled) { + filter: brightness(0.92); +} + +/* 칩 꼴 — 알약 · 켜면 강조 바탕 · 상자는 숨김(키보드 · 보조기기는 그대로) */ +.ui-checkbox--chip { + position: relative; + height: var(--ui-size-h); + padding-inline: var(--ui-size-px); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + font-size: var(--ui-size-font); + color: var(--color-text-secondary); +} +.ui-checkbox--chip .ui-checkbox__input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.ui-checkbox--chip:is(:hover, [data-state="hover"]):not(:has(.ui-checkbox__input:disabled)) { + background: var(--color-hover); +} +.ui-checkbox--chip:has(.ui-checkbox__input:checked) { + border-color: var(--color-accent); + background: var(--color-accent-soft); + color: var(--color-accent); + font-weight: var(--font-weight-medium); +} +.ui-checkbox--chip:has(.ui-checkbox__input:focus-visible) { + outline: 2px solid var(--color-focus-ring); + outline-offset: 1px; +} +.ui-checkbox--chip:has(.ui-checkbox__input:disabled) { + opacity: 0.5; +} diff --git a/ui_template/ui_template_checkbox.ts b/ui_template/ui_template_checkbox.ts index 0f8845a31..cbbc3a382 100644 --- a/ui_template/ui_template_checkbox.ts +++ b/ui_template/ui_template_checkbox.ts @@ -1,11 +1,17 @@ /* ============================================================================= * ui_template_checkbox.ts - * 공용 체크박스 — [□ 글] 한 줄 · 색은 테마 토큰(밝음 · 어두움 같이). 줄 세우기는 부르는 쪽 몫. + * 부품 「체크 · 라디오」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) — [□ 글] 한 줄 · 줄 세우기는 부르는 쪽 몫. * - * const box = createCheckbox("등고선", { checked: true, value: "contour" }); - * panel.append(box.root); // 읽기 = box.input.checked · box.input.value + * 꼴 = 체크 · 라디오(radio + name) · 칩(chip — 켜면 채움 · 테두리 알약) + * 크기 = 상자 14 · 칩은 data-size(기본 24) + * 상태 = 마우스 `:hover` · 막힘 `disabled` — CSS 는 `[data-state]` 로도 받음 + * 쓰기 = `const box = createCheckbox("등고선", { checked: true, value: "contour" }); panel.append(box.root);` + * 읽기 = box.input.checked · box.input.value · 라디오 = `createCheckbox("밝음", { radio: "theme", value: "light" })` + * 대신 = 날 체크(시트 · B01 · A07 · D03 · D04 · E03 `e03-check` · M02 …) · `.app-settings__radios` · `m02-lib__pick` · `b05-route__check` + * 클래스 = `ui-checkbox` · `ui-checkbox__input` · `ui-checkbox__text` — 시험 · 가이드 촬영이 잡음 * ========================================================================== */ +import { setUiSize, type UiSize } from "./ui_template_button"; import "./ui_template_checkbox.css"; export interface Checkbox { @@ -13,17 +19,31 @@ export interface Checkbox { input: HTMLInputElement; } -export function createCheckbox( - text: string, - options: { checked?: boolean; value?: string } = {}, -): Checkbox { +export interface CheckboxOptions { + checked?: boolean; + value?: string; + /** 라디오 묶음 이름 — 주면 라디오 */ + radio?: string; + /** 칩 꼴(켜면 채움) */ + chip?: boolean; + /** 칩 크기 단(기본 24) */ + size?: UiSize; + disabled?: boolean; + onChange?: (checked: boolean) => void; +} + +export function createCheckbox(text: string, options: CheckboxOptions = {}): Checkbox { const root = document.createElement("label"); - root.className = "ui-checkbox"; + root.className = `ui-checkbox${options.radio ? " ui-checkbox--radio" : ""}${options.chip ? " ui-checkbox--chip" : ""}`; + if (options.chip) setUiSize(root, options.size ?? 24); const input = document.createElement("input"); - input.type = "checkbox"; + input.type = options.radio ? "radio" : "checkbox"; + if (options.radio) input.name = options.radio; input.className = "ui-checkbox__input"; input.checked = options.checked ?? false; + input.disabled = options.disabled ?? false; if (options.value !== undefined) input.value = options.value; + if (options.onChange) input.addEventListener("change", () => options.onChange!(input.checked)); const caption = document.createElement("span"); caption.className = "ui-checkbox__text"; caption.textContent = text; diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 1049dfb43..4b425d285 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -19,6 +19,7 @@ export * from "./ui_template_elements_chart"; export * from "./ui_template_elements_styles"; export { el } from "./ui_template_elements_base"; export * from "./ui_template_button"; +export * from "./ui_template_input"; /** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ @@ -27,132 +28,9 @@ export * from "./ui_template_button"; * -------------------------------------------------------------------------- */ /* ----------------------------------------------------------------------------- - * 2. 입력 필드 (Input Field) — design.md: 1px mist border, 8px radius, focus ring + * 2. 입력 · 고르기 — 부품 「입력」 · 「고르기」 · 「슬라이더」는 `ui_template_input.ts`(552-2) · 부르는 길은 그대로 * -------------------------------------------------------------------------- */ -export interface InputFieldOptions { - /** 라벨 텍스트 (i18n 결과) */ - label?: string; - /** placeholder (i18n 결과) */ - placeholder?: string; - type?: "text" | "password" | "email" | "number" | "search" | "tel" | "date"; - value?: string; - required?: boolean; - /** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */ - min?: number; - max?: number; - onInput?: (value: string) => void; - /** 좁은 자리(표 칸·좌측 패널)용 작은 크기 — 작은 고르기칸(compact)과 높이가 같음 */ - compact?: boolean; -} - -export interface InputFieldHandle { - /** 필드 컨테이너 (라벨 + 입력 + 에러 슬롯) */ - root: HTMLDivElement; - input: HTMLInputElement; - /** 에러 메시지 표시/해제. msg가 없으면 에러 해제. */ - setError: (msg?: string) => void; -} - -export function createInputField(opts: InputFieldOptions): InputFieldHandle { - const root = el("div", { className: "ui-field" }); - - if (opts.label) { - root.append(el("label", { className: "ui-field__label", text: opts.label })); - } - - const input = el("input", { - className: opts.compact ? "ui-input ui-input--sm" : "ui-input", - attrs: { - type: opts.type ?? "text", - ...(opts.placeholder ? { placeholder: opts.placeholder } : {}), - ...(opts.required ? { required: "true" } : {}), - ...(opts.min !== undefined ? { min: String(opts.min) } : {}), - ...(opts.max !== undefined ? { max: String(opts.max) } : {}), - }, - }); - if (opts.value !== undefined) input.value = opts.value; - if (opts.onInput) { - input.addEventListener("input", () => opts.onInput!(input.value)); - } - - // 에러 메시지 슬롯 (입력창 하단 표기, frontend.md §4) - const errorSlot = el("span", { className: "ui-field__error", attrs: { role: "alert" } }); - - root.append(input, errorSlot); - - const setError = (msg?: string): void => { - if (msg) { - input.classList.add("ui-input--error"); - errorSlot.textContent = msg; - errorSlot.classList.add("is-visible"); - } else { - input.classList.remove("ui-input--error"); - errorSlot.textContent = ""; - errorSlot.classList.remove("is-visible"); - } - }; - - return { root, input, setError }; -} - -/* ----------------------------------------------------------------------------- - * 2-1. 드롭다운 필드 (Select Field) — design.md: input과 조화되는 8px radius, focus - * -------------------------------------------------------------------------- */ - -export interface SelectFieldOptions { - /** 라벨 텍스트 */ - label?: string; - /** 옵션 항목 목록 */ - options: { value: string; text: string }[]; - /** 초기 선택값 */ - value?: string; - onChange?: (value: string) => void; - disabled?: boolean; - /** 좁은 자리(표 칸·좌측 패널)용 작은 여백 */ - compact?: boolean; - /** 라벨이 없을 때 보조기기용 이름 */ - ariaLabel?: string; -} - -export interface SelectFieldHandle { - root: HTMLDivElement; - select: HTMLSelectElement; - /** 선택지를 다시 채움 — value 가 목록에 없으면 첫 항목 */ - setOptions: (options: { value: string; text: string }[], value?: string) => void; -} - -export function createSelectField(opts: SelectFieldOptions): SelectFieldHandle { - const root = el("div", { className: "ui-field" }); - - if (opts.label) { - root.append(el("label", { className: "ui-field__label", text: opts.label })); - } - - const select = el("select", { - className: opts.compact ? "ui-select ui-select--sm" : "ui-select", - attrs: opts.ariaLabel ? { "aria-label": opts.ariaLabel } : {}, - }); - - const setOptions: SelectFieldHandle["setOptions"] = (options, value) => { - select.replaceChildren( - ...options.map((opt) => el("option", { attrs: { value: opt.value }, text: opt.text })), - ); - if (value !== undefined && options.some((opt) => opt.value === value)) select.value = value; - }; - setOptions(opts.options, opts.value); - - if (opts.disabled) select.disabled = true; - - if (opts.onChange) { - select.addEventListener("change", () => opts.onChange!(select.value)); - } - - root.append(select); - - return { root, select, setOptions }; -} - /* ----------------------------------------------------------------------------- * 3. 카드 / 패널 (Card) — design.md: white surface, 8px radius, shadow * -------------------------------------------------------------------------- */ diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 5df072a75..ce9a80c6b 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -143,13 +143,17 @@ const BASE_CSS = ` border: 1px solid var(--color-border); border-radius: var(--radius-inputs); padding: 10px 36px 10px 14px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233e0079'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 16px; cursor: pointer; transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } +/* 화살표 = 셰브런 · 선 색 = --color-text-secondary 값(밝음 #6b7280 · 어두움 #9aa1ab · 그림 파일 속이라 변수를 못 씀) */ +:root[data-theme="dark"] .ui-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239aa1ab'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); +} .ui-select:focus { outline: none; border-color: var(--color-focus-ring); diff --git a/ui_template/ui_template_hover_info.css b/ui_template/ui_template_hover_info.css index ac71c5d76..b7e23057c 100644 --- a/ui_template/ui_template_hover_info.css +++ b/ui_template/ui_template_hover_info.css @@ -13,7 +13,7 @@ background: var(--color-surface-raised); box-shadow: var(--shadow-sm); color: var(--color-text-body); - font-size: 12px; + font-size: var(--text-caption); line-height: 1.4; white-space: pre-line; overflow-wrap: anywhere; @@ -23,3 +23,9 @@ .ui-hover-info[hidden] { display: none; } + +/* 제자리 상자(tooltipBox · 552-2) — 흐름 안 · 그림 위 설명은 부르는 쪽이 absolute 로 놓음 */ +.ui-hover-info--inline { + position: static; + display: inline-block; +} diff --git a/ui_template/ui_template_hover_info.ts b/ui_template/ui_template_hover_info.ts index 7fa67d442..8eb84ef9c 100644 --- a/ui_template/ui_template_hover_info.ts +++ b/ui_template/ui_template_hover_info.ts @@ -7,6 +7,13 @@ * 상자 = 화면에 하나(새로 뜨면 앞 것 갈음) · 대상 아래가 기본 · 아래가 모자라면 위로 뒤집고 * 좌우는 화면 안으로 당김(`placeHoverInfo`) · 색 = 테마 토큰(밝음 · 어두움 같이) * 옛 title 풍선은 그대로 둠 — 새 자리부터 이 양식(B09 칸 풍선 `tip` 이 첫 사용처) + * + * 부품 「툴팁」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * 꼴 = 마우스 글(attachHoverInfo) · 값 풍선 · 그림 위 설명(tooltipBox — 제자리 상자) + * 크기 = 글 12 · 폭 최대 320 · 상태 = 없음 + * 쓰기 = `attachHoverInfo(cell, "누르면 고침")` · `panel.append(tooltipBox("구간 1 · 높이 2.4 m"))` + * 대신 = `title=`(B06 52 · CAD 32 · B07 20) · `.ui-prov-card` · 시트 `.ss-errtip__balloon` · B05 `b05-structure__balloon` · + * E03 `e03-tip` · E04 · E07 `*-map__tip` * ========================================================================== */ import "./ui_template_hover_info.css"; @@ -139,3 +146,12 @@ export function attachHoverInfo(root: HTMLElement, text: HoverText, selector?: s if (owner && root.contains(owner)) hideHoverInfo(); }; } + +/** 제자리 툴팁 상자 — 그림 위 설명 · 값 풍선(위치는 부르는 쪽 몫 · 마우스를 안 막음) */ +export function tooltipBox(text: string): HTMLDivElement { + const tip = document.createElement("div"); + tip.className = "ui-hover-info ui-hover-info--inline"; + tip.setAttribute("role", "tooltip"); + tip.textContent = text; + return tip; +} diff --git a/ui_template/ui_template_input.css b/ui_template/ui_template_input.css new file mode 100644 index 000000000..0f0b4db05 --- /dev/null +++ b/ui_template/ui_template_input.css @@ -0,0 +1,102 @@ +/* ============================================================================= + * ui_template_input.css — 입력 · 고르기 크기 단 · 상태 · 단위 붙음 · 슬라이더(552-2) + * 기본 꼴(42 · 여백 · 화살표)은 `ui_template_elements_styles.ts`(옛 자리 · 덮어쓰기 차례 그대로). + * 단 값(--ui-size-*)은 `ui_template_button.css` 한 곳. + * ========================================================================== */ + +/* 크기 단 — 높이를 박음(칸은 글 줄 높이가 브라우저마다 달라 여백 식으로는 안 맞음) */ +.ui-input[data-size]:not(textarea), +.ui-select[data-size] { + height: var(--ui-size-h); + padding-block: 0; + font-size: var(--ui-size-font); + line-height: normal; +} +.ui-input[data-size] { + padding-inline: 8px; +} +.ui-select[data-size] { + padding-inline: 8px 26px; + background-position: right 8px center; +} +.ui-input[data-size]:is([data-size="36"], [data-size="42"]) { + padding-inline: 14px; +} +.ui-select[data-size]:is([data-size="36"], [data-size="42"]) { + padding-inline: 14px 36px; + background-position: right 12px center; +} +.ui-input[data-size]:is([data-size="18"], [data-size="20"], [data-size="22"], [data-size="24"]), +.ui-select[data-size]:is([data-size="18"], [data-size="20"], [data-size="22"], [data-size="24"]) { + border-radius: var(--ui-size-radius); +} +textarea.ui-input { + resize: vertical; +} + +/* 상태 — 마우스 · 막힘 · 오류(옛 --error 같이) */ +.ui-input:is(:hover, [data-state="hover"]):not(:disabled, :focus), +.ui-select:is(:hover, [data-state="hover"]):not(:disabled, :focus) { + border-color: var(--color-border-strong); +} +.ui-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ui-input:is(.is-error, [aria-invalid="true"]):not(:focus), +.ui-select:is(.is-error, [aria-invalid="true"]):not(:focus) { + border-color: var(--color-danger); +} + +/* 단위 붙음 — 칸 안 오른쪽 글 · 칸 폭은 부르는 쪽 몫 */ +.ui-input-unit { + position: relative; + display: flex; +} +.ui-input-unit > .ui-input { + flex: 1; + min-width: 0; + padding-right: calc(8px + 2.5em); +} +.ui-input-unit__text { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + font-size: var(--text-caption); + color: var(--color-text-secondary); + pointer-events: none; +} + +/* 슬라이더 — [이름 | 막대 | 값 글] 3열 */ +.ui-slider { + display: grid; + grid-template-columns: auto minmax(40px, 1fr) auto; + align-items: center; + gap: 8px; + min-height: var(--ui-size-h); + font-size: var(--text-caption); + color: var(--color-text-secondary); +} +.ui-slider__range { + width: 100%; + margin: 0; + accent-color: var(--color-accent); + cursor: pointer; +} +.ui-slider__range:is(:hover, [data-state="hover"]):not(:disabled) { + accent-color: var(--color-primary-hover); +} +.ui-slider__range:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.ui-slider__label { + white-space: nowrap; +} +.ui-slider__value { + min-width: 3em; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text); +} diff --git a/ui_template/ui_template_input.ts b/ui_template/ui_template_input.ts new file mode 100644 index 000000000..7feb6c8cf --- /dev/null +++ b/ui_template/ui_template_input.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * ui_template_input.ts + * 부품 「입력」 · 「고르기」 · 「슬라이더」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * + * 꼴 = 입력: 글 · 수 · 찾기 · 날짜 · 비밀번호 · 단위 붙음(unit) · 여러 줄(multiline) / 고르기: 한 개 · 화살표 = 셰브런 + * 슬라이더: 이름 · 막대 · 값 글 3열 한 줄 + * 크기 = `size: 24 | 28 | 32 | 42 …`(data-size · 여덟 단) · 크기를 안 주면 옛 꼴(42) · 옛 `compact` = 옛 작은 칸(30) 그대로 + * 상태 = 마우스 `:hover` · 막힘 `disabled` · 오류 `.is-error` / `aria-invalid` / setError — CSS 는 `[data-state]` 로도 받음 + * 쓰기 = `createInputField({ label: "폭", type: "number", unit: "m", size: 28 })` · `createSelectField({ options, size: 28 })` + * `createSlider({ label: "투명도", min: 0, max: 100, value: 60, format: (v) => `${v}%` })` + * 대신 = `.ui-panel-form input`(26) · `b01-sys-input` · `m02-df*__input` · `b04-surface__select` · `viewer-option-row` …(목록 4장) + * 클래스 = `ui-field` · `ui-field__label` · `ui-input(--sm · --error)` · `ui-select(--sm)` · `ui-field__error` — 시험 · 가이드 촬영이 잡음 + * ========================================================================== */ + +import { setUiSize, type UiSize } from "./ui_template_button"; +import { el } from "./ui_template_elements_base"; +import "./ui_template_input.css"; + +export interface InputFieldOptions { + /** 라벨 텍스트 (i18n 결과) */ + label?: string; + /** placeholder (i18n 결과) */ + placeholder?: string; + type?: "text" | "password" | "email" | "number" | "search" | "tel" | "date"; + value?: string; + required?: boolean; + /** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */ + min?: number; + max?: number; + onInput?: (value: string) => void; + /** 좁은 자리(표 칸·좌측 패널)용 작은 크기 — 작은 고르기칸(compact)과 높이가 같음 */ + compact?: boolean; + /** 크기 단(높이 px) */ + size?: UiSize; + /** 칸 안 오른쪽 단위 글(m · % · m³) */ + unit?: string; + /** 여러 줄 — textarea(줄 수) */ + multiline?: number; +} + +export interface InputFieldHandle { + /** 필드 컨테이너 (라벨 + 입력 + 에러 슬롯) */ + root: HTMLDivElement; + input: HTMLInputElement; + /** 에러 메시지 표시/해제. msg가 없으면 에러 해제. */ + setError: (msg?: string) => void; +} + +export function createInputField(opts: InputFieldOptions): InputFieldHandle { + const root = el("div", { className: "ui-field" }); + + if (opts.label) { + root.append(el("label", { className: "ui-field__label", text: opts.label })); + } + + const attrs: Record = { + ...(opts.placeholder ? { placeholder: opts.placeholder } : {}), + ...(opts.required ? { required: "true" } : {}), + }; + // 여러 줄은 textarea — 값 · 이벤트 · 오류 손잡이는 input 과 같은 길(value · placeholder) + const input = ( + opts.multiline + ? el("textarea", { className: "ui-input", attrs: { ...attrs, rows: String(opts.multiline) } }) + : el("input", { + className: opts.compact ? "ui-input ui-input--sm" : "ui-input", + attrs: { + type: opts.type ?? "text", + ...attrs, + ...(opts.min !== undefined ? { min: String(opts.min) } : {}), + ...(opts.max !== undefined ? { max: String(opts.max) } : {}), + }, + }) + ) as HTMLInputElement; + if (opts.size) setUiSize(input, opts.size); + if (opts.value !== undefined) input.value = opts.value; + if (opts.onInput) { + input.addEventListener("input", () => opts.onInput!(input.value)); + } + + // 에러 메시지 슬롯 (입력창 하단 표기, frontend.md §4) + const errorSlot = el("span", { className: "ui-field__error", attrs: { role: "alert" } }); + + if (opts.unit) { + const wrap = el("div", { className: "ui-input-unit" }); + wrap.append(input, el("span", { className: "ui-input-unit__text", text: opts.unit })); + root.append(wrap, errorSlot); + } else { + root.append(input, errorSlot); + } + + const setError = (msg?: string): void => { + if (msg) { + input.classList.add("ui-input--error"); + input.setAttribute("aria-invalid", "true"); + errorSlot.textContent = msg; + errorSlot.classList.add("is-visible"); + } else { + input.classList.remove("ui-input--error"); + input.removeAttribute("aria-invalid"); + errorSlot.textContent = ""; + errorSlot.classList.remove("is-visible"); + } + }; + + return { root, input, setError }; +} + +export interface SelectFieldOptions { + /** 라벨 텍스트 */ + label?: string; + /** 옵션 항목 목록 */ + options: { value: string; text: string }[]; + /** 초기 선택값 */ + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + /** 좁은 자리(표 칸·좌측 패널)용 작은 여백 */ + compact?: boolean; + /** 크기 단(높이 px) */ + size?: UiSize; + /** 라벨이 없을 때 보조기기용 이름 */ + ariaLabel?: string; +} + +export interface SelectFieldHandle { + root: HTMLDivElement; + select: HTMLSelectElement; + /** 선택지를 다시 채움 — value 가 목록에 없으면 첫 항목 */ + setOptions: (options: { value: string; text: string }[], value?: string) => void; +} + +export function createSelectField(opts: SelectFieldOptions): SelectFieldHandle { + const root = el("div", { className: "ui-field" }); + + if (opts.label) { + root.append(el("label", { className: "ui-field__label", text: opts.label })); + } + + const select = el("select", { + className: opts.compact ? "ui-select ui-select--sm" : "ui-select", + attrs: opts.ariaLabel ? { "aria-label": opts.ariaLabel } : {}, + }); + if (opts.size) setUiSize(select, opts.size); + + const setOptions: SelectFieldHandle["setOptions"] = (options, value) => { + select.replaceChildren( + ...options.map((opt) => el("option", { attrs: { value: opt.value }, text: opt.text })), + ); + if (value !== undefined && options.some((opt) => opt.value === value)) select.value = value; + }; + setOptions(opts.options, opts.value); + + if (opts.disabled) select.disabled = true; + + if (opts.onChange) { + select.addEventListener("change", () => opts.onChange!(select.value)); + } + + root.append(select); + + return { root, select, setOptions }; +} + +export interface SliderOptions { + label: string; + min: number; + max: number; + step?: number; + value: number; + /** 값 글 — 없으면 수 그대로 */ + format?: (value: number) => string; + onInput?: (value: number) => void; + disabled?: boolean; +} + +export interface SliderHandle { + root: HTMLDivElement; + input: HTMLInputElement; + /** 값을 바꾸고 값 글을 맞춤(onInput 은 안 부름) */ + set: (value: number) => void; +} + +/** 슬라이더 — [이름 | 막대 | 값 글] 한 줄(높이 24) */ +export function createSlider(opts: SliderOptions): SliderHandle { + const root = el("div", { className: "ui-slider" }); + setUiSize(root, 24); + const input = el("input", { + className: "ui-slider__range", + attrs: { + type: "range", + min: String(opts.min), + max: String(opts.max), + step: String(opts.step ?? 1), + "aria-label": opts.label, + }, + }); + const text = el("span", { className: "ui-slider__value" }); + const show = (): void => { + text.textContent = opts.format ? opts.format(input.valueAsNumber) : input.value; + }; + const set = (value: number): void => { + input.value = String(value); + show(); + }; + set(opts.value); + if (opts.disabled) input.disabled = true; + input.addEventListener("input", () => { + show(); + opts.onInput?.(input.valueAsNumber); + }); + root.append(el("span", { className: "ui-slider__label", text: opts.label }), input, text); + return { root, input, set }; +} diff --git a/ui_template/ui_template_segment.css b/ui_template/ui_template_segment.css new file mode 100644 index 000000000..56e4ba04c --- /dev/null +++ b/ui_template/ui_template_segment.css @@ -0,0 +1,49 @@ +/* 부품 「분할 고르기」(552-2) — 대시보드 `.b01-sys-seg` 꼴 · 단 값은 ui_template_button.css */ +.ui-segment { + display: inline-flex; + height: var(--ui-size-h); + border: 1px solid var(--color-border); + border-radius: var(--ui-size-radius); + overflow: hidden; +} +.ui-segment__item { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--ui-size-gap); + padding: 0 var(--ui-size-px); + border: 0; + background: var(--color-surface-raised); + color: var(--color-text-secondary); + font: inherit; + font-size: var(--ui-size-font); + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} +.ui-segment__item + .ui-segment__item { + border-left: 1px solid var(--color-border); +} +.ui-segment__item svg { + width: var(--ui-size-icon); + height: var(--ui-size-icon); +} +.ui-segment__item:is(:hover, [data-state="hover"]):not(:disabled) { + background: var(--color-hover); + color: var(--color-text); +} +.ui-segment__item[aria-checked="true"] { + background: var(--color-accent-soft); + color: var(--color-accent); + font-weight: var(--font-weight-bold); +} +.ui-segment__item:is(:active, [data-state="active"]):not(:disabled) { + box-shadow: inset 0 1px 2px var(--color-scrim); +} +.ui-segment__item:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/ui_template/ui_template_segment.ts b/ui_template/ui_template_segment.ts new file mode 100644 index 000000000..81a591655 --- /dev/null +++ b/ui_template/ui_template_segment.ts @@ -0,0 +1,72 @@ +/* ============================================================================= + * ui_template_segment.ts + * 부품 「분할 고르기」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) — 붙은 칸 둘 ~ 다섯 중 하나 + * + * 꼴 = 글 · 아이콘(+ title) · 글 + 아이콘 + * 크기 = data-size 20 · 24 · 28 · 32(기본 28) + * 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` · 고름 = aria-checked — CSS 는 `[data-state]` 로도 받음 + * 쓰기 = `createSegment({ options: [{ value: "list", label: "목록" }, { value: "card", label: "카드" }], value: "list", onChange })` + * 대신 = B01 `.b01-sys-seg` · `.b01-home__seg` · `.b01-proj-seg` · B04 `b04-surface__sheet-method` · B05 `b05-routerec__pills` · + * B06 `b06-design__btn(--active)` · CAD `.cad-inspector-tabs` · E04 · E06 · E07 `*-toggle` · M02 `m02-dfip__chips` + * ========================================================================== */ + +import { setUiSize, type UiSize } from "./ui_template_button"; +import { icon, type IconName } from "./ui_template_icons"; +import "./ui_template_segment.css"; + +export interface SegmentOption { + value: string; + label?: string; + icon?: IconName; + /** 아이콘만이면 필수 */ + title?: string; + disabled?: boolean; +} + +export interface SegmentHandle { + root: HTMLDivElement; + get: () => string; + /** 고름을 바꿈(onChange 는 안 부름) */ + set: (value: string) => void; +} + +export function createSegment(opts: { + options: SegmentOption[]; + value: string; + onChange?: (value: string) => void; + /** 20 · 24 · 28 · 32 권함 */ + size?: UiSize; + ariaLabel?: string; +}): SegmentHandle { + const root = document.createElement("div"); + root.className = "ui-segment"; + root.setAttribute("role", "radiogroup"); + if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel); + setUiSize(root, opts.size ?? 28); + let current = opts.value; + const items = opts.options.map((o) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "ui-segment__item"; + b.setAttribute("role", "radio"); + b.dataset.value = o.value; + if (o.icon) b.append(icon(o.icon, 16)); + if (o.label) b.append(o.label); + if (o.title) b.title = o.title; + if (o.title && !o.label) b.setAttribute("aria-label", o.title); + b.disabled = o.disabled ?? false; + b.addEventListener("click", () => { + if (current === o.value) return; + set(o.value); + opts.onChange?.(o.value); + }); + return b; + }); + const set = (value: string): void => { + current = value; + for (const b of items) b.setAttribute("aria-checked", String(b.dataset.value === value)); + }; + set(current); + root.append(...items); + return { root, get: () => current, set }; +} diff --git a/ui_template/ui_template_tabs.css b/ui_template/ui_template_tabs.css new file mode 100644 index 000000000..349602ec7 --- /dev/null +++ b/ui_template/ui_template_tabs.css @@ -0,0 +1,68 @@ +/* 부품 「탭」(552-2) — 밑줄 탭 = 대시보드 `.b01-pg-tabs` 꼴 · 보조 탭 = 작은 알약 · 단 값은 ui_template_button.css */ +.ui-tabs { + display: flex; + align-items: flex-end; + gap: 4px; + border-bottom: 1px solid var(--color-border); +} +.ui-tabs__tab { + flex: none; + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--ui-size-h); + padding: 0 12px; + margin-bottom: -1px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--ui-size-font); + font-weight: var(--font-weight-bold); + line-height: 1; + white-space: nowrap; + cursor: pointer; +} +.ui-tabs__tab:is(:hover, [data-state="hover"]):not(:disabled) { + color: var(--color-text); +} +.ui-tabs__tab.is-active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} +.ui-tabs__tab:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 보조 탭 — 밑줄 없음 · 고른 칸 = 강조 바탕 · 글 */ +.ui-tabs--pill { + align-items: center; + border-bottom: 0; +} +.ui-tabs--pill > .ui-btn { + flex: none; + white-space: nowrap; + color: var(--color-text-secondary); +} +.ui-tabs--pill > .ui-btn.is-active { + color: var(--color-accent); + font-weight: var(--font-weight-bold); +} + +/* 수 배지 — 빨강 동그라미 */ +.ui-tabs__count { + min-width: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--color-danger-fill); + color: var(--color-canvas); + font-size: 11px; + font-weight: var(--font-weight-medium); + line-height: 18px; + text-align: center; +} +.ui-tabs__count[hidden] { + display: none; +} diff --git a/ui_template/ui_template_tabs.ts b/ui_template/ui_template_tabs.ts new file mode 100644 index 000000000..038bbb0ff --- /dev/null +++ b/ui_template/ui_template_tabs.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * ui_template_tabs.ts + * 부품 「탭」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * + * 꼴 = 밑줄 탭(line · 한 꼴 · 대시보드 `tabBar` 꼴) · 보조 탭(pill · 작은 알약 · `.ui-subtabs`) · 탭 오른쪽 수 배지(0 = 숨김) + * 문서 탭(`cad-tabs` · `ss-tabs`)은 꼴 그대로 · 토큰만(553) + * 크기 = data-size 24 · 32 · 36(밑줄 기본 36 · 보조 기본 24) + * 상태 = 마우스 `:hover` · 막힘 `disabled` · 고름 = aria-selected + `.is-active` — CSS 는 `[data-state]` 로도 받음 + * 쓰기 = `const tabs = createTabs({ tabs: [{ key: "a", label: "개요" }], value: "a", onChange }); tabs.setBadge("a", 3)` + * 대신 = B01 `.b01-pg-tabs` · 공용 `.ui-subtabs` · B08 `.b08-quantity__tabs` · B09 `.b09-estimation__tabs` · `app-settings__tabs` · + * M02 `m02-sod__tabs` · 시트 `.ss-fmtdlg__tabs` + * 클래스 = 보조 탭 = `ui-subtabs` + 칸 `ui-btn ui-btn--pill`(옛 이름 · 시험이 잡음) + * ========================================================================== */ + +import { createButton, setUiSize, type UiSize } from "./ui_template_button"; +import "./ui_template_tabs.css"; + +export interface TabItem { + key: string; + label: string; + disabled?: boolean; +} + +export interface TabsHandle { + root: HTMLDivElement; + /** 고름을 바꿈(onChange 는 안 부름) */ + select: (key: string) => void; + /** 탭 오른쪽 수 — 0 이하면 숨김 */ + setBadge: (key: string, count: number) => void; +} + +export function createTabs(opts: { + tabs: TabItem[]; + value: string; + onChange?: (key: string) => void; + kind?: "line" | "pill"; + /** 24 · 32 · 36 권함 */ + size?: UiSize; +}): TabsHandle { + const pill = opts.kind === "pill"; + const root = document.createElement("div"); + root.className = pill ? "ui-tabs ui-tabs--pill ui-subtabs" : "ui-tabs"; + root.setAttribute("role", "tablist"); + setUiSize(root, opts.size ?? (pill ? 24 : 36)); + const buttons = new Map(); + const badges = new Map(); + const select = (key: string): void => { + for (const [k, b] of buttons) { + b.classList.toggle("is-active", k === key); + b.setAttribute("aria-selected", String(k === key)); + } + }; + for (const tab of opts.tabs) { + const b = pill + ? createButton({ label: tab.label, variant: "pill", size: opts.size ?? 24 }) + : document.createElement("button"); + if (!pill) { + b.type = "button"; + b.className = "ui-tabs__tab"; + b.append(tab.label); + } + b.setAttribute("role", "tab"); + b.dataset.tab = tab.key; + b.disabled = tab.disabled ?? false; + const count = document.createElement("span"); + count.className = "ui-tabs__count"; + count.hidden = true; + b.append(count); + b.addEventListener("click", () => { + select(tab.key); + opts.onChange?.(tab.key); + }); + buttons.set(tab.key, b); + badges.set(tab.key, count); + root.append(b); + } + select(opts.value); + return { + root, + select, + setBadge: (key, n) => { + const c = badges.get(key); + if (!c) return; + c.textContent = String(n); + c.hidden = n <= 0; + }, + }; +} diff --git a/ui_template/ui_template_text.css b/ui_template/ui_template_text.css new file mode 100644 index 000000000..38b4df6de --- /dev/null +++ b/ui_template/ui_template_text.css @@ -0,0 +1,29 @@ +/* 부품 「보조 글 · 안내 줄」(552-2) — 색 = 공통 값 · 글 크기 = --text-* */ +.ui-text { + display: flex; + align-items: center; + gap: 4px; + margin: 0; + font-size: var(--text-caption); + line-height: 1.4; + color: var(--color-text-secondary); +} +.ui-text[data-text="14"] { + font-size: var(--text-body-sm); +} +.ui-text--hint { + color: var(--color-text-secondary); +} +.ui-text--error { + color: var(--color-danger); +} +.ui-text--warning { + color: var(--color-warning); +} +.ui-text--success { + color: var(--color-success); +} +.ui-text__mark { + flex: none; + font-weight: var(--font-weight-bold); +} diff --git a/ui_template/ui_template_text.ts b/ui_template/ui_template_text.ts new file mode 100644 index 000000000..c1a70f8b7 --- /dev/null +++ b/ui_template/ui_template_text.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * ui_template_text.ts + * 부품 「보조 글 · 안내 줄」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * + * 꼴 = 흐린 글(muted) · 안내(hint · ⓘ 아이콘) · 상태 글(error · warning · success — 글자 기호 ✗ ! ✓ 같이) · 칸 오류(`.ui-field__error`) + * 크기 = 글 12(기본) · 14 — data-text 로 고름 + * 상태 = 오류 = error 꼴(색만으로 안 가름 · 기호 같이) + * 쓰기 = `createText("저장 안 한 값이 있음", "warning")` · `createText("단위 m", "muted", 14)` + * 대신 = 파일마다 `__muted`(약 30 벌) · `__hint` · `__status` · `__note` · `__warn` · `.b01-sys-error` · `b03-file__error` …(목록 4장) + * ========================================================================== */ + +import { icon } from "./ui_template_icons"; +import "./ui_template_text.css"; + +export type TextKind = "muted" | "hint" | "error" | "warning" | "success"; + +/** 상태 글 앞 기호 — 색만으로 안 가름 */ +const MARK: Partial> = { error: "✗", warning: "!", success: "✓" }; + +export function createText( + text: string, + kind: TextKind = "muted", + size: 12 | 14 = 12, +): HTMLElement { + const p = document.createElement("p"); + p.className = `ui-text ui-text--${kind}`; + p.dataset.text = String(size); + if (kind === "hint") p.append(icon("info", size + 2)); + const mark = MARK[kind]; + if (mark) { + const m = document.createElement("span"); + m.className = "ui-text__mark"; + m.textContent = mark; + p.append(m); + } + p.append(text); + if (kind === "error") p.setAttribute("role", "alert"); + return p; +} diff --git a/ui_template/ui_template_toggle.css b/ui_template/ui_template_toggle.css new file mode 100644 index 000000000..c5cedbe20 --- /dev/null +++ b/ui_template/ui_template_toggle.css @@ -0,0 +1,86 @@ +/* 부품 「켜기」(552-2) — 스위치 · 켜기 칩 · 단 값은 ui_template_button.css */ +.ui-toggle { + display: inline-flex; + align-items: center; + gap: var(--ui-size-gap); + height: var(--ui-size-h); + padding: 0; + border: 0; + background: none; + color: var(--color-text-body); + font: inherit; + font-size: var(--ui-size-font); + line-height: 1; + white-space: nowrap; + cursor: pointer; +} + +/* 스위치 막대 — 높이 = 단의 0.65 · 너비 = 높이 × 1.8 */ +.ui-toggle__track { + position: relative; + flex: none; + width: calc(var(--ui-size-h) * 0.65 * 1.8); + height: calc(var(--ui-size-h) * 0.65); + border-radius: var(--radius-pills); + background: var(--color-smoke); + transition: background-color var(--transition-fast); +} +.ui-toggle__track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(var(--ui-size-h) * 0.65 - 4px); + height: calc(var(--ui-size-h) * 0.65 - 4px); + border-radius: 50%; + background: var(--color-canvas); + box-shadow: var(--shadow-sm); + transition: transform var(--transition-fast); +} +.ui-toggle[aria-checked="true"] .ui-toggle__track { + background: var(--color-accent); +} +.ui-toggle[aria-checked="true"] .ui-toggle__track::after { + transform: translateX(calc(var(--ui-size-h) * 0.65 * 0.8)); +} +.ui-toggle:is(:hover, [data-state="hover"]):not(:disabled) { + color: var(--color-text); +} +.ui-toggle:is(:hover, [data-state="hover"]):not(:disabled) .ui-toggle__track { + filter: brightness(0.92); +} + +/* 켜기 칩 — 알약 · 켜면 강조 테두리 · 바탕 */ +.ui-toggle--chip { + padding-inline: var(--ui-size-px); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + color: var(--color-text-secondary); +} +.ui-toggle--chip:is(:hover, [data-state="hover"]):not(:disabled) { + background: var(--color-hover); +} +.ui-toggle--chip[aria-pressed="true"] { + border-color: var(--color-accent); + background: var(--color-accent-soft); + color: var(--color-accent); + font-weight: var(--font-weight-medium); +} +.ui-toggle__dot { + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} +.ui-toggle--chip[aria-pressed="false"] .ui-toggle__dot { + opacity: 0.4; +} + +.ui-toggle:is(:active, [data-state="active"]):not(:disabled) { + transform: translateY(1px); +} +.ui-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/ui_template/ui_template_toggle.ts b/ui_template/ui_template_toggle.ts new file mode 100644 index 000000000..25112acc8 --- /dev/null +++ b/ui_template/ui_template_toggle.ts @@ -0,0 +1,70 @@ +/* ============================================================================= + * ui_template_toggle.ts + * 부품 「켜기」(552-2 · 부품 모음 `#/b01-account?view=ui-kit`) + * + * 꼴 = 스위치(switch · [글 ○━] 한 줄) · 켜기 칩(chip · 색 점 붙음 · 지도 층 · 보기 켜고 끄기) + * 크기 = data-size 20 · 24 · 28 · 32(기본 24) + * 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` · 켬 = aria-checked / aria-pressed — CSS 는 `[data-state]` 로도 받음 + * 쓰기 = `createToggle({ label: "등고선", checked: true, onChange })` + * `createToggle({ kind: "chip", label: "유역", dot: "var(--map-basin-1)" })` + * 대신 = B03 `b03-file__lasfree` · B04 `b04-map__layer-button` · `.toggle-button` · B05 `b05-drainage__layer-button` · + * `toggleButton()` · B06 `b06-structure-panel__toggle` · `.ui-prov-toggle` · 시트 `.is-on` 단추 · CAD 상태막대 켜기 + * ========================================================================== */ + +import { setUiSize, type UiSize } from "./ui_template_button"; +import "./ui_template_toggle.css"; + +export interface ToggleOptions { + label: string; + /** switch(기본) · chip */ + kind?: "switch" | "chip"; + checked?: boolean; + /** 칩 앞 색 점 — CSS 색 값(그림 색은 `var(--map-*)` · `var(--draw-*)`) */ + dot?: string; + /** 20 · 24 · 28 · 32 권함 */ + size?: UiSize; + disabled?: boolean; + onChange?: (on: boolean) => void; +} + +export interface ToggleHandle { + root: HTMLButtonElement; + /** 지금 값 */ + get: () => boolean; + /** 값을 바꿈(onChange 는 안 부름) */ + set: (on: boolean) => void; +} + +export function createToggle(opts: ToggleOptions): ToggleHandle { + const chip = opts.kind === "chip"; + const root = document.createElement("button"); + root.type = "button"; + root.className = chip ? "ui-toggle ui-toggle--chip" : "ui-toggle"; + setUiSize(root, opts.size ?? 24); + if (!chip) root.setAttribute("role", "switch"); + const attr = chip ? "aria-pressed" : "aria-checked"; + if (chip && opts.dot) { + const dot = document.createElement("span"); + dot.className = "ui-toggle__dot"; + dot.style.background = opts.dot; + root.append(dot); + } + const text = document.createElement("span"); + text.className = "ui-toggle__text"; + text.textContent = opts.label; + root.append(text); + if (!chip) { + const track = document.createElement("span"); + track.className = "ui-toggle__track"; + root.append(track); + } + const set = (on: boolean): void => root.setAttribute(attr, String(on)); + const get = (): boolean => root.getAttribute(attr) === "true"; + set(opts.checked ?? false); + root.disabled = opts.disabled ?? false; + root.addEventListener("click", () => { + set(!get()); + opts.onChange?.(get()); + }); + return { root, get, set }; +} diff --git a/ui_template/ui_template_ui_kit.css b/ui_template/ui_template_ui_kit.css index 178685ae2..0154a6e13 100644 --- a/ui_template/ui_template_ui_kit.css +++ b/ui_template/ui_template_ui_kit.css @@ -155,3 +155,10 @@ padding-top: 8px; border-top: 1px dashed var(--color-border); } + +/* 견본 세로 묶음(탭 두 꼴) */ +.ui-kit__demo-col { + display: flex; + flex-direction: column; + gap: 8px; +} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 6161b8f28..4a35c0f82 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -18,9 +18,16 @@ * 몫 = 552-2 기본 · 552-3 묶음 · 552-4 틀 — 세 묶음이 떨어져 있어 서로 줄을 안 건드림. * ========================================================================== */ +import { createBadge } from "./ui_template_badge"; import { createButton, createIconButton, type UiSize } from "./ui_template_button"; -import { createInputField, createSelectField } from "./ui_template_elements"; +import { createCheckbox } from "./ui_template_checkbox"; +import { tooltipBox } from "./ui_template_hover_info"; import { ICON_NAMES, icon } from "./ui_template_icons"; +import { createInputField, createSelectField, createSlider } from "./ui_template_input"; +import { createSegment } from "./ui_template_segment"; +import { createTabs } from "./ui_template_tabs"; +import { createText } from "./ui_template_text"; +import { createToggle } from "./ui_template_toggle"; import { badge, dataTable, @@ -28,7 +35,6 @@ import { rowMenu, settingCard, settingRow, - tabBar, valueText, } from "../B01_Dashboard/B01_Dashboard_UI_PageKit"; @@ -93,6 +99,80 @@ const row = (...children: HTMLElement[]): HTMLElement => { const noop = (): void => {}; +/** 552-2 견본 조각 — 상태를 안쪽 요소에 굳힘 */ +const OPTS = [ + { value: "a", text: "임도" }, + { value: "b", text: "예정노선" }, +]; +interface FieldDemo { + root: HTMLElement; + node: HTMLElement; + setError?: (m?: string) => void; +} +function fieldDemo(s: KitState, make: () => FieldDemo): HTMLElement | null { + if (s === "active") return null; + const f = make(); + markState(f.node, s); + if (s === "error") f.setError?.("값을 넣어야 함"); + return f.root; +} +function checkDemo(s: KitState): HTMLElement | null { + if (s === "active" || s === "error") return null; + const boxes = [ + createCheckbox("등고선", { checked: true }), + createCheckbox("밝음", { radio: `kit-${s}`, checked: true }), + createCheckbox("어두움", { radio: `kit-${s}` }), + createCheckbox("지적", { chip: true, checked: true }), + createCheckbox("유역", { chip: true }), + ]; + for (const b of boxes) { + if (s === "disabled") b.input.disabled = true; + else markState(b.root, s); + } + return row(...boxes.map((b) => b.root)); +} +function segmentDemo(s: KitState): HTMLElement | null { + if (s === "error") return null; + const seg = createSegment({ + options: [ + { value: "list", label: "목록" }, + { value: "card", label: "카드" }, + { value: "map", icon: "map", title: "지도" }, + ], + value: "list", + }); + const items = [...seg.root.children] as HTMLButtonElement[]; + if (s === "disabled") items.forEach((b) => markState(b, s)); + else markState(items[1], s); + return seg.root; +} +function tabsDemo(s: KitState): HTMLElement | null { + if (s === "active" || s === "error") return null; + const line = createTabs({ + tabs: [ + { key: "a", label: "개요" }, + { key: "b", label: "구성원" }, + { key: "c", label: "기록" }, + ], + value: "a", + }); + const pill = createTabs({ + kind: "pill", + tabs: [ + { key: "a", label: "토공" }, + { key: "b", label: "배수" }, + { key: "c", label: "구조물" }, + ], + value: "a", + }); + line.setBadge("b", 3); + for (const t of [line, pill]) markState(t.root.children[1] as HTMLButtonElement, s); + const box = document.createElement("div"); + box.className = "ui-kit__demo-col"; + box.append(line.root, pill.root); + return box; +} + // prettier-ignore export const KIT_PARTS: KitPart[] = [ // ── 552-2 기본 ───────────────────────────────────────────── @@ -102,23 +182,29 @@ export const KIT_PARTS: KitPart[] = [ { 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, - demo: (s) => { const f = createSelectField({ options: [{ value: "a", text: "임도" }, { value: "b", text: "예정노선" }] }); markState(f.select, s); return f.root; } }, - { key: "check", name: "체크 · 라디오", group: "basic", kinds: "체크 · 라디오 · 글 붙음", file: "ui_template_checkbox.ts" }, - { key: "toggle", name: "켜기", group: "basic", kinds: "켬 · 끔 · 글 붙음", file: "ui_template_toggle.ts" }, - { key: "segment", name: "분할 고르기", group: "basic", kinds: "글 · 아이콘 · 여덟 단", file: "ui_template_segment.ts" }, - { key: "tabs", name: "탭", group: "basic", kinds: "밑줄 탭(한 꼴) · 수 배지 · 문서 탭은 꼴 그대로", file: "ui_template_tabs.ts", legacy: true, - demo: (s) => (s === "default" ? tabBar([{ key: "a", label: "개요" }, { key: "b", label: "구성원" }, { key: "c", label: "기록" }], "a", noop).root : null) }, - { 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: "input", name: "입력", group: "basic", kinds: "글 · 수 · 단위 붙음 · 찾기 · 날짜 · 여러 줄 · 여덟 단", file: "ui_template_input.ts", + demo: (s) => fieldDemo(s, () => { const f = createInputField({ placeholder: "폭", type: "number", unit: "m", size: 32 }); return { root: f.root, node: f.input, setError: f.setError }; }), + sizes: (z) => createInputField({ placeholder: String(z), size: z }).input }, + { key: "select", name: "고르기", group: "basic", kinds: "한 개 · 화살표 = 셰브런 · 여덟 단", file: "ui_template_input.ts", + demo: (s) => fieldDemo(s, () => { const f = createSelectField({ options: OPTS, size: 32 }); return { root: f.root, node: f.select }; }), + sizes: (z) => createSelectField({ options: OPTS, size: z }).select }, + { key: "check", name: "체크 · 라디오", group: "basic", kinds: "체크 · 라디오 · 글 붙음 · 칩(켜면 채움)", file: "ui_template_checkbox.ts", demo: checkDemo }, + { key: "toggle", name: "켜기", group: "basic", kinds: "스위치 · 켜기 칩(색 점) · 20 · 24 · 28 · 32", file: "ui_template_toggle.ts", + demo: (s) => (s === "error" ? null : row(...[createToggle({ label: "등고선", checked: true }), createToggle({ label: "지적" }), createToggle({ kind: "chip", label: "유역", checked: true, dot: "var(--color-info)" })].map((t) => markState(t.root, s)))), + sizes: (z) => createToggle({ label: String(z), size: z, checked: true }).root }, + { key: "segment", name: "분할 고르기", group: "basic", kinds: "글 · 아이콘 · 둘 ~ 다섯 칸 · 20 · 24 · 28 · 32", file: "ui_template_segment.ts", demo: segmentDemo, + sizes: (z) => createSegment({ options: [{ value: "a", label: String(z) }, { value: "b", icon: "map", title: "지도" }], value: "a", size: z }).root }, + { key: "tabs", name: "탭", group: "basic", kinds: "밑줄 탭(한 꼴) · 수 배지 · 보조 탭(작은 알약) · 문서 탭은 꼴 그대로", file: "ui_template_tabs.ts", demo: tabsDemo }, + { key: "badge", name: "배지", group: "basic", kinds: "상태 · 역할 · 종류 · 수 · 램프(✓ ✗ 같이) · 점", file: "ui_template_badge.ts", + demo: (s) => (s === "default" ? row(createBadge("진행 중", "accent"), createBadge("완료", "success"), createBadge("멈춤", "warning"), createBadge("오류", "danger"), createBadge("안정성", "info"), createBadge("임시", "neutral"), createBadge("3", "danger", { kind: "count" }), createBadge("통과", "success", { kind: "lamp" }), createBadge("미달", "danger", { kind: "lamp" }), createBadge("확인 필요", "warning", { kind: "dot" }), createBadge("작게", "neutral", { size: 18 })) : null) }, + { key: "tooltip", name: "툴팁", group: "basic", kinds: "마우스 글 · 값 풍선 · 그림 위 설명", file: "ui_template_hover_info.ts", + demo: (s) => (s === "default" ? row(tooltipBox("누르면 값을 고침"), tooltipBox("구간 1 · 높이 2.4 m\n기울기 1 : 1.5")) : 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 묶음 ───────────────────────────────────────────── + { key: "slider", name: "슬라이더", group: "basic", kinds: "값 하나 · 이름 · 값 글 · 3열 줄(24)", file: "ui_template_input.ts", + demo: (s) => { if (s === "active" || s === "error") return null; const sl = createSlider({ label: "투명도", min: 0, max: 100, value: 60, format: (v) => `${v}%` }); markState(sl.input, s); return sl.root; } }, + { key: "text", name: "보조 글 · 안내 줄", group: "basic", kinds: "흐린 글 · 안내 · 상태(오류 · 경고 · 성공 · 기호 같이) · 12 · 14", file: "ui_template_text.ts", + demo: (s) => (s === "default" ? row(createText("흐린 글 — 마지막 저장 3 분 전"), createText("안내 — 노선을 먼저 고름", "hint"), createText("저장 안 한 값이 있음", "warning"), createText("저장됨", "success"), createText("본문 곁 14", "muted", 14)) : s === "error" ? createText("값이 범위를 벗어남", "error") : null) }, { key: "card", name: "카드", group: "compound", kinds: "선 카드 · 머리 카드 · 고를 수 있는 카드", file: "ui_template_card.ts" }, { key: "float-panel", name: "떠 있는 판", group: "compound", kinds: "그림 위 판 · 끌기 머리 · 닫기 · 팝오버", file: "ui_template_float_panel.ts" }, { key: "collapsible", name: "접이 카드", group: "compound", kinds: "패널 카드 · 구역 · 캐럿 셰브런", file: "ui_template_collapsible.ts" },