Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/* 부품 「범위 바」(631) — 칸 · 경계 입력 · 색 상자 · 단 값은 ui_template_button.css */
|
||||
.ui-range-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.ui-range-bar__track {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.ui-range-bar__end {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: var(--ui-size-h);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-range-bar__seg {
|
||||
flex: 1 1 0;
|
||||
min-width: 16px;
|
||||
height: var(--ui-size-h);
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--ui-range-bar-color, transparent);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
.ui-range-bar__seg[data-empty],
|
||||
.ui-range-bar__swatch[data-empty]::before {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--color-border-strong) 0 1px,
|
||||
transparent 1px 5px
|
||||
);
|
||||
}
|
||||
.ui-range-bar__seg:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.ui-range-bar__seg[aria-pressed="true"] {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.ui-range-bar__seg:is(:active, [data-state="active"]):not(:disabled) {
|
||||
box-shadow: inset 0 1px 2px var(--color-scrim);
|
||||
}
|
||||
.ui-range-bar__seg:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ui-range-bar__edge {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 4rem;
|
||||
gap: 2px;
|
||||
}
|
||||
.ui-range-bar__edge .ui-input {
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.ui-range-bar__hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-range-bar__colors {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.ui-range-bar__swatch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--text-caption);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-range-bar__swatch::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: var(--ui-range-bar-color);
|
||||
}
|
||||
.ui-range-bar__swatch[data-empty]::before {
|
||||
box-shadow: inset 0 0 0 1px var(--color-border-strong);
|
||||
}
|
||||
.ui-range-bar__swatch:is(:hover, [data-state="hover"]):not(:disabled) {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.ui-range-bar__swatch[aria-pressed="true"] {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* =============================================================================
|
||||
* 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 };
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { createEmpty } from "./ui_template_empty";
|
||||
import { createLegend } from "./ui_template_flow_legend";
|
||||
import { createKv } from "./ui_template_kv";
|
||||
import { createProgressBar, createProgressCircle } from "./ui_template_progress";
|
||||
import { createRangeBar } from "./ui_template_range_bar";
|
||||
import { createStat, statGrid } from "./ui_template_stat";
|
||||
import { toastNode } from "./ui_template_toast";
|
||||
import { FRAME_DEMOS } from "./ui_template_ui_kit_frames";
|
||||
@@ -347,6 +348,9 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) },
|
||||
{ key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts",
|
||||
demo: (s) => (s === "default" ? col(createLegend([{ label: "임도", color: "var(--color-chart-0)" }, { label: "설계선", color: "var(--color-chart-2)", kind: "line" }, { label: "원지반선", color: "var(--color-chart-1)", kind: "dash" }, { label: "관", color: "var(--color-chart-3)", kind: "dot" }, { label: "꺼진 층", color: "var(--color-chart-0)", off: true }], { onToggle: noop }), createLegend([{ label: "절토", color: "var(--color-danger)" }, { label: "성토", color: "var(--color-info)" }], { vertical: true })) : null) },
|
||||
{ key: "range-bar", name: "범위 바", group: "compound", kinds: "칸(넓이 = 경계 비례) · 칸 사이 경계값 하나 · 칸 눌러 고름 · 색 상자 · 칠 안 함 빗금 · 경계 오류 · 24 · 28", file: "ui_template_range_bar.ts",
|
||||
demo: (s) => { if (s !== "default" && s !== "error") return null; const bar = createRangeBar({ edges: [0, 26.8, 70, 83.9, 250], colors: ["", "c0", "c1", "c2"], palette: [0, 1, 2, 3].map((n) => ({ value: `c${n}`, name: `색 ${n + 1}`, color: `var(--color-chart-${n})` })), noneLabel: "칠 안 함", unit: "%", ends: ["0 %", "250 % 이상"], hint: (v) => `${Math.round(v)}`, onChange: noop });
|
||||
if (s === "error") bar.setError(2); return bar.root; } },
|
||||
{ key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 왼쪽 · 오른쪽 값 · 조작 · 줄 52 · 좁은 꼴(줄 28)", file: "ui_template_setting_row.ts",
|
||||
demo: (s) => (s === "default" ? col(settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null)), settingRow("기본 노폭", "새 노선에 먼저 들어감", createInputField({ placeholder: "3.0 m" }).root), settingRow("글꼴 파일", "", createButton({ label: "바꾸기", variant: "ghost", size: 28, onClick: noop }))]), settingCard([settingRow("측면 한계각(°)", "", createInputField({ placeholder: "30", size: 24 }).root, { compact: true }), settingRow("균열 모드", "", valueText("없음"), { compact: true })])) : null) },
|
||||
{ key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 받는 꼴 안내 · 진행 · 오류", file: "ui_template_drop.ts",
|
||||
|
||||
Reference in New Issue
Block a user