Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015uTVHEAjkLLn21sdLiKAkn
200 lines
7.1 KiB
TypeScript
200 lines
7.1 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_range_bar.ts
|
|
* 부품 「범위 바」(631 · 부품 모음 `#/b01-account?view=ui-kit`) — 가로 한 줄 칸(범위) · 칸 사이 경계값 하나 · 칸 눌러 고름 · 색 상자로 칸 색
|
|
*
|
|
* 꼴 = 칸(넓이 = 경계 차 비례 · 아주 좁으면 최소 넓이) · 칸 사이 = 수 입력 · 두 끝 = 고정 글 · 아래 = 색 상자 줄(+ 칠 안 함 = 빗금)
|
|
* 크기 = data-size 24 · 28(기본 28)
|
|
* 상태 = 마우스 `:hover` · 눌림 `:active` · 고름 = aria-pressed · 막힘 `disabled` · 오류 = 경계 칸 aria-invalid(`setError`)
|
|
* 쓰기 = `createRangeBar({ edges: [0, 20, 250], colors: ["a", "b"], palette: [{ value: "a", name: "노랑", color: "var(--…)" }], onChange })`
|
|
* 나눔 · 합침 · 검사는 부르는 쪽(급경사 = `A00_Common/map/logic/map_slope_bands.ts`)
|
|
* ========================================================================== */
|
|
|
|
import { setUiSize, type UiSize } from "./ui_template_button";
|
|
import { createInputField } from "./ui_template_input";
|
|
import "./ui_template_range_bar.css";
|
|
|
|
export interface RangeBarColor {
|
|
value: string;
|
|
name: string;
|
|
/** CSS 색(`var(--…)`) */
|
|
color: string;
|
|
}
|
|
|
|
export interface RangeBarState {
|
|
/** 경계 — 처음 · 끝은 고정 · 칸 i = [경계 i, 경계 i+1) */
|
|
edges: number[];
|
|
/** 칸 색 값(빈 글 = 칠 안 함) */
|
|
colors: string[];
|
|
}
|
|
|
|
export interface RangeBarHandle {
|
|
root: HTMLDivElement;
|
|
get: () => RangeBarState;
|
|
/** 칸을 새로 그림(onChange 는 안 부름) */
|
|
set: (state: RangeBarState, selected?: number) => void;
|
|
selected: () => number;
|
|
/** 경계 i(1 ~ 끝 − 1)를 틀림으로 · 없으면 모두 풂 */
|
|
setError: (edge?: number) => void;
|
|
}
|
|
|
|
export function createRangeBar(opts: {
|
|
edges: number[];
|
|
colors: string[];
|
|
palette: RangeBarColor[];
|
|
/** 있으면 색 상자 줄 끝에 칠 안 함(값 빈 글) */
|
|
noneLabel?: string;
|
|
unit?: string;
|
|
/** 두 끝 글(없음 = 경계값 + 단위) */
|
|
ends?: [string, string];
|
|
/** 경계 아래 작은 글(예 ° 환산) */
|
|
hint?: (value: number) => string;
|
|
onChange?: (state: RangeBarState) => void;
|
|
onSelect?: (index: number) => void;
|
|
size?: UiSize;
|
|
ariaLabel?: string;
|
|
}): RangeBarHandle {
|
|
const root = document.createElement("div");
|
|
root.className = "ui-range-bar";
|
|
setUiSize(root, opts.size ?? 28);
|
|
if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel);
|
|
const track = document.createElement("div");
|
|
track.className = "ui-range-bar__track";
|
|
const swatchRow = document.createElement("div");
|
|
swatchRow.className = "ui-range-bar__colors";
|
|
root.append(track, swatchRow);
|
|
|
|
let state: RangeBarState = { edges: [...opts.edges], colors: [...opts.colors] };
|
|
let current = 0;
|
|
let segs: HTMLButtonElement[] = [];
|
|
let inputs: HTMLInputElement[] = [];
|
|
const unit = opts.unit ? ` ${opts.unit}` : "";
|
|
const colorOf = (value: string): string =>
|
|
opts.palette.find((p) => p.value === value)?.color ?? "";
|
|
const nameOf = (value: string): string =>
|
|
opts.palette.find((p) => p.value === value)?.name ?? opts.noneLabel ?? "";
|
|
const changed = (): void => opts.onChange?.(get());
|
|
const get = (): RangeBarState => ({ edges: [...state.edges], colors: [...state.colors] });
|
|
|
|
const swatches = [
|
|
...opts.palette,
|
|
...(opts.noneLabel !== undefined ? [{ value: "", name: opts.noneLabel, color: "" }] : []),
|
|
].map((p) => {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = "ui-range-bar__swatch";
|
|
b.dataset.value = p.value;
|
|
if (p.color) b.style.setProperty("--ui-range-bar-color", p.color);
|
|
else b.dataset.empty = "";
|
|
b.append(p.name);
|
|
b.addEventListener("click", () => {
|
|
if (state.colors[current] === p.value) return;
|
|
state.colors[current] = p.value;
|
|
paint();
|
|
changed();
|
|
});
|
|
swatchRow.append(b);
|
|
return b;
|
|
});
|
|
|
|
/** 칸 넓이 = 경계 차(경계가 틀리면 그대로 둠) */
|
|
function layout(): void {
|
|
const e = state.edges;
|
|
if (!e.every((v, i) => Number.isFinite(v) && (i === 0 || v > e[i - 1]))) return;
|
|
segs.forEach((seg, i) => {
|
|
seg.style.flexGrow = String(e[i + 1] - e[i]);
|
|
seg.title = `${e[i]} ~ ${e[i + 1]}${unit} · ${nameOf(state.colors[i])}`;
|
|
});
|
|
}
|
|
|
|
function paint(): void {
|
|
segs.forEach((seg, i) => {
|
|
const color = colorOf(state.colors[i]);
|
|
if (color) {
|
|
seg.style.setProperty("--ui-range-bar-color", color);
|
|
delete seg.dataset.empty;
|
|
} else {
|
|
seg.style.removeProperty("--ui-range-bar-color");
|
|
seg.dataset.empty = "";
|
|
}
|
|
seg.setAttribute("aria-pressed", String(i === current));
|
|
});
|
|
const picked = state.colors[current];
|
|
for (const b of swatches) b.setAttribute("aria-pressed", String(b.dataset.value === picked));
|
|
layout();
|
|
}
|
|
|
|
const endText = (text: string): HTMLSpanElement => {
|
|
const span = document.createElement("span");
|
|
span.className = "ui-range-bar__end";
|
|
span.textContent = text;
|
|
return span;
|
|
};
|
|
|
|
function render(): void {
|
|
const e = state.edges;
|
|
segs = [];
|
|
inputs = [];
|
|
track.replaceChildren(endText(opts.ends?.[0] ?? `${e[0]}${unit}`));
|
|
state.colors.forEach((_, i) => {
|
|
const seg = document.createElement("button");
|
|
seg.type = "button";
|
|
seg.className = "ui-range-bar__seg";
|
|
seg.dataset.index = String(i);
|
|
seg.addEventListener("click", () => {
|
|
current = i;
|
|
paint();
|
|
opts.onSelect?.(i);
|
|
});
|
|
segs.push(seg);
|
|
track.append(seg);
|
|
if (i === state.colors.length - 1) return;
|
|
const field = createInputField({
|
|
type: "number",
|
|
size: opts.size ?? 28,
|
|
value: String(e[i + 1]),
|
|
min: e[0],
|
|
max: e.at(-1),
|
|
});
|
|
field.root.classList.add("ui-range-bar__edge");
|
|
field.input.dataset.edge = String(i + 1);
|
|
const hint = document.createElement("span");
|
|
hint.className = "ui-range-bar__hint";
|
|
const showHint = (): void => {
|
|
const v = state.edges[i + 1];
|
|
hint.textContent = opts.hint && Number.isFinite(v) ? opts.hint(v) : "";
|
|
};
|
|
field.input.addEventListener("input", () => {
|
|
const raw = field.input.value.trim();
|
|
state.edges[i + 1] = raw === "" ? NaN : Number(raw);
|
|
showHint();
|
|
layout();
|
|
changed();
|
|
});
|
|
showHint();
|
|
if (opts.hint) field.root.append(hint);
|
|
inputs.push(field.input);
|
|
track.append(field.root);
|
|
});
|
|
track.append(endText(opts.ends?.[1] ?? `${e.at(-1)}${unit}`));
|
|
paint();
|
|
}
|
|
|
|
const set = (next: RangeBarState, selected = current): void => {
|
|
state = { edges: [...next.edges], colors: [...next.colors] };
|
|
current = Math.max(0, Math.min(selected, state.colors.length - 1));
|
|
render();
|
|
};
|
|
|
|
const setError = (edge?: number): void => {
|
|
inputs.forEach((input, k) => {
|
|
const bad = k + 1 === edge;
|
|
input.classList.toggle("ui-input--error", bad);
|
|
if (bad) input.setAttribute("aria-invalid", "true");
|
|
else input.removeAttribute("aria-invalid");
|
|
});
|
|
};
|
|
|
|
render();
|
|
return { root, get, set, selected: () => current, setError };
|
|
}
|