@cfasim-ui/components 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Box/Box.d.ts +7 -11
- package/dist/Button/Button.d.ts +7 -11
- package/dist/ButtonGroup/ButtonGroup.d.ts +8 -12
- package/dist/Container/Container.d.ts +7 -11
- package/dist/Expander/Expander.d.ts +12 -14
- package/dist/Grid/Grid.d.ts +6 -5
- package/dist/Hint/Hint.d.ts +2 -1
- package/dist/Icon/Icon.d.ts +3 -2
- package/dist/LightDarkToggle/LightDarkToggle.d.ts +2 -1
- package/dist/MultiSelect/MultiSelect.d.ts +6 -6
- package/dist/NumberInput/NumberInput.d.ts +13 -11
- package/dist/ParamEditor/ParamEditor.d.ts +3 -2
- package/dist/ParamEditor/ParamEditorImpl.d.ts +3 -5
- package/dist/{ParamEditorImpl-D0xZTyAN.js → ParamEditorImpl-CkElC6PI.js} +3075 -3041
- package/dist/SelectBox/SelectBox.d.ts +8 -6
- package/dist/{SelectBox-CByXZfaC.js → SelectBox-Df8dE2r-.js} +6 -6
- package/dist/SidebarLayout/SidebarLayout.d.ts +21 -20
- package/dist/Spinner/Spinner.d.ts +3 -2
- package/dist/TextInput/TextInput.d.ts +7 -5
- package/dist/Toggle/Toggle.d.ts +8 -6
- package/dist/ToggleGroup/ToggleGroup.d.ts +8 -6
- package/dist/_internal/FieldLabel.d.ts +2 -1
- package/dist/index.js +20 -20
- package/docs/Box.md +49 -0
- package/docs/Button.md +67 -0
- package/docs/ButtonGroup.md +64 -0
- package/docs/Container.md +103 -0
- package/docs/Expander.md +34 -0
- package/docs/Grid.md +170 -0
- package/docs/Hint.md +29 -0
- package/docs/Icon.md +188 -0
- package/docs/MultiSelect.md +143 -0
- package/docs/NumberInput.md +485 -0
- package/docs/ParamEditor.md +97 -0
- package/docs/SelectBox.md +182 -0
- package/docs/SidebarLayout.md +106 -0
- package/docs/Spinner.md +51 -0
- package/docs/TextInput.md +83 -0
- package/docs/Toggle.md +81 -0
- package/docs/ToggleGroup.md +163 -0
- package/docs/index.json +194 -0
- package/package.json +24 -20
- package/src/Box/Box.md +41 -0
- package/src/Box/Box.vue +52 -0
- package/src/Button/Button.md +59 -0
- package/src/Button/Button.vue +81 -0
- package/src/ButtonGroup/ButtonGroup.md +62 -0
- package/src/ButtonGroup/ButtonGroup.vue +91 -0
- package/src/Container/Container.md +99 -0
- package/src/Container/Container.vue +62 -0
- package/src/Expander/Expander.md +23 -0
- package/src/Expander/Expander.vue +95 -0
- package/src/Grid/Grid.md +175 -0
- package/src/Grid/Grid.vue +145 -0
- package/src/Hint/Hint.md +24 -0
- package/src/Hint/Hint.vue +83 -0
- package/src/Icon/Icon.md +176 -0
- package/src/Icon/Icon.vue +104 -0
- package/src/Icon/defaultIcons.ts +92 -0
- package/src/Icon/github.svg +1 -0
- package/src/Icon/registry.ts +68 -0
- package/src/LightDarkToggle/LightDarkToggle.vue +49 -0
- package/src/MultiSelect/MultiSelect.md +142 -0
- package/src/MultiSelect/MultiSelect.vue +279 -0
- package/src/NumberInput/NumberInput.md +455 -0
- package/src/NumberInput/NumberInput.vue +575 -0
- package/src/ParamEditor/ParamEditor.md +87 -0
- package/src/ParamEditor/ParamEditor.vue +43 -0
- package/src/ParamEditor/ParamEditorImpl.vue +358 -0
- package/src/SelectBox/SelectBox.md +169 -0
- package/src/SelectBox/SelectBox.vue +260 -0
- package/src/SidebarLayout/SidebarLayout.md +106 -0
- package/src/SidebarLayout/SidebarLayout.vue +468 -0
- package/src/Spinner/Spinner.md +45 -0
- package/src/Spinner/Spinner.vue +55 -0
- package/src/TextInput/TextInput.md +68 -0
- package/src/TextInput/TextInput.vue +54 -0
- package/src/Toggle/Toggle.md +68 -0
- package/src/Toggle/Toggle.vue +81 -0
- package/src/ToggleGroup/ToggleGroup.md +158 -0
- package/src/ToggleGroup/ToggleGroup.vue +138 -0
- package/src/_internal/FieldLabel.vue +27 -0
- package/src/_internal/field.ts +27 -0
- package/src/_internal/gap.ts +17 -0
- package/src/_internal/input.css +54 -0
- package/src/_internal/listbox.css +53 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +33 -0
- package/src/svg.d.ts +5 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, computed, onMounted, getCurrentInstance } from "vue";
|
|
3
|
+
import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
|
|
4
|
+
import { formatNumber, type NumberFormat } from "@cfasim-ui/shared";
|
|
5
|
+
import Hint from "../Hint/Hint.vue";
|
|
6
|
+
import type { FieldProps } from "../_internal/field";
|
|
7
|
+
import "../_internal/input.css";
|
|
8
|
+
|
|
9
|
+
export type NumberRange = [number, number];
|
|
10
|
+
|
|
11
|
+
// The default `v-model` is always a scalar number. For range mode, bind
|
|
12
|
+
// `v-model:range` (a tuple) and/or the split `v-model:lower`/`v-model:upper`.
|
|
13
|
+
// Mode is auto-detected from which v-model bindings the parent provides;
|
|
14
|
+
// no explicit toggle prop. Precedence on read: lower/upper > range >
|
|
15
|
+
// slider defaults.
|
|
16
|
+
const model = defineModel<number>();
|
|
17
|
+
const range = defineModel<NumberRange>("range");
|
|
18
|
+
const lower = defineModel<number>("lower");
|
|
19
|
+
const upper = defineModel<number>("upper");
|
|
20
|
+
|
|
21
|
+
interface Props extends FieldProps {
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
step?: number;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
percent?: boolean;
|
|
27
|
+
slider?: boolean;
|
|
28
|
+
live?: boolean;
|
|
29
|
+
numberType?: "integer" | "float";
|
|
30
|
+
required?: boolean;
|
|
31
|
+
decimals?: number;
|
|
32
|
+
// Custom formatter for the displayed value. Accepts a NumberFormat
|
|
33
|
+
// (preset name, printf string, or function) — see `formatNumber` in
|
|
34
|
+
// `@cfasim-ui/shared`. When set, overrides the default percent/decimal
|
|
35
|
+
// formatting for both the text input value and slider thumb/min/max
|
|
36
|
+
// labels. The model stays a raw number; only the display changes. Note
|
|
37
|
+
// that formats which add suffixes or scale the value (e.g.
|
|
38
|
+
// `"percent:1"`) may not round-trip through the text input — use
|
|
39
|
+
// `percent: true` for value scaling and `format` for display shaping.
|
|
40
|
+
format?: NumberFormat;
|
|
41
|
+
/** @deprecated Use `format` instead. Still honored for slider labels
|
|
42
|
+
* when `format` is unset, but will be removed in a future release. */
|
|
43
|
+
sliderDisplay?: (value: number) => string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = defineProps<Props>();
|
|
47
|
+
|
|
48
|
+
function isRangeValue(v: unknown): v is NumberRange {
|
|
49
|
+
return Array.isArray(v) && v.length === 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Auto-detect range mode from the parent's v-model bindings. We check both
|
|
53
|
+
// the listener (which Vue attaches as `onUpdate:<name>` on the vnode) and
|
|
54
|
+
// the initial value — that way one-way `:range="x"` bindings also work.
|
|
55
|
+
// Determined once at setup; mode doesn't change for the component's life.
|
|
56
|
+
const instance = getCurrentInstance();
|
|
57
|
+
const vnodeProps = instance?.vnode.props;
|
|
58
|
+
const isRange =
|
|
59
|
+
!!vnodeProps?.["onUpdate:range"] ||
|
|
60
|
+
!!vnodeProps?.["onUpdate:lower"] ||
|
|
61
|
+
!!vnodeProps?.["onUpdate:upper"] ||
|
|
62
|
+
range.value !== undefined ||
|
|
63
|
+
lower.value !== undefined ||
|
|
64
|
+
upper.value !== undefined;
|
|
65
|
+
|
|
66
|
+
// Range implies slider — a two-handle range has no sensible text-input form.
|
|
67
|
+
const isSlider = computed(() => !!props.slider || isRange);
|
|
68
|
+
|
|
69
|
+
// Warn if the parent bound the default `v-model` in range mode — it will
|
|
70
|
+
// never receive updates, which is almost always a bug.
|
|
71
|
+
onMounted(() => {
|
|
72
|
+
if (isRange && !!vnodeProps?.["onUpdate:modelValue"]) {
|
|
73
|
+
console.warn(
|
|
74
|
+
"[NumberInput] In range mode, the default `v-model` is unused. Bind `v-model:range` or `v-model:lower`/`v-model:upper` instead.",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const sliderMin = computed(() => props.min ?? 0);
|
|
80
|
+
const sliderMax = computed(() => props.max ?? (props.percent ? 1 : 100));
|
|
81
|
+
const sliderStep = computed(() => props.step ?? (props.percent ? 0.01 : 1));
|
|
82
|
+
|
|
83
|
+
// Count fractional digits in a finite number's decimal representation.
|
|
84
|
+
// Uses toPrecision + parseFloat to mask float-multiplication artifacts
|
|
85
|
+
// (e.g. 0.007 * 100 === 0.7000000000000001 would otherwise return 16).
|
|
86
|
+
function countDecimals(n: number): number {
|
|
87
|
+
if (!Number.isFinite(n) || Number.isInteger(n)) return 0;
|
|
88
|
+
const s = parseFloat(Math.abs(n).toPrecision(12)).toString();
|
|
89
|
+
const dot = s.indexOf(".");
|
|
90
|
+
if (dot !== -1) return s.length - dot - 1;
|
|
91
|
+
const eNeg = s.indexOf("e-");
|
|
92
|
+
if (eNeg !== -1) return Number(s.slice(eNeg + 2));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const inputStep = computed(() => {
|
|
97
|
+
if (props.step != null) return props.percent ? props.step * 100 : props.step;
|
|
98
|
+
return 1;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const inputMin = computed(() => {
|
|
102
|
+
if (props.min != null) return props.percent ? props.min * 100 : props.min;
|
|
103
|
+
return props.percent ? 0 : undefined;
|
|
104
|
+
});
|
|
105
|
+
const inputMax = computed(() => {
|
|
106
|
+
if (props.max != null) return props.percent ? props.max * 100 : props.max;
|
|
107
|
+
return props.percent ? 100 : undefined;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Display precision: explicit `decimals` wins; otherwise inferred from the
|
|
111
|
+
// input step (which is already in display units — see `inputStep`). Integer
|
|
112
|
+
// mode always collapses to 0.
|
|
113
|
+
const displayDecimals = computed(() => {
|
|
114
|
+
if (props.numberType === "integer") return 0;
|
|
115
|
+
if (props.decimals != null) return Math.max(0, props.decimals);
|
|
116
|
+
return countDecimals(inputStep.value);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function roundToDecimals(v: number, d: number): number {
|
|
120
|
+
const factor = Math.pow(10, d);
|
|
121
|
+
return Math.round(v * factor) / factor;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatSliderValue(v: number | undefined) {
|
|
125
|
+
if (v == null) return "";
|
|
126
|
+
// sliderDisplay (deprecated) is a function — i.e. already a valid
|
|
127
|
+
// NumberFormat — so route it through formatNumber. `format` wins when
|
|
128
|
+
// both are set.
|
|
129
|
+
const fmt = props.format ?? props.sliderDisplay;
|
|
130
|
+
if (fmt !== undefined) return formatNumber(v, fmt);
|
|
131
|
+
const d = displayDecimals.value;
|
|
132
|
+
if (props.percent) return (v * 100).toFixed(d) + "%";
|
|
133
|
+
return v.toLocaleString("en-US", {
|
|
134
|
+
minimumFractionDigits: d,
|
|
135
|
+
maximumFractionDigits: d,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toDisplay(v: number | undefined) {
|
|
140
|
+
if (v == null) return v;
|
|
141
|
+
if (!props.percent) return v;
|
|
142
|
+
// Round in display units to hide float-multiplication artifacts like
|
|
143
|
+
// 0.1 * 100 === 10.000000000000002. Precision follows displayDecimals.
|
|
144
|
+
return roundToDecimals(v * 100, displayDecimals.value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function fromDisplay(v: number) {
|
|
148
|
+
return props.percent ? v / 100 : v;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function coerceInteger(v: number): number {
|
|
152
|
+
if (props.numberType !== "integer") return v;
|
|
153
|
+
// Truncate the display value to an integer, then convert back
|
|
154
|
+
const display = toDisplay(v);
|
|
155
|
+
if (display == null) return v;
|
|
156
|
+
return fromDisplay(Math.trunc(display));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatWithCommas(v: number | undefined): string {
|
|
160
|
+
if (v == null) return "";
|
|
161
|
+
return v.toLocaleString("en-US");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function formatForDisplay(v: number | undefined): string {
|
|
165
|
+
if (v == null) return "";
|
|
166
|
+
if (props.format !== undefined) return formatNumber(v, props.format);
|
|
167
|
+
const d = displayDecimals.value;
|
|
168
|
+
if (d > 0) {
|
|
169
|
+
return v.toLocaleString("en-US", {
|
|
170
|
+
minimumFractionDigits: d,
|
|
171
|
+
maximumFractionDigits: d,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const s = formatWithCommas(v);
|
|
175
|
+
if (props.numberType === "float" && Number.isInteger(v)) {
|
|
176
|
+
return s + ".0";
|
|
177
|
+
}
|
|
178
|
+
return s;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stripCommas(s: string): string {
|
|
182
|
+
return s.replace(/,/g, "");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Resolve the current value across all bindings:
|
|
186
|
+
// - In range mode: lower/upper take precedence; falls back per-side to
|
|
187
|
+
// `range`; finally to slider min/max. The default `v-model` is
|
|
188
|
+
// unused in this mode.
|
|
189
|
+
// - In single mode: just the default `v-model`.
|
|
190
|
+
function effectiveValue(): number | NumberRange | undefined {
|
|
191
|
+
if (isRange) {
|
|
192
|
+
const tuple = range.value;
|
|
193
|
+
const lo = lower.value ?? tuple?.[0];
|
|
194
|
+
const hi = upper.value ?? tuple?.[1];
|
|
195
|
+
if (lo !== undefined || hi !== undefined) {
|
|
196
|
+
return [lo ?? sliderMin.value, hi ?? sliderMax.value];
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
return model.value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Initial single-value display string. The text input isn't rendered in
|
|
204
|
+
// range mode, so `local` is only consulted in single mode.
|
|
205
|
+
const initialEffective = effectiveValue();
|
|
206
|
+
const initialSingle =
|
|
207
|
+
typeof initialEffective === "number" ? initialEffective : undefined;
|
|
208
|
+
const local = ref(formatForDisplay(toDisplay(initialSingle)));
|
|
209
|
+
|
|
210
|
+
// Slider state is always an array, even in single mode (reka-ui's API).
|
|
211
|
+
// In range mode it holds [low, high]; in single mode it holds [value].
|
|
212
|
+
function modelToSliderArray(v: number | NumberRange | undefined): number[] {
|
|
213
|
+
if (isRange) {
|
|
214
|
+
if (isRangeValue(v)) return [v[0], v[1]];
|
|
215
|
+
return [sliderMin.value, sliderMax.value];
|
|
216
|
+
}
|
|
217
|
+
if (typeof v === "number") return [v];
|
|
218
|
+
return [sliderMin.value];
|
|
219
|
+
}
|
|
220
|
+
const sliderArrayLocal = ref<number[]>(modelToSliderArray(initialEffective));
|
|
221
|
+
const validationError = ref<string>();
|
|
222
|
+
|
|
223
|
+
watch([model, range, lower, upper], () => {
|
|
224
|
+
const v = effectiveValue();
|
|
225
|
+
if (!isRange && !isRangeValue(v)) {
|
|
226
|
+
local.value = formatForDisplay(toDisplay(v as number | undefined));
|
|
227
|
+
}
|
|
228
|
+
sliderArrayLocal.value = modelToSliderArray(v);
|
|
229
|
+
validationError.value = validate(v);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Characters that can appear in a valid JS number literal: digits, thousands
|
|
233
|
+
// separators (commas), decimal point, sign, and scientific-notation exponent.
|
|
234
|
+
// Anything else is stripped when the value is committed.
|
|
235
|
+
const INVALID_NUMBER_CHARS = /[^0-9,.\-+eE]/g;
|
|
236
|
+
|
|
237
|
+
function reformatInput(event: Event) {
|
|
238
|
+
const input = event.target as HTMLInputElement;
|
|
239
|
+
const raw = stripCommas(input.value);
|
|
240
|
+
if (raw === "" || raw === "-") return;
|
|
241
|
+
if (raw.endsWith(".") || (raw.includes(".") && raw.endsWith("0"))) return;
|
|
242
|
+
const parsed = Number(raw);
|
|
243
|
+
if (Number.isNaN(parsed)) return;
|
|
244
|
+
|
|
245
|
+
const formatted = formatWithCommas(parsed);
|
|
246
|
+
if (formatted === input.value) return;
|
|
247
|
+
|
|
248
|
+
const cursorPos = input.selectionStart ?? 0;
|
|
249
|
+
const commasBefore = (input.value.slice(0, cursorPos).match(/,/g) || [])
|
|
250
|
+
.length;
|
|
251
|
+
local.value = formatted;
|
|
252
|
+
|
|
253
|
+
requestAnimationFrame(() => {
|
|
254
|
+
const rawPos = cursorPos - commasBefore;
|
|
255
|
+
let newPos = 0;
|
|
256
|
+
let rawCount = 0;
|
|
257
|
+
for (let i = 0; i < formatted.length; i++) {
|
|
258
|
+
if (formatted[i] !== ",") rawCount++;
|
|
259
|
+
if (rawCount >= rawPos) {
|
|
260
|
+
newPos = i + 1;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (rawCount < rawPos) newPos = formatted.length;
|
|
265
|
+
input.setSelectionRange(newPos, newPos);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function onBlur() {
|
|
270
|
+
commit();
|
|
271
|
+
if (local.value.trim() === "") return;
|
|
272
|
+
const parsed = Number(stripCommas(local.value));
|
|
273
|
+
if (!Number.isNaN(parsed)) {
|
|
274
|
+
local.value = formatForDisplay(parsed);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let liveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
279
|
+
function onInputEvent() {
|
|
280
|
+
if (!props.live || isSlider.value) return;
|
|
281
|
+
if (liveTimeout) clearTimeout(liveTimeout);
|
|
282
|
+
liveTimeout = setTimeout(commit, 300);
|
|
283
|
+
}
|
|
284
|
+
function onChangeEvent() {
|
|
285
|
+
if (!props.live || isSlider.value) return;
|
|
286
|
+
if (liveTimeout) clearTimeout(liveTimeout);
|
|
287
|
+
commit();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function validateScalar(v: number): string | undefined {
|
|
291
|
+
const display = toDisplay(v) as number;
|
|
292
|
+
if (inputMin.value != null && display < inputMin.value) {
|
|
293
|
+
return `Min ${inputMin.value}${props.percent ? "%" : ""}`;
|
|
294
|
+
}
|
|
295
|
+
if (inputMax.value != null && display > inputMax.value) {
|
|
296
|
+
return `Max ${inputMax.value}${props.percent ? "%" : ""}`;
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Single source of truth for required / min / max errors — used on commit,
|
|
302
|
+
// programmatic updates, and arrow-key stepping. In range mode, returns the
|
|
303
|
+
// first failing handle's error, suffixed with "(lower)" or "(upper)".
|
|
304
|
+
function validate(v: number | NumberRange | undefined): string | undefined {
|
|
305
|
+
if (v == null) return props.required ? "Required" : undefined;
|
|
306
|
+
if (isRangeValue(v)) {
|
|
307
|
+
const lo = validateScalar(v[0]);
|
|
308
|
+
if (lo) return `${lo} (lower)`;
|
|
309
|
+
const hi = validateScalar(v[1]);
|
|
310
|
+
if (hi) return `${hi} (upper)`;
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
return validateScalar(v);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function commit() {
|
|
317
|
+
// commit() is only reachable when !isSlider (only text-input events call
|
|
318
|
+
// it). Default `v-model` is scalar-only.
|
|
319
|
+
const current = model.value;
|
|
320
|
+
if (local.value.trim() === "") {
|
|
321
|
+
model.value = undefined;
|
|
322
|
+
sliderArrayLocal.value = modelToSliderArray(undefined);
|
|
323
|
+
validationError.value = validate(undefined);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Strip any characters that can't be part of a valid number literal.
|
|
327
|
+
// People are free to type anything while editing; we clean it up on commit.
|
|
328
|
+
const cleaned = local.value.replace(INVALID_NUMBER_CHARS, "");
|
|
329
|
+
// Require at least one digit. Otherwise Number("") === 0 would silently
|
|
330
|
+
// turn pure garbage ("abc") into 0. Reset to the current model value so
|
|
331
|
+
// invalid input doesn't linger in the field.
|
|
332
|
+
if (!/\d/.test(cleaned)) {
|
|
333
|
+
local.value = formatForDisplay(toDisplay(current));
|
|
334
|
+
validationError.value = validate(current);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (cleaned !== local.value) {
|
|
338
|
+
local.value = cleaned;
|
|
339
|
+
}
|
|
340
|
+
let parsed = Number(stripCommas(cleaned));
|
|
341
|
+
if (Number.isNaN(parsed)) return;
|
|
342
|
+
|
|
343
|
+
if (props.numberType === "integer") {
|
|
344
|
+
parsed = Math.trunc(parsed);
|
|
345
|
+
local.value = formatForDisplay(parsed);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const next = fromDisplay(parsed);
|
|
349
|
+
const error = validate(next);
|
|
350
|
+
validationError.value = error;
|
|
351
|
+
if (error) return;
|
|
352
|
+
|
|
353
|
+
model.value = next;
|
|
354
|
+
sliderArrayLocal.value = [next];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function commitSliderArray(v: number[], asModel: boolean): void {
|
|
358
|
+
const coerced = v.map(coerceInteger);
|
|
359
|
+
sliderArrayLocal.value = coerced;
|
|
360
|
+
if (!isRange) {
|
|
361
|
+
local.value = formatForDisplay(toDisplay(coerced[0]));
|
|
362
|
+
}
|
|
363
|
+
if (asModel) {
|
|
364
|
+
if (isRange) {
|
|
365
|
+
// Emit to all range sinks; consumers without a matching v-model just
|
|
366
|
+
// ignore their `update:*` event. The default `v-model` is unused in
|
|
367
|
+
// range mode.
|
|
368
|
+
range.value = [coerced[0], coerced[1]] as NumberRange;
|
|
369
|
+
lower.value = coerced[0];
|
|
370
|
+
upper.value = coerced[1];
|
|
371
|
+
} else {
|
|
372
|
+
model.value = coerced[0];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function thumbAriaLabel(i: number): string | undefined {
|
|
378
|
+
if (!props.label) return undefined;
|
|
379
|
+
if (!isRange) return props.label;
|
|
380
|
+
return i === 0 ? `${props.label} (lower)` : `${props.label} (upper)`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function onSliderUpdate(v: number[] | undefined) {
|
|
384
|
+
if (!v) return;
|
|
385
|
+
commitSliderArray(v, !!props.live);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function onSliderCommit(v: number[] | undefined) {
|
|
389
|
+
if (!v) return;
|
|
390
|
+
commitSliderArray(v, true);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
const parsed = Number(stripCommas(local.value));
|
|
396
|
+
const current = Number.isNaN(parsed) ? 0 : parsed;
|
|
397
|
+
const step = inputStep.value * (event.shiftKey ? 10 : 1);
|
|
398
|
+
let next = current + step * direction;
|
|
399
|
+
if (props.numberType === "integer") next = Math.trunc(next);
|
|
400
|
+
if (inputMin.value != null) next = Math.max(next, inputMin.value);
|
|
401
|
+
if (inputMax.value != null) next = Math.min(next, inputMax.value);
|
|
402
|
+
local.value = formatForDisplay(next);
|
|
403
|
+
const nextModel = fromDisplay(next);
|
|
404
|
+
model.value = nextModel;
|
|
405
|
+
sliderArrayLocal.value = [nextModel];
|
|
406
|
+
}
|
|
407
|
+
</script>
|
|
408
|
+
|
|
409
|
+
<template>
|
|
410
|
+
<component
|
|
411
|
+
:is="props.label ? 'label' : 'div'"
|
|
412
|
+
:class="props.label ? 'cfasim-input-label' : undefined"
|
|
413
|
+
>
|
|
414
|
+
<span
|
|
415
|
+
v-if="props.label"
|
|
416
|
+
class="cfasim-input-label-row"
|
|
417
|
+
:class="{ 'visually-hidden': props.hideLabel }"
|
|
418
|
+
>
|
|
419
|
+
{{ props.label }}
|
|
420
|
+
<Hint v-if="props.hint && !props.hideLabel" :text="props.hint" />
|
|
421
|
+
</span>
|
|
422
|
+
<span v-if="!isSlider" class="input-wrapper">
|
|
423
|
+
<input
|
|
424
|
+
type="text"
|
|
425
|
+
class="cfasim-input"
|
|
426
|
+
:inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
|
|
427
|
+
v-model="local"
|
|
428
|
+
:placeholder="props.placeholder"
|
|
429
|
+
:aria-label="!props.label ? props.ariaLabel : undefined"
|
|
430
|
+
:aria-invalid="!!validationError"
|
|
431
|
+
:aria-required="props.required || undefined"
|
|
432
|
+
:required="props.required"
|
|
433
|
+
@blur="onBlur"
|
|
434
|
+
@keydown.enter="commit"
|
|
435
|
+
@keydown.up="onArrowStep($event, 1)"
|
|
436
|
+
@keydown.down="onArrowStep($event, -1)"
|
|
437
|
+
@input="
|
|
438
|
+
reformatInput($event);
|
|
439
|
+
onInputEvent();
|
|
440
|
+
"
|
|
441
|
+
@change="onChangeEvent"
|
|
442
|
+
/>
|
|
443
|
+
<span v-if="props.percent" class="input-suffix">%</span>
|
|
444
|
+
</span>
|
|
445
|
+
<span v-if="validationError" class="input-error" role="alert">
|
|
446
|
+
{{ validationError }}
|
|
447
|
+
</span>
|
|
448
|
+
<div v-if="isSlider" class="slider-container">
|
|
449
|
+
<SliderRoot
|
|
450
|
+
class="slider-root"
|
|
451
|
+
:model-value="sliderArrayLocal"
|
|
452
|
+
:min="sliderMin"
|
|
453
|
+
:max="sliderMax"
|
|
454
|
+
:step="sliderStep"
|
|
455
|
+
@update:model-value="onSliderUpdate"
|
|
456
|
+
@value-commit="onSliderCommit"
|
|
457
|
+
>
|
|
458
|
+
<SliderTrack class="slider-track">
|
|
459
|
+
<SliderRange class="slider-range" />
|
|
460
|
+
</SliderTrack>
|
|
461
|
+
<SliderThumb
|
|
462
|
+
v-for="(v, i) in sliderArrayLocal"
|
|
463
|
+
:key="i"
|
|
464
|
+
class="slider-thumb"
|
|
465
|
+
:aria-label="thumbAriaLabel(i)"
|
|
466
|
+
>
|
|
467
|
+
<span class="slider-current">
|
|
468
|
+
{{ formatSliderValue(v) }}
|
|
469
|
+
</span>
|
|
470
|
+
</SliderThumb>
|
|
471
|
+
</SliderRoot>
|
|
472
|
+
<div class="slider-labels">
|
|
473
|
+
<span>{{ formatSliderValue(sliderMin) }}</span>
|
|
474
|
+
<span>{{ formatSliderValue(sliderMax) }}</span>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
</component>
|
|
478
|
+
</template>
|
|
479
|
+
|
|
480
|
+
<style scoped>
|
|
481
|
+
.input-wrapper {
|
|
482
|
+
display: flex;
|
|
483
|
+
align-items: center;
|
|
484
|
+
gap: 0.25em;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.input-wrapper .cfasim-input {
|
|
488
|
+
flex: 1;
|
|
489
|
+
min-width: 0;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.input-suffix {
|
|
493
|
+
color: var(--color-text-secondary);
|
|
494
|
+
font-size: var(--font-size-sm);
|
|
495
|
+
flex-shrink: 0;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.input-error {
|
|
499
|
+
color: var(--color-error);
|
|
500
|
+
font-size: var(--font-size-xs);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.slider-container {
|
|
504
|
+
display: flex;
|
|
505
|
+
flex-direction: column;
|
|
506
|
+
gap: 0.25em;
|
|
507
|
+
padding-top: 1.5em;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.slider-current {
|
|
511
|
+
position: absolute;
|
|
512
|
+
bottom: 100%;
|
|
513
|
+
left: 50%;
|
|
514
|
+
transform: translateX(-50%);
|
|
515
|
+
margin-bottom: 1px;
|
|
516
|
+
font-size: var(--font-size-xs);
|
|
517
|
+
color: var(--color-text-secondary);
|
|
518
|
+
white-space: nowrap;
|
|
519
|
+
pointer-events: none;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.slider-root {
|
|
523
|
+
position: relative;
|
|
524
|
+
display: flex;
|
|
525
|
+
align-items: center;
|
|
526
|
+
width: 100%;
|
|
527
|
+
height: 1.5em;
|
|
528
|
+
touch-action: none;
|
|
529
|
+
user-select: none;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.slider-track {
|
|
533
|
+
position: relative;
|
|
534
|
+
flex-grow: 1;
|
|
535
|
+
height: 3px;
|
|
536
|
+
background-color: var(--color-bg-3);
|
|
537
|
+
border-radius: var(--radius-full);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.slider-range {
|
|
541
|
+
position: absolute;
|
|
542
|
+
height: 100%;
|
|
543
|
+
background-color: var(--color-primary);
|
|
544
|
+
border-radius: var(--radius-full);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.slider-thumb {
|
|
548
|
+
position: relative;
|
|
549
|
+
display: block;
|
|
550
|
+
width: 1em;
|
|
551
|
+
height: 1em;
|
|
552
|
+
background-color: var(--color-primary);
|
|
553
|
+
border-radius: var(--radius-full);
|
|
554
|
+
cursor: pointer;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.slider-thumb:hover {
|
|
558
|
+
background-color: var(--color-primary-hover);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.slider-thumb:active,
|
|
562
|
+
.slider-thumb:focus-visible {
|
|
563
|
+
outline: none;
|
|
564
|
+
box-shadow: 0 0 0 4px
|
|
565
|
+
color-mix(in srgb, var(--color-primary) 25%, transparent);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.slider-labels {
|
|
569
|
+
display: flex;
|
|
570
|
+
justify-content: space-between;
|
|
571
|
+
font-size: var(--font-size-xs);
|
|
572
|
+
color: var(--color-text-secondary);
|
|
573
|
+
margin-top: -0.5em;
|
|
574
|
+
}
|
|
575
|
+
</style>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
keywords:
|
|
3
|
+
[parameters, editor, code, json, toml, yaml, codemirror, import, export]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ParamEditor
|
|
7
|
+
|
|
8
|
+
A code editor for editing simulation parameters as JSON, TOML, or YAML. Backed by CodeMirror 6 with syntax highlighting. Includes Import, Export, and Apply actions. Apply can also be triggered with `Cmd`/`Ctrl`+`S`, and the editor auto-applies on blur whenever the text parses cleanly and the parsed value differs from the current `value`.
|
|
9
|
+
|
|
10
|
+
Exported files default to `params-YYYYMMDD-HHMMSS.<ext>` (local-time timestamp captured when Export is clicked). Override the basename with the `filename` prop.
|
|
11
|
+
|
|
12
|
+
The editor is **lazy-loaded**: the underlying CodeMirror bundle and YAML/TOML parsers are only fetched the first time `<ParamEditor>` mounts, so consumers that never open the editor never pay for it.
|
|
13
|
+
|
|
14
|
+
Users can switch formats from the dropdown; the editor round-trips the current value through the new format. Apply emits an `apply` event with the parsed value — the parent decides what to do with it (typically merge back into its own parameter state).
|
|
15
|
+
|
|
16
|
+
Set the editor font size with the `fontSize` prop (any CSS length, e.g. `"16px"` or `"1rem"`); it defaults to `var(--font-size-sm)`.
|
|
17
|
+
|
|
18
|
+
## Examples
|
|
19
|
+
|
|
20
|
+
<script setup>
|
|
21
|
+
import { reactive } from 'vue'
|
|
22
|
+
const params = reactive({ beta: 0.5, gamma: 0.1, population: 10000 })
|
|
23
|
+
function onApply(v) { Object.assign(params, v) }
|
|
24
|
+
const yamlParams = reactive({ beta: 0.5, gamma: 0.1 })
|
|
25
|
+
function onYamlApply(v) { Object.assign(yamlParams, v) }
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
### Basic usage
|
|
29
|
+
|
|
30
|
+
<ComponentDemo>
|
|
31
|
+
<div style="width: 100%">
|
|
32
|
+
<ParamEditor :value="params" @apply="onApply" />
|
|
33
|
+
<p data-testid="basic-output">beta = {{ params.beta }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<template #code>
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<script setup>
|
|
40
|
+
import { reactive } from "vue";
|
|
41
|
+
const params = reactive({ beta: 0.5, gamma: 0.1, population: 10000 });
|
|
42
|
+
function onApply(v) {
|
|
43
|
+
Object.assign(params, v);
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<ParamEditor :value="params" @apply="onApply" />
|
|
49
|
+
</template>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
</template>
|
|
53
|
+
</ComponentDemo>
|
|
54
|
+
|
|
55
|
+
### Starting in a different format
|
|
56
|
+
|
|
57
|
+
<ComponentDemo>
|
|
58
|
+
<div style="width: 100%">
|
|
59
|
+
<ParamEditor :value="yamlParams" format="yaml" @apply="onYamlApply" />
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<template #code>
|
|
63
|
+
|
|
64
|
+
```vue
|
|
65
|
+
<ParamEditor :value="params" format="yaml" @apply="onApply" />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
</template>
|
|
69
|
+
</ComponentDemo>
|
|
70
|
+
|
|
71
|
+
### Custom font size
|
|
72
|
+
|
|
73
|
+
<ComponentDemo>
|
|
74
|
+
<div style="width: 100%">
|
|
75
|
+
<ParamEditor :value="params" font-size="16px" @apply="onApply" />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<template #code>
|
|
79
|
+
|
|
80
|
+
```vue
|
|
81
|
+
<ParamEditor :value="params" font-size="16px" @apply="onApply" />
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
</template>
|
|
85
|
+
</ComponentDemo>
|
|
86
|
+
|
|
87
|
+
<!--@include: ./_api/param-editor.md-->
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { defineAsyncComponent } from "vue";
|
|
3
|
+
import Spinner from "../Spinner/Spinner.vue";
|
|
4
|
+
|
|
5
|
+
export type ParamEditorFormat = "json" | "toml" | "yaml";
|
|
6
|
+
export type ParamEditorValue = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
value: ParamEditorValue;
|
|
10
|
+
format?: ParamEditorFormat;
|
|
11
|
+
height?: string;
|
|
12
|
+
/** Editor font size, as any CSS length (e.g. `"16px"`, `"1rem"`).
|
|
13
|
+
* Defaults to `var(--font-size-sm)`. */
|
|
14
|
+
fontSize?: string;
|
|
15
|
+
/** Basename for the Export download (no extension). Defaults to
|
|
16
|
+
* `params-YYYYMMDD-HHMMSS` computed at click time. */
|
|
17
|
+
filename?: string;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
defineEmits<{
|
|
21
|
+
apply: [value: ParamEditorValue];
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
// The actual editor (CodeMirror + YAML/TOML parsers) is loaded on demand
|
|
25
|
+
// the first time this component mounts, so consumers that never open the
|
|
26
|
+
// editor never pay for the ~200KB gzipped of editor + language code.
|
|
27
|
+
const Impl = defineAsyncComponent({
|
|
28
|
+
loader: () => import("./ParamEditorImpl.vue"),
|
|
29
|
+
loadingComponent: Spinner,
|
|
30
|
+
delay: 100,
|
|
31
|
+
});
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<Impl
|
|
36
|
+
:value="value"
|
|
37
|
+
:format="format"
|
|
38
|
+
:height="height"
|
|
39
|
+
:font-size="fontSize"
|
|
40
|
+
:filename="filename"
|
|
41
|
+
@apply="$emit('apply', $event)"
|
|
42
|
+
/>
|
|
43
|
+
</template>
|