@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.
Files changed (89) hide show
  1. package/dist/Box/Box.d.ts +7 -11
  2. package/dist/Button/Button.d.ts +7 -11
  3. package/dist/ButtonGroup/ButtonGroup.d.ts +8 -12
  4. package/dist/Container/Container.d.ts +7 -11
  5. package/dist/Expander/Expander.d.ts +12 -14
  6. package/dist/Grid/Grid.d.ts +6 -5
  7. package/dist/Hint/Hint.d.ts +2 -1
  8. package/dist/Icon/Icon.d.ts +3 -2
  9. package/dist/LightDarkToggle/LightDarkToggle.d.ts +2 -1
  10. package/dist/MultiSelect/MultiSelect.d.ts +6 -6
  11. package/dist/NumberInput/NumberInput.d.ts +13 -11
  12. package/dist/ParamEditor/ParamEditor.d.ts +3 -2
  13. package/dist/ParamEditor/ParamEditorImpl.d.ts +3 -5
  14. package/dist/{ParamEditorImpl-D0xZTyAN.js → ParamEditorImpl-CkElC6PI.js} +3075 -3041
  15. package/dist/SelectBox/SelectBox.d.ts +8 -6
  16. package/dist/{SelectBox-CByXZfaC.js → SelectBox-Df8dE2r-.js} +6 -6
  17. package/dist/SidebarLayout/SidebarLayout.d.ts +21 -20
  18. package/dist/Spinner/Spinner.d.ts +3 -2
  19. package/dist/TextInput/TextInput.d.ts +7 -5
  20. package/dist/Toggle/Toggle.d.ts +8 -6
  21. package/dist/ToggleGroup/ToggleGroup.d.ts +8 -6
  22. package/dist/_internal/FieldLabel.d.ts +2 -1
  23. package/dist/index.js +20 -20
  24. package/docs/Box.md +49 -0
  25. package/docs/Button.md +67 -0
  26. package/docs/ButtonGroup.md +64 -0
  27. package/docs/Container.md +103 -0
  28. package/docs/Expander.md +34 -0
  29. package/docs/Grid.md +170 -0
  30. package/docs/Hint.md +29 -0
  31. package/docs/Icon.md +188 -0
  32. package/docs/MultiSelect.md +143 -0
  33. package/docs/NumberInput.md +485 -0
  34. package/docs/ParamEditor.md +97 -0
  35. package/docs/SelectBox.md +182 -0
  36. package/docs/SidebarLayout.md +106 -0
  37. package/docs/Spinner.md +51 -0
  38. package/docs/TextInput.md +83 -0
  39. package/docs/Toggle.md +81 -0
  40. package/docs/ToggleGroup.md +163 -0
  41. package/docs/index.json +194 -0
  42. package/package.json +24 -20
  43. package/src/Box/Box.md +41 -0
  44. package/src/Box/Box.vue +52 -0
  45. package/src/Button/Button.md +59 -0
  46. package/src/Button/Button.vue +81 -0
  47. package/src/ButtonGroup/ButtonGroup.md +62 -0
  48. package/src/ButtonGroup/ButtonGroup.vue +91 -0
  49. package/src/Container/Container.md +99 -0
  50. package/src/Container/Container.vue +62 -0
  51. package/src/Expander/Expander.md +23 -0
  52. package/src/Expander/Expander.vue +95 -0
  53. package/src/Grid/Grid.md +175 -0
  54. package/src/Grid/Grid.vue +145 -0
  55. package/src/Hint/Hint.md +24 -0
  56. package/src/Hint/Hint.vue +83 -0
  57. package/src/Icon/Icon.md +176 -0
  58. package/src/Icon/Icon.vue +104 -0
  59. package/src/Icon/defaultIcons.ts +92 -0
  60. package/src/Icon/github.svg +1 -0
  61. package/src/Icon/registry.ts +68 -0
  62. package/src/LightDarkToggle/LightDarkToggle.vue +49 -0
  63. package/src/MultiSelect/MultiSelect.md +142 -0
  64. package/src/MultiSelect/MultiSelect.vue +279 -0
  65. package/src/NumberInput/NumberInput.md +455 -0
  66. package/src/NumberInput/NumberInput.vue +575 -0
  67. package/src/ParamEditor/ParamEditor.md +87 -0
  68. package/src/ParamEditor/ParamEditor.vue +43 -0
  69. package/src/ParamEditor/ParamEditorImpl.vue +358 -0
  70. package/src/SelectBox/SelectBox.md +169 -0
  71. package/src/SelectBox/SelectBox.vue +260 -0
  72. package/src/SidebarLayout/SidebarLayout.md +106 -0
  73. package/src/SidebarLayout/SidebarLayout.vue +468 -0
  74. package/src/Spinner/Spinner.md +45 -0
  75. package/src/Spinner/Spinner.vue +55 -0
  76. package/src/TextInput/TextInput.md +68 -0
  77. package/src/TextInput/TextInput.vue +54 -0
  78. package/src/Toggle/Toggle.md +68 -0
  79. package/src/Toggle/Toggle.vue +81 -0
  80. package/src/ToggleGroup/ToggleGroup.md +158 -0
  81. package/src/ToggleGroup/ToggleGroup.vue +138 -0
  82. package/src/_internal/FieldLabel.vue +27 -0
  83. package/src/_internal/field.ts +27 -0
  84. package/src/_internal/gap.ts +17 -0
  85. package/src/_internal/input.css +54 -0
  86. package/src/_internal/listbox.css +53 -0
  87. package/src/env.d.ts +4 -0
  88. package/src/index.ts +33 -0
  89. 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>