/* ============================================================================= * 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 }; }