@cfasim-ui/components 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/components",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Vue 3 UI components for cfasim-ui",
6
6
  "license": "Apache-2.0",
@@ -6,5 +6,5 @@ test("NumberInput page renders demos", async ({ page }) => {
6
6
  const demos = page.locator(".demo-preview");
7
7
  await expect(demos.first()).toBeVisible();
8
8
  await expect(demos.first().getByText("Days")).toBeVisible();
9
- await expect(demos.first().locator('input[type="number"]')).toBeVisible();
9
+ await expect(demos.first().locator('input[type="text"]')).toBeVisible();
10
10
  });
@@ -113,15 +113,27 @@ describe("NumberInput", () => {
113
113
  expect(wrapper.props("modelValue")).toBeCloseTo(0.85);
114
114
  });
115
115
 
116
- it("defaults step/min/max for percent mode", () => {
116
+ it("validates min/max for percent mode", async () => {
117
117
  const wrapper = mount(NumberInput, {
118
- props: { modelValue: 0.5, label: "Rate", percent: true },
118
+ props: {
119
+ modelValue: 0.5,
120
+ label: "Rate",
121
+ percent: true,
122
+ "onUpdate:modelValue": (v: number | undefined) =>
123
+ wrapper.setProps({ modelValue: v }),
124
+ },
119
125
  });
120
126
  const input = wrapper.find("input");
121
- const el = input.element as HTMLInputElement;
122
- expect(el.step).toBe("1");
123
- expect(el.min).toBe("0");
124
- expect(el.max).toBe("100");
127
+
128
+ await input.setValue(110);
129
+ await input.trigger("blur");
130
+ expect(wrapper.find(".input-error").text()).toBe("Max 100%");
131
+ expect(wrapper.props("modelValue")).toBe(0.5);
132
+
133
+ await input.setValue(-5);
134
+ await input.trigger("blur");
135
+ expect(wrapper.find(".input-error").text()).toBe("Min 0%");
136
+ expect(wrapper.props("modelValue")).toBe(0.5);
125
137
  });
126
138
 
127
139
  it("does not show suffix when percent is not set", () => {
@@ -311,6 +323,141 @@ describe("NumberInput", () => {
311
323
  vi.useRealTimers();
312
324
  });
313
325
 
326
+ it("increments value on ArrowUp", async () => {
327
+ const wrapper = mount(NumberInput, {
328
+ props: {
329
+ modelValue: 100,
330
+ label: "Count",
331
+ "onUpdate:modelValue": (v: number | undefined) =>
332
+ wrapper.setProps({ modelValue: v }),
333
+ },
334
+ });
335
+ const input = wrapper.find("input");
336
+ await input.trigger("keydown", { key: "ArrowUp" });
337
+ expect(wrapper.props("modelValue")).toBe(101);
338
+ expect((input.element as HTMLInputElement).value).toBe("101");
339
+ });
340
+
341
+ it("decrements value on ArrowDown", async () => {
342
+ const wrapper = mount(NumberInput, {
343
+ props: {
344
+ modelValue: 100,
345
+ label: "Count",
346
+ "onUpdate:modelValue": (v: number | undefined) =>
347
+ wrapper.setProps({ modelValue: v }),
348
+ },
349
+ });
350
+ const input = wrapper.find("input");
351
+ await input.trigger("keydown", { key: "ArrowDown" });
352
+ expect(wrapper.props("modelValue")).toBe(99);
353
+ });
354
+
355
+ it("steps by custom step prop", async () => {
356
+ const wrapper = mount(NumberInput, {
357
+ props: {
358
+ modelValue: 100,
359
+ label: "Count",
360
+ step: 5,
361
+ "onUpdate:modelValue": (v: number | undefined) =>
362
+ wrapper.setProps({ modelValue: v }),
363
+ },
364
+ });
365
+ const input = wrapper.find("input");
366
+ await input.trigger("keydown", { key: "ArrowUp" });
367
+ expect(wrapper.props("modelValue")).toBe(105);
368
+ });
369
+
370
+ it("steps by 10x with shift+arrow", async () => {
371
+ const wrapper = mount(NumberInput, {
372
+ props: {
373
+ modelValue: 100,
374
+ label: "Count",
375
+ "onUpdate:modelValue": (v: number | undefined) =>
376
+ wrapper.setProps({ modelValue: v }),
377
+ },
378
+ });
379
+ const input = wrapper.find("input");
380
+ await input.trigger("keydown", { key: "ArrowUp", shiftKey: true });
381
+ expect(wrapper.props("modelValue")).toBe(110);
382
+ });
383
+
384
+ it("converts step prop to display units in percent mode", async () => {
385
+ const wrapper = mount(NumberInput, {
386
+ props: {
387
+ modelValue: 0.5,
388
+ label: "Rate",
389
+ percent: true,
390
+ step: 0.01,
391
+ "onUpdate:modelValue": (v: number | undefined) =>
392
+ wrapper.setProps({ modelValue: v }),
393
+ },
394
+ });
395
+ const input = wrapper.find("input");
396
+ expect((input.element as HTMLInputElement).value).toBe("50");
397
+
398
+ await input.trigger("keydown", { key: "ArrowUp" });
399
+ expect(wrapper.props("modelValue")).toBeCloseTo(0.51);
400
+ expect((input.element as HTMLInputElement).value).toBe("51");
401
+ });
402
+
403
+ it("clamps arrow step to min/max", async () => {
404
+ const wrapper = mount(NumberInput, {
405
+ props: {
406
+ modelValue: 99,
407
+ label: "Count",
408
+ max: 100,
409
+ "onUpdate:modelValue": (v: number | undefined) =>
410
+ wrapper.setProps({ modelValue: v }),
411
+ },
412
+ });
413
+ const input = wrapper.find("input");
414
+ await input.trigger("keydown", { key: "ArrowUp" });
415
+ expect(wrapper.props("modelValue")).toBe(100);
416
+ await input.trigger("keydown", { key: "ArrowUp" });
417
+ expect(wrapper.props("modelValue")).toBe(100);
418
+ });
419
+
420
+ it("displays numbers with comma separators", () => {
421
+ const wrapper = mount(NumberInput, {
422
+ props: { modelValue: 1000000, label: "Population" },
423
+ });
424
+ const input = wrapper.find("input");
425
+ expect((input.element as HTMLInputElement).value).toBe("1,000,000");
426
+ });
427
+
428
+ it("keeps commas during editing and commits on blur", async () => {
429
+ const wrapper = mount(NumberInput, {
430
+ props: {
431
+ modelValue: 1500,
432
+ label: "Count",
433
+ "onUpdate:modelValue": (v: number | undefined) =>
434
+ wrapper.setProps({ modelValue: v }),
435
+ },
436
+ });
437
+ const input = wrapper.find("input");
438
+ expect((input.element as HTMLInputElement).value).toBe("1,500");
439
+
440
+ await input.setValue("2500");
441
+ await input.trigger("blur");
442
+ expect((input.element as HTMLInputElement).value).toBe("2,500");
443
+ expect(wrapper.props("modelValue")).toBe(2500);
444
+ });
445
+
446
+ it("accepts comma-formatted input and parses correctly", async () => {
447
+ const wrapper = mount(NumberInput, {
448
+ props: {
449
+ modelValue: 100,
450
+ label: "Count",
451
+ "onUpdate:modelValue": (v: number | undefined) =>
452
+ wrapper.setProps({ modelValue: v }),
453
+ },
454
+ });
455
+ const input = wrapper.find("input");
456
+ await input.setValue("1,234,567");
457
+ await input.trigger("blur");
458
+ expect(wrapper.props("modelValue")).toBe(1234567);
459
+ });
460
+
314
461
  it("syncs local value when model changes externally", async () => {
315
462
  const wrapper = mount(NumberInput, {
316
463
  props: {
@@ -322,7 +469,7 @@ describe("NumberInput", () => {
322
469
  const input = wrapper.find("input");
323
470
  expect((input.element as HTMLInputElement).value).toBe("100");
324
471
 
325
- await wrapper.setProps({ modelValue: 500 });
326
- expect((input.element as HTMLInputElement).value).toBe("500");
472
+ await wrapper.setProps({ modelValue: 5000 });
473
+ expect((input.element as HTMLInputElement).value).toBe("5,000");
327
474
  });
328
475
  });
@@ -24,7 +24,7 @@ const sliderStep = computed(() => props.step ?? (props.percent ? 0.01 : 1));
24
24
  function formatSliderValue(v: number | undefined) {
25
25
  if (v == null) return "";
26
26
  if (props.percent) return (v * 100).toFixed(0) + "%";
27
- return String(v);
27
+ return v.toLocaleString("en-US");
28
28
  }
29
29
 
30
30
  function toDisplay(v: number | undefined) {
@@ -36,15 +36,63 @@ function fromDisplay(v: number) {
36
36
  return props.percent ? v / 100 : v;
37
37
  }
38
38
 
39
- const local = ref(toDisplay(model.value));
39
+ function formatWithCommas(v: number | undefined): string {
40
+ if (v == null) return "";
41
+ return v.toLocaleString("en-US");
42
+ }
43
+
44
+ function stripCommas(s: string): string {
45
+ return s.replace(/,/g, "");
46
+ }
47
+
48
+ const local = ref(formatWithCommas(toDisplay(model.value)));
40
49
  const sliderLocal = ref(model.value);
41
50
  const validationError = ref<string>();
42
51
 
43
52
  watch(model, (v) => {
44
- local.value = toDisplay(v);
53
+ local.value = formatWithCommas(toDisplay(v));
45
54
  sliderLocal.value = v;
46
55
  });
47
56
 
57
+ function reformatInput(event: Event) {
58
+ const input = event.target as HTMLInputElement;
59
+ const raw = stripCommas(input.value);
60
+ if (raw === "" || raw === "-") return;
61
+ const parsed = Number(raw);
62
+ if (Number.isNaN(parsed)) return;
63
+
64
+ const formatted = formatWithCommas(parsed);
65
+ if (formatted === input.value) return;
66
+
67
+ const cursorPos = input.selectionStart ?? 0;
68
+ const commasBefore = (input.value.slice(0, cursorPos).match(/,/g) || [])
69
+ .length;
70
+ local.value = formatted;
71
+
72
+ requestAnimationFrame(() => {
73
+ const rawPos = cursorPos - commasBefore;
74
+ let newPos = 0;
75
+ let rawCount = 0;
76
+ for (let i = 0; i < formatted.length; i++) {
77
+ if (formatted[i] !== ",") rawCount++;
78
+ if (rawCount >= rawPos) {
79
+ newPos = i + 1;
80
+ break;
81
+ }
82
+ }
83
+ if (rawCount < rawPos) newPos = formatted.length;
84
+ input.setSelectionRange(newPos, newPos);
85
+ });
86
+ }
87
+
88
+ function onBlur() {
89
+ commit();
90
+ const parsed = Number(stripCommas(local.value));
91
+ if (!Number.isNaN(parsed)) {
92
+ local.value = formatWithCommas(parsed);
93
+ }
94
+ }
95
+
48
96
  let liveTimeout: ReturnType<typeof setTimeout> | null = null;
49
97
  function onInputEvent() {
50
98
  if (!props.live || props.slider) return;
@@ -68,7 +116,7 @@ function validate(displayValue: number): string | undefined {
68
116
  }
69
117
 
70
118
  function commit() {
71
- const parsed = Number(local.value);
119
+ const parsed = Number(stripCommas(local.value));
72
120
  if (Number.isNaN(parsed)) return;
73
121
 
74
122
  const error = validate(parsed);
@@ -82,7 +130,7 @@ function commit() {
82
130
  function onSliderUpdate(v: number[] | undefined) {
83
131
  if (!v) return;
84
132
  sliderLocal.value = v[0];
85
- local.value = toDisplay(v[0]);
133
+ local.value = formatWithCommas(toDisplay(v[0]));
86
134
  if (props.live) {
87
135
  model.value = v[0];
88
136
  }
@@ -93,7 +141,25 @@ function onSliderCommit(v: number[] | undefined) {
93
141
  model.value = v[0];
94
142
  }
95
143
 
96
- const inputStep = computed(() => props.step ?? (props.percent ? 1 : undefined));
144
+ const inputStep = computed(() => {
145
+ if (props.step != null) return props.percent ? props.step * 100 : props.step;
146
+ return 1;
147
+ });
148
+
149
+ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
150
+ event.preventDefault();
151
+ const parsed = Number(stripCommas(local.value));
152
+ const current = Number.isNaN(parsed) ? 0 : parsed;
153
+ const step = inputStep.value * (event.shiftKey ? 10 : 1);
154
+ let next = current + step * direction;
155
+ if (inputMin.value != null) next = Math.max(next, inputMin.value);
156
+ if (inputMax.value != null) next = Math.min(next, inputMax.value);
157
+ local.value = formatWithCommas(next);
158
+ validationError.value = undefined;
159
+ model.value = fromDisplay(next);
160
+ sliderLocal.value = model.value;
161
+ }
162
+
97
163
  const inputMin = computed(() => {
98
164
  if (props.min != null) return props.percent ? props.min * 100 : props.min;
99
165
  return props.percent ? 0 : undefined;
@@ -112,16 +178,19 @@ const inputMax = computed(() => {
112
178
  </span>
113
179
  <span v-if="!props.slider" class="input-wrapper">
114
180
  <input
115
- type="number"
116
- v-model.number="local"
181
+ type="text"
182
+ inputmode="decimal"
183
+ v-model="local"
117
184
  :placeholder="props.placeholder"
118
- :step="inputStep"
119
- :min="inputMin"
120
- :max="inputMax"
121
185
  :aria-invalid="!!validationError"
122
- @blur="commit"
186
+ @blur="onBlur"
123
187
  @keydown.enter="commit"
124
- @input="onInputEvent"
188
+ @keydown.up="onArrowStep($event, 1)"
189
+ @keydown.down="onArrowStep($event, -1)"
190
+ @input="
191
+ reformatInput($event);
192
+ onInputEvent();
193
+ "
125
194
  @change="onChangeEvent"
126
195
  />
127
196
  <span v-if="props.percent" class="input-suffix">%</span>
@@ -157,16 +226,19 @@ const inputMax = computed(() => {
157
226
  <div v-else>
158
227
  <span v-if="!props.slider" class="input-wrapper">
159
228
  <input
160
- type="number"
161
- v-model.number="local"
229
+ type="text"
230
+ inputmode="decimal"
231
+ v-model="local"
162
232
  :placeholder="props.placeholder"
163
- :step="inputStep"
164
- :min="inputMin"
165
- :max="inputMax"
166
233
  :aria-invalid="!!validationError"
167
- @blur="commit"
234
+ @blur="onBlur"
168
235
  @keydown.enter="commit"
169
- @input="onInputEvent"
236
+ @keydown.up="onArrowStep($event, 1)"
237
+ @keydown.down="onArrowStep($event, -1)"
238
+ @input="
239
+ reformatInput($event);
240
+ onInputEvent();
241
+ "
170
242
  @change="onChangeEvent"
171
243
  />
172
244
  <span v-if="props.percent" class="input-suffix">%</span>