merge(sub_laptop_2): 552-2 기본 부품 13 — 부품 모음 목록 충돌 양쪽 살림(552-2 견본 + 552-3 · 552-4 묶음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:41:46 +09:00
co-authored by Claude Opus 5.5
21 changed files with 1280 additions and 151 deletions
+130
View File
@@ -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
+81
View File
@@ -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);
}
+44
View File
@@ -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<Record<BadgeTone, string>> = { 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;
}
+41
View File
@@ -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;
}
+29 -9
View File
@@ -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;
+2 -124
View File
@@ -18,6 +18,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";
/** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */
@@ -26,132 +27,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
* -------------------------------------------------------------------------- */
+5 -1
View File
@@ -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);
+7 -1
View File
@@ -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;
}
+16
View File
@@ -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;
}
+102
View File
@@ -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);
}
+213
View File
@@ -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<string, string> = {
...(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 };
}
+49
View File
@@ -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;
}
+72
View File
@@ -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 };
}
+68
View File
@@ -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;
}
+88
View File
@@ -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<string, HTMLButtonElement>();
const badges = new Map<string, HTMLSpanElement>();
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;
},
};
}
+29
View File
@@ -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);
}
+39
View File
@@ -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<Record<TextKind, string>> = { 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;
}
+86
View File
@@ -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;
}
+70
View File
@@ -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 };
}
+7
View File
@@ -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;
}
+102 -16
View File
@@ -18,16 +18,22 @@
* 몫 = 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,
rowMenu,
settingCard,
settingRow,
tabBar,
valueText,
} from "../B01_Dashboard/B01_Dashboard_UI_PageKit";
// 552-3 묶음 부품
@@ -111,6 +117,79 @@ const col = (...children: HTMLElement[]): HTMLElement => {
box.style.alignItems = "stretch";
return box;
};
/** 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[] = [
@@ -121,22 +200,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" },
{ 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) },
// ── 552-3 묶음 ─────────────────────────────────────────────
{ key: "card", name: "카드", group: "compound", kinds: "선 카드 · 머리 카드 · 고르는 카드 · 여백 12 · 16", file: "ui_template_card.ts",
demo: (s) => { const pick = (): HTMLElement => markState(createCard({ title: "새 노선", body: ["노선을 그려 시작"], pad: 12, onSelect: noop }), s);