Files
Aislo/ui_template/ui_template_input.ts
T
eomsangdonandClaude Opus 5.5 2c51bb53a6 feat(ui-kit): 기본 부품 입력 · 고르기 · 슬라이더 · 체크 · 켜기 · 분할 · 탭 · 배지 · 툴팁 · 보조 글 (PLAN 552-2)
- 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 <noreply@anthropic.com>
2026-10-26 21:39:26 +09:00

214 lines
7.7 KiB
TypeScript

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