@cfasim-ui/docs 0.3.16 → 0.3.18

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.
@@ -12,6 +12,16 @@ const days = ref(10)
12
12
  const population = ref(100000)
13
13
  const coverage = ref(0.5)
14
14
  const r0 = ref(3.5)
15
+ const ageRange = ref([18, 65])
16
+ const coverageRange = ref([0.2, 0.8])
17
+ const minAge = ref(18)
18
+ const maxAge = ref(65)
19
+ const dayMs = 24 * 60 * 60 * 1000
20
+ const dateStart = Date.UTC(2024, 0, 1)
21
+ const dateEnd = Date.UTC(2024, 11, 31)
22
+ const dateRange = ref([Date.UTC(2024, 2, 1), Date.UTC(2024, 8, 30)])
23
+ const formatDate = (ms) =>
24
+ new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" })
15
25
  </script>
16
26
 
17
27
  <ComponentDemo>
@@ -116,6 +126,157 @@ const days = ref(10);
116
126
  </template>
117
127
  </ComponentDemo>
118
128
 
129
+ ### Range slider
130
+
131
+ Bind `v-model:range` with a `[low, high]` tuple to render a two-handle
132
+ slider. Range mode is enabled automatically by the binding — there's no
133
+ explicit toggle prop.
134
+
135
+ <ComponentDemo>
136
+ <div style="width: 300px">
137
+ <NumberInput
138
+ v-model:range="ageRange"
139
+ label="Age range"
140
+ :min="0"
141
+ :max="100"
142
+ number-type="integer"
143
+ />
144
+ </div>
145
+
146
+ <template #code>
147
+
148
+ ```vue
149
+ <script setup>
150
+ import { ref } from "vue";
151
+ const ageRange = ref([18, 65]);
152
+ </script>
153
+
154
+ <NumberInput
155
+ v-model:range="ageRange"
156
+ label="Age range"
157
+ :min="0"
158
+ :max="100"
159
+ number-type="integer"
160
+ />
161
+ ```
162
+
163
+ </template>
164
+ </ComponentDemo>
165
+
166
+ ### Range slider with split bindings
167
+
168
+ When your state stores the bounds in separate refs (rather than as a tuple),
169
+ bind them directly with `v-model:lower` and `v-model:upper`. You can bind
170
+ either pair or combine them with `v-model:range` — writes from the component
171
+ go to every bound sink.
172
+
173
+ <ComponentDemo>
174
+ <div style="width: 300px">
175
+ <NumberInput
176
+ v-model:lower="minAge"
177
+ v-model:upper="maxAge"
178
+ label="Age range (split)"
179
+ :min="0"
180
+ :max="100"
181
+ number-type="integer"
182
+ />
183
+ </div>
184
+
185
+ <template #code>
186
+
187
+ ```vue
188
+ <script setup>
189
+ import { ref } from "vue";
190
+ const minAge = ref(18);
191
+ const maxAge = ref(65);
192
+ </script>
193
+
194
+ <NumberInput
195
+ v-model:lower="minAge"
196
+ v-model:upper="maxAge"
197
+ label="Age range"
198
+ :min="0"
199
+ :max="100"
200
+ number-type="integer"
201
+ />
202
+ ```
203
+
204
+ </template>
205
+ </ComponentDemo>
206
+
207
+ Range mode works with `percent` and `live` as well:
208
+
209
+ <ComponentDemo>
210
+ <div style="width: 300px">
211
+ <NumberInput
212
+ v-model:range="coverageRange"
213
+ label="Coverage range"
214
+ percent
215
+ live
216
+ :max="1"
217
+ />
218
+ </div>
219
+
220
+ <template #code>
221
+
222
+ ```vue
223
+ <NumberInput
224
+ v-model:range="coverageRange"
225
+ label="Coverage range"
226
+ percent
227
+ live
228
+ :max="1"
229
+ />
230
+ ```
231
+
232
+ </template>
233
+ </ComponentDemo>
234
+
235
+ ### Custom slider display
236
+
237
+ Pass `slider-display` (a `(value: number) => string` function) to format the
238
+ thumb labels and the min/max labels however you like. The internal model is
239
+ still a number — only the displayed text changes. This applies to single
240
+ sliders and ranges; the regular text input is unaffected.
241
+
242
+ <ComponentDemo>
243
+ <div style="width: 300px">
244
+ <NumberInput
245
+ v-model:range="dateRange"
246
+ label="Date range"
247
+ :min="dateStart"
248
+ :max="dateEnd"
249
+ :step="dayMs"
250
+ :slider-display="formatDate"
251
+ />
252
+ </div>
253
+
254
+ <template #code>
255
+
256
+ ```vue
257
+ <script setup>
258
+ import { ref } from "vue";
259
+ const dayMs = 24 * 60 * 60 * 1000;
260
+ const dateStart = Date.UTC(2024, 0, 1);
261
+ const dateEnd = Date.UTC(2024, 11, 31);
262
+ const dateRange = ref([Date.UTC(2024, 2, 1), Date.UTC(2024, 8, 30)]);
263
+ const formatDate = (ms) =>
264
+ new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric" });
265
+ </script>
266
+
267
+ <NumberInput
268
+ v-model:range="dateRange"
269
+ label="Date range"
270
+ :min="dateStart"
271
+ :max="dateEnd"
272
+ :step="dayMs"
273
+ :slider-display="formatDate"
274
+ />
275
+ ```
276
+
277
+ </template>
278
+ </ComponentDemo>
279
+
119
280
  ### Live slider
120
281
 
121
282
  With `live`, the model updates while dragging the slider thumb rather than only on release.
@@ -284,6 +445,9 @@ the input visually.
284
445
  | Name | Type |
285
446
  |------|------|
286
447
  | `v-model` | `number` |
448
+ | `v-model:range` | `NumberRange` |
449
+ | `v-model:lower` | `number` |
450
+ | `v-model:upper` | `number` |
287
451
 
288
452
  ## Props
289
453
 
@@ -302,4 +466,5 @@ the input visually.
302
466
  | `numberType` | `"integer" \| "float"` | No | — |
303
467
  | `required` | `boolean` | No | — |
304
468
  | `decimals` | `number` | No | — |
469
+ | `sliderDisplay` | `(value: number) =&gt; string` | No | — |
305
470
 
@@ -1,9 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, computed } from "vue";
2
+ import { ref, watch, computed, onMounted, getCurrentInstance } from "vue";
3
3
  import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
4
4
  import Hint from "../Hint/Hint.vue";
5
5
 
6
+ export type NumberRange = [number, number];
7
+
8
+ // The default `v-model` is always a scalar number. For range mode, bind
9
+ // `v-model:range` (a tuple) and/or the split `v-model:lower`/`v-model:upper`.
10
+ // Mode is auto-detected from which v-model bindings the parent provides;
11
+ // no explicit toggle prop. Precedence on read: lower/upper > range >
12
+ // slider defaults.
6
13
  const model = defineModel<number>();
14
+ const range = defineModel<NumberRange>("range");
15
+ const lower = defineModel<number>("lower");
16
+ const upper = defineModel<number>("upper");
7
17
 
8
18
  const props = defineProps<{
9
19
  label?: string;
@@ -19,8 +29,43 @@ const props = defineProps<{
19
29
  numberType?: "integer" | "float";
20
30
  required?: boolean;
21
31
  decimals?: number;
32
+ // Custom formatter for slider thumb labels and min/max labels. Overrides
33
+ // the default percent/decimal formatting when provided. Only consulted in
34
+ // slider/range mode — the text input keeps its own number-shaped formatting.
35
+ sliderDisplay?: (value: number) => string;
22
36
  }>();
23
37
 
38
+ function isRangeValue(v: unknown): v is NumberRange {
39
+ return Array.isArray(v) && v.length === 2;
40
+ }
41
+
42
+ // Auto-detect range mode from the parent's v-model bindings. We check both
43
+ // the listener (which Vue attaches as `onUpdate:<name>` on the vnode) and
44
+ // the initial value — that way one-way `:range="x"` bindings also work.
45
+ // Determined once at setup; mode doesn't change for the component's life.
46
+ const instance = getCurrentInstance();
47
+ const vnodeProps = instance?.vnode.props;
48
+ const isRange =
49
+ !!vnodeProps?.["onUpdate:range"] ||
50
+ !!vnodeProps?.["onUpdate:lower"] ||
51
+ !!vnodeProps?.["onUpdate:upper"] ||
52
+ range.value !== undefined ||
53
+ lower.value !== undefined ||
54
+ upper.value !== undefined;
55
+
56
+ // Range implies slider — a two-handle range has no sensible text-input form.
57
+ const isSlider = computed(() => !!props.slider || isRange);
58
+
59
+ // Warn if the parent bound the default `v-model` in range mode — it will
60
+ // never receive updates, which is almost always a bug.
61
+ onMounted(() => {
62
+ if (isRange && !!vnodeProps?.["onUpdate:modelValue"]) {
63
+ console.warn(
64
+ "[NumberInput] In range mode, the default `v-model` is unused. Bind `v-model:range` or `v-model:lower`/`v-model:upper` instead.",
65
+ );
66
+ }
67
+ });
68
+
24
69
  const sliderMin = computed(() => props.min ?? (props.percent ? 0 : 0));
25
70
  const sliderMax = computed(() => props.max ?? (props.percent ? 1 : 100));
26
71
  const sliderStep = computed(() => props.step ?? (props.percent ? 0.01 : 1));
@@ -68,6 +113,7 @@ function roundToDecimals(v: number, d: number): number {
68
113
 
69
114
  function formatSliderValue(v: number | undefined) {
70
115
  if (v == null) return "";
116
+ if (props.sliderDisplay) return props.sliderDisplay(v);
71
117
  const d = displayDecimals.value;
72
118
  if (props.percent) return (v * 100).toFixed(d) + "%";
73
119
  return v.toLocaleString("en-US", {
@@ -121,13 +167,50 @@ function stripCommas(s: string): string {
121
167
  return s.replace(/,/g, "");
122
168
  }
123
169
 
124
- const local = ref(formatForDisplay(toDisplay(model.value)));
125
- const sliderLocal = ref(model.value);
170
+ // Resolve the current value across all bindings:
171
+ // - In range mode: lower/upper take precedence; falls back per-side to
172
+ // `range`; finally to slider min/max. The default `v-model` is
173
+ // unused in this mode.
174
+ // - In single mode: just the default `v-model`.
175
+ function effectiveValue(): number | NumberRange | undefined {
176
+ if (isRange) {
177
+ const tuple = range.value;
178
+ const lo = lower.value ?? tuple?.[0];
179
+ const hi = upper.value ?? tuple?.[1];
180
+ if (lo !== undefined || hi !== undefined) {
181
+ return [lo ?? sliderMin.value, hi ?? sliderMax.value];
182
+ }
183
+ return undefined;
184
+ }
185
+ return model.value;
186
+ }
187
+
188
+ // Initial single-value display string. The text input isn't rendered in
189
+ // range mode, so `local` is only consulted in single mode.
190
+ const initialEffective = effectiveValue();
191
+ const initialSingle =
192
+ typeof initialEffective === "number" ? initialEffective : undefined;
193
+ const local = ref(formatForDisplay(toDisplay(initialSingle)));
194
+
195
+ // Slider state is always an array, even in single mode (reka-ui's API).
196
+ // In range mode it holds [low, high]; in single mode it holds [value].
197
+ function modelToSliderArray(v: number | NumberRange | undefined): number[] {
198
+ if (isRange) {
199
+ if (isRangeValue(v)) return [v[0], v[1]];
200
+ return [sliderMin.value, sliderMax.value];
201
+ }
202
+ if (typeof v === "number") return [v];
203
+ return [sliderMin.value];
204
+ }
205
+ const sliderArrayLocal = ref<number[]>(modelToSliderArray(initialEffective));
126
206
  const validationError = ref<string>();
127
207
 
128
- watch(model, (v) => {
129
- local.value = formatForDisplay(toDisplay(v));
130
- sliderLocal.value = v;
208
+ watch([model, range, lower, upper], () => {
209
+ const v = effectiveValue();
210
+ if (!isRange && !isRangeValue(v)) {
211
+ local.value = formatForDisplay(toDisplay(v as number | undefined));
212
+ }
213
+ sliderArrayLocal.value = modelToSliderArray(v);
131
214
  validationError.value = validate(v);
132
215
  });
133
216
 
@@ -179,21 +262,17 @@ function onBlur() {
179
262
 
180
263
  let liveTimeout: ReturnType<typeof setTimeout> | null = null;
181
264
  function onInputEvent() {
182
- if (!props.live || props.slider) return;
265
+ if (!props.live || isSlider.value) return;
183
266
  if (liveTimeout) clearTimeout(liveTimeout);
184
267
  liveTimeout = setTimeout(commit, 300);
185
268
  }
186
269
  function onChangeEvent() {
187
- if (!props.live || props.slider) return;
270
+ if (!props.live || isSlider.value) return;
188
271
  if (liveTimeout) clearTimeout(liveTimeout);
189
272
  commit();
190
273
  }
191
274
 
192
- // Validates a model value (or undefined for empty). Single source of truth
193
- // for required / min / max errors — used on commit, programmatic updates,
194
- // and arrow-key stepping.
195
- function validate(v: number | undefined): string | undefined {
196
- if (v == null) return props.required ? "Required" : undefined;
275
+ function validateScalar(v: number): string | undefined {
197
276
  const display = toDisplay(v) as number;
198
277
  if (inputMin.value != null && display < inputMin.value) {
199
278
  return `Min ${inputMin.value}${props.percent ? "%" : ""}`;
@@ -204,11 +283,28 @@ function validate(v: number | undefined): string | undefined {
204
283
  return undefined;
205
284
  }
206
285
 
286
+ // Single source of truth for required / min / max errors — used on commit,
287
+ // programmatic updates, and arrow-key stepping. In range mode, returns the
288
+ // first failing handle's error, suffixed with "(lower)" or "(upper)".
289
+ function validate(v: number | NumberRange | undefined): string | undefined {
290
+ if (v == null) return props.required ? "Required" : undefined;
291
+ if (isRangeValue(v)) {
292
+ const lo = validateScalar(v[0]);
293
+ if (lo) return `${lo} (lower)`;
294
+ const hi = validateScalar(v[1]);
295
+ if (hi) return `${hi} (upper)`;
296
+ return undefined;
297
+ }
298
+ return validateScalar(v);
299
+ }
300
+
207
301
  function commit() {
208
- // An empty field clears the model distinct from garbage input.
302
+ // commit() is only reachable when !isSlider (only text-input events call
303
+ // it). Default `v-model` is scalar-only.
304
+ const current = model.value;
209
305
  if (local.value.trim() === "") {
210
306
  model.value = undefined;
211
- sliderLocal.value = undefined;
307
+ sliderArrayLocal.value = modelToSliderArray(undefined);
212
308
  validationError.value = validate(undefined);
213
309
  return;
214
310
  }
@@ -219,8 +315,8 @@ function commit() {
219
315
  // turn pure garbage ("abc") into 0. Reset to the current model value so
220
316
  // invalid input doesn't linger in the field.
221
317
  if (!/\d/.test(cleaned)) {
222
- local.value = formatForDisplay(toDisplay(model.value));
223
- validationError.value = validate(model.value);
318
+ local.value = formatForDisplay(toDisplay(current));
319
+ validationError.value = validate(current);
224
320
  return;
225
321
  }
226
322
  if (cleaned !== local.value) {
@@ -240,22 +336,43 @@ function commit() {
240
336
  if (error) return;
241
337
 
242
338
  model.value = next;
243
- sliderLocal.value = model.value;
339
+ sliderArrayLocal.value = [next];
340
+ }
341
+
342
+ function commitSliderArray(v: number[], asModel: boolean): void {
343
+ const coerced = v.map(coerceInteger);
344
+ sliderArrayLocal.value = coerced;
345
+ if (!isRange) {
346
+ local.value = formatForDisplay(toDisplay(coerced[0]));
347
+ }
348
+ if (asModel) {
349
+ if (isRange) {
350
+ // Emit to all range sinks; consumers without a matching v-model just
351
+ // ignore their `update:*` event. The default `v-model` is unused in
352
+ // range mode.
353
+ range.value = [coerced[0], coerced[1]] as NumberRange;
354
+ lower.value = coerced[0];
355
+ upper.value = coerced[1];
356
+ } else {
357
+ model.value = coerced[0];
358
+ }
359
+ }
360
+ }
361
+
362
+ function thumbAriaLabel(i: number): string | undefined {
363
+ if (!props.label) return undefined;
364
+ if (!isRange) return props.label;
365
+ return i === 0 ? `${props.label} (lower)` : `${props.label} (upper)`;
244
366
  }
245
367
 
246
368
  function onSliderUpdate(v: number[] | undefined) {
247
369
  if (!v) return;
248
- const val = coerceInteger(v[0]);
249
- sliderLocal.value = val;
250
- local.value = formatForDisplay(toDisplay(val));
251
- if (props.live) {
252
- model.value = val;
253
- }
370
+ commitSliderArray(v, !!props.live);
254
371
  }
255
372
 
256
373
  function onSliderCommit(v: number[] | undefined) {
257
374
  if (!v) return;
258
- model.value = coerceInteger(v[0]);
375
+ commitSliderArray(v, true);
259
376
  }
260
377
 
261
378
  function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
@@ -268,71 +385,26 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
268
385
  if (inputMin.value != null) next = Math.max(next, inputMin.value);
269
386
  if (inputMax.value != null) next = Math.min(next, inputMax.value);
270
387
  local.value = formatForDisplay(next);
271
- model.value = fromDisplay(next);
272
- sliderLocal.value = model.value;
388
+ const nextModel = fromDisplay(next);
389
+ model.value = nextModel;
390
+ sliderArrayLocal.value = [nextModel];
273
391
  }
274
392
  </script>
275
393
 
276
394
  <template>
277
- <label v-if="props.label" class="input-label">
395
+ <component
396
+ :is="props.label ? 'label' : 'div'"
397
+ :class="props.label ? 'input-label' : undefined"
398
+ >
278
399
  <span
400
+ v-if="props.label"
279
401
  class="input-label-row"
280
402
  :class="{ 'visually-hidden': props.hideLabel }"
281
403
  >
282
404
  {{ props.label }}
283
405
  <Hint v-if="props.hint && !props.hideLabel" :text="props.hint" />
284
406
  </span>
285
- <span v-if="!props.slider" class="input-wrapper">
286
- <input
287
- type="text"
288
- :inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
289
- v-model="local"
290
- :placeholder="props.placeholder"
291
- :aria-invalid="!!validationError"
292
- :aria-required="props.required || undefined"
293
- :required="props.required"
294
- @blur="onBlur"
295
- @keydown.enter="commit"
296
- @keydown.up="onArrowStep($event, 1)"
297
- @keydown.down="onArrowStep($event, -1)"
298
- @input="
299
- reformatInput($event);
300
- onInputEvent();
301
- "
302
- @change="onChangeEvent"
303
- />
304
- <span v-if="props.percent" class="input-suffix">%</span>
305
- </span>
306
- <span v-if="validationError" class="input-error" role="alert">
307
- {{ validationError }}
308
- </span>
309
- <div v-if="props.slider" class="slider-container">
310
- <SliderRoot
311
- class="slider-root"
312
- :model-value="sliderLocal != null ? [sliderLocal] : [sliderMin]"
313
- :min="sliderMin"
314
- :max="sliderMax"
315
- :step="sliderStep"
316
- @update:model-value="onSliderUpdate"
317
- @value-commit="onSliderCommit"
318
- >
319
- <SliderTrack class="slider-track">
320
- <SliderRange class="slider-range" />
321
- </SliderTrack>
322
- <SliderThumb class="slider-thumb" :aria-label="props.label">
323
- <span class="slider-current">
324
- {{ formatSliderValue(sliderLocal) }}
325
- </span>
326
- </SliderThumb>
327
- </SliderRoot>
328
- <div class="slider-labels">
329
- <span>{{ formatSliderValue(sliderMin) }}</span>
330
- <span>{{ formatSliderValue(sliderMax) }}</span>
331
- </div>
332
- </div>
333
- </label>
334
- <div v-else>
335
- <span v-if="!props.slider" class="input-wrapper">
407
+ <span v-if="!isSlider" class="input-wrapper">
336
408
  <input
337
409
  type="text"
338
410
  :inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
@@ -356,10 +428,10 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
356
428
  <span v-if="validationError" class="input-error" role="alert">
357
429
  {{ validationError }}
358
430
  </span>
359
- <div v-if="props.slider" class="slider-container">
431
+ <div v-if="isSlider" class="slider-container">
360
432
  <SliderRoot
361
433
  class="slider-root"
362
- :model-value="sliderLocal != null ? [sliderLocal] : [sliderMin]"
434
+ :model-value="sliderArrayLocal"
363
435
  :min="sliderMin"
364
436
  :max="sliderMax"
365
437
  :step="sliderStep"
@@ -369,9 +441,14 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
369
441
  <SliderTrack class="slider-track">
370
442
  <SliderRange class="slider-range" />
371
443
  </SliderTrack>
372
- <SliderThumb class="slider-thumb" :aria-label="props.label">
444
+ <SliderThumb
445
+ v-for="(v, i) in sliderArrayLocal"
446
+ :key="i"
447
+ class="slider-thumb"
448
+ :aria-label="thumbAriaLabel(i)"
449
+ >
373
450
  <span class="slider-current">
374
- {{ formatSliderValue(sliderLocal) }}
451
+ {{ formatSliderValue(v) }}
375
452
  </span>
376
453
  </SliderThumb>
377
454
  </SliderRoot>
@@ -380,7 +457,7 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
380
457
  <span>{{ formatSliderValue(sliderMax) }}</span>
381
458
  </div>
382
459
  </div>
383
- </div>
460
+ </component>
384
461
  </template>
385
462
 
386
463
  <style scoped>
@@ -10,6 +10,7 @@ export { default as Hint } from "./Hint/Hint.vue";
10
10
  export { default as Icon } from "./Icon/Icon.vue";
11
11
  export { default as LightDarkToggle } from "./LightDarkToggle/LightDarkToggle.vue";
12
12
  export { default as NumberInput } from "./NumberInput/NumberInput.vue";
13
+ export type { NumberRange } from "./NumberInput/NumberInput.vue";
13
14
  export { default as SelectBox } from "./SelectBox/SelectBox.vue";
14
15
  export type { SelectOption } from "./SelectBox/SelectBox.vue";
15
16
  export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout.vue";
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.16",
2
+ "version": "0.3.18",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/theme/base.css CHANGED
@@ -129,11 +129,6 @@ body {
129
129
  font-size: var(--font-size-md);
130
130
  }
131
131
 
132
- :root,
133
- body {
134
- overflow: hidden;
135
- }
136
-
137
132
  h1,
138
133
  h2,
139
134
  h3,