@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
|
@@ -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="
|
|
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("
|
|
116
|
+
it("validates min/max for percent mode", async () => {
|
|
117
117
|
const wrapper = mount(NumberInput, {
|
|
118
|
-
props: {
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
expect(
|
|
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:
|
|
326
|
-
expect((input.element as HTMLInputElement).value).toBe("
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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="
|
|
116
|
-
|
|
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="
|
|
186
|
+
@blur="onBlur"
|
|
123
187
|
@keydown.enter="commit"
|
|
124
|
-
@
|
|
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="
|
|
161
|
-
|
|
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="
|
|
234
|
+
@blur="onBlur"
|
|
168
235
|
@keydown.enter="commit"
|
|
169
|
-
@
|
|
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>
|