Files
Aislo/ui_template/ui_template_range_bar.ts
T

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 };
}