@fiscozen/input 3.4.4 → 3.5.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.
@@ -2,44 +2,30 @@
2
2
  /**
3
3
  * FzCurrencyInput Component
4
4
  *
5
- * Specialized currency input built on FzInput with number formatting, validation,
6
- * and step controls. Formats values using Intl.NumberFormat with locale-aware separators.
7
- * Supports min/max constraints and step quantization
8
- * that detects decimal/thousand separators automatically.
5
+ * @deprecated Use `<FzInput type="currency">` instead: the currency behavior now
6
+ * lives in FzInput. This wrapper is kept for backwards compatibility until the
7
+ * migration of all consumers is complete and forwards everything to
8
+ * `<FzInput type="currency">`.
9
9
  *
10
10
  * @component
11
11
  * @example
12
12
  * <FzCurrencyInput label="Amount" v-model="value" :min="0" :max="1000" />
13
13
  */
14
- import { computed, nextTick, onMounted, ref, watch } from "vue";
14
+ import { computed, ref } from "vue";
15
15
  import FzInput from "./FzInput.vue";
16
16
  import { FzCurrencyInputProps } from "./types";
17
- import {
18
- clamp,
19
- format as formatValue,
20
- parse,
21
- roundTo,
22
- truncateDecimals,
23
- } from "@fiscozen/composables";
24
- import { FzIcon } from "@fiscozen/icons";
25
17
 
26
- const fzInputRef = ref<InstanceType<typeof FzInput>>();
27
-
28
- const fzInputModel = ref<string | undefined>();
18
+ // Structural type: InstanceType<> is not applicable to generic components
19
+ const fzInputRef = ref<{
20
+ inputRef: HTMLInputElement | null;
21
+ containerRef: HTMLElement | null;
22
+ } | null>(null);
29
23
 
30
24
  const containerRef = computed(() => fzInputRef.value?.containerRef);
31
25
 
32
26
  const inputRef = computed(() => fzInputRef.value?.inputRef);
33
27
 
34
- const isFocused = ref(false);
35
-
36
- const props = withDefaults(defineProps<FzCurrencyInputProps>(), {
37
- min: -Infinity,
38
- minimumFractionDigits: 2,
39
- max: Infinity,
40
- maximumFractionDigits: 2,
41
- step: 1,
42
- });
28
+ const props = defineProps<FzCurrencyInputProps>();
43
29
 
44
30
  // DOM events (@focus, @blur, @keydown, …) propagate via v-bind fallthrough on the
45
31
  // inner FzInput. @update:modelValue is handled by defineModel. Only custom events
@@ -49,675 +35,17 @@ const emit = defineEmits<{
49
35
  }>();
50
36
  const model = defineModel<FzCurrencyInputProps["modelValue"]>();
51
37
 
52
- let isInternalUpdate = false;
53
-
54
- /**
55
- * Determines the value to emit when input is empty based on nullOnEmpty and zeroOnEmpty props
56
- *
57
- * Priority: nullOnEmpty > zeroOnEmpty > undefined
58
- *
59
- * @returns null if nullOnEmpty is true, 0 if zeroOnEmpty is true, undefined otherwise
60
- */
61
- const getEmptyValue = (): number | null | undefined => {
62
- if (props.nullOnEmpty) {
63
- return null;
64
- }
65
- if (props.zeroOnEmpty) {
66
- return 0;
67
- }
68
- return undefined;
69
- };
70
-
71
- /**
72
- * Determines the display value when input is empty
73
- *
74
- * When zeroOnEmpty is true and the empty value is 0:
75
- * - During typing (focused): returns empty string (formatting happens on blur)
76
- * - On blur (not focused): returns formatted "0,00"
77
- *
78
- * Otherwise returns empty string.
79
- *
80
- * @param isEmptyValueZero - Whether the empty value is 0 (from getEmptyValue())
81
- * If true, implies props.zeroOnEmpty is also true
82
- * @param isCurrentlyFocused - Whether the input is currently focused
83
- * @returns Display string to show in the input field
84
- */
85
- const getEmptyDisplayValue = (
86
- isEmptyValueZero: boolean,
87
- isCurrentlyFocused: boolean,
88
- ): string => {
89
- if (isEmptyValueZero) {
90
- // During typing, show empty string. Formatting happens on blur
91
- if (isCurrentlyFocused) {
92
- return "";
93
- }
94
- // On blur or when not focused, show formatted zero
95
- return formatValue(0, {
96
- minimumFractionDigits: props.minimumFractionDigits,
97
- maximumFractionDigits: props.maximumFractionDigits,
98
- roundDecimals: false,
99
- useGrouping: true,
100
- });
101
- }
102
- return "";
103
- };
104
-
105
- /**
106
- * Computed aria-label for step up button
107
- *
108
- * Uses custom stepUpAriaLabel if provided, otherwise generates default label based on step value.
109
- */
110
- const stepUpAriaLabel = computed(() => {
111
- if (props.stepUpAriaLabel) {
112
- return props.stepUpAriaLabel;
113
- }
114
- return `Incrementa di ${props.step}`;
115
- });
116
-
117
- /**
118
- * Computed aria-label for step down button
119
- *
120
- * Uses custom stepDownAriaLabel if provided, otherwise generates default label based on step value.
121
- */
122
- const stepDownAriaLabel = computed(() => {
123
- if (props.stepDownAriaLabel) {
124
- return props.stepDownAriaLabel;
125
- }
126
- return `Decrementa di ${props.step}`;
127
- });
128
-
129
- /**
130
- * Computed disabled state for step controls
131
- *
132
- * Step controls are disabled when input is readonly or disabled.
133
- */
134
- const isStepDisabled = computed(() => {
135
- return props.readonly || props.disabled;
136
- });
137
-
138
38
  /**
139
- * Validates and normalizes user input
140
- *
141
- * Allows only digits, "." and ",". Converts "." to ",".
142
- * Allows minus sign only at the beginning for negative values.
143
- * Handles double comma case: "123,45" -> "12,3,45" -> "12,34"
144
- * (keeps only the first comma, everything after becomes decimal part)
145
- *
146
- * @param inputValue - Raw input value from user
147
- * @returns Normalized value with only one comma and optional leading minus sign
39
+ * Adapter towards FzInput's currency model (`number | null | undefined`).
40
+ * Deprecated string values are passed through unchanged: FzInput's currency mode
41
+ * still parses them at runtime (with the same deprecation warning).
148
42
  */
149
- const normalizeInput = (inputValue: string): string => {
150
- // Allow only digits, "." "," and "-"
151
- let filtered = inputValue.replace(/[^0-9.,-]/g, "");
152
-
153
- // Check if minus sign is at the beginning (after removing invalid chars)
154
- const hasLeadingMinus = filtered.startsWith("-");
155
-
156
- // Remove all minus signs (we'll reattach only one at the beginning if needed)
157
- filtered = filtered.replace(/-/g, "");
158
-
159
- // Convert "." to ","
160
- filtered = filtered.replace(/\./g, ",");
161
-
162
- // Handle multiple commas: keep only the first one
163
- const firstCommaIndex = filtered.indexOf(",");
164
- if (firstCommaIndex !== -1) {
165
- // Keep everything before first comma + first comma + everything after first comma (remove other commas)
166
- const beforeComma = filtered.substring(0, firstCommaIndex);
167
- const afterComma = filtered
168
- .substring(firstCommaIndex + 1)
169
- .replace(/,/g, "");
170
- filtered = beforeComma + "," + afterComma;
171
- }
172
-
173
- // Reattach minus sign at the beginning if it was present at the start
174
- return hasLeadingMinus ? "-" + filtered : filtered;
175
- };
176
-
177
- /**
178
- * Prevents invalid characters from being typed
179
- *
180
- * Allows only digits, "." and ",". Allows minus sign only at the beginning.
181
- * Blocks all other characters.
182
- * Also allows control keys (Backspace, Delete, Arrow keys, Tab, etc.)
183
- * Multiple commas are handled by normalizeInput.
184
- *
185
- * @param e - Keyboard event
186
- */
187
- const handleKeydown = (e: KeyboardEvent) => {
188
- // Allow control keys (Backspace, Delete, Arrow keys, Tab, etc.)
189
- if (
190
- e.ctrlKey ||
191
- e.metaKey ||
192
- e.altKey ||
193
- [
194
- "Backspace",
195
- "Delete",
196
- "ArrowLeft",
197
- "ArrowRight",
198
- "ArrowUp",
199
- "ArrowDown",
200
- "Tab",
201
- "Enter",
202
- "Home",
203
- "End",
204
- ].includes(e.key)
205
- ) {
206
- return;
207
- }
208
-
209
- // Allow minus sign only at the beginning (position 0) or when entire value is selected
210
- if (e.key === "-") {
211
- const target = e.target as HTMLInputElement;
212
- const cursorPosition = target.selectionStart ?? 0;
213
- const selectionLength = (target.selectionEnd ?? 0) - cursorPosition;
214
- const valueLength = target.value.length;
215
-
216
- // Allow minus if:
217
- // 1. Cursor is at position 0 (beginning)
218
- // 2. Entire value is selected (user can replace with negative)
219
- if (cursorPosition !== 0 && selectionLength !== valueLength) {
220
- e.preventDefault();
221
- }
222
- return;
223
- }
224
-
225
- // Allow only digits, "." and ","
226
- if (!/^[0-9.,]$/.test(e.key)) {
227
- e.preventDefault();
228
- }
229
- };
230
-
231
- /**
232
- * Handles paste event to replace entire input value
233
- *
234
- * Prevents default paste behavior and replaces the entire input value with the pasted content.
235
- * Uses parse() to handle Italian format (e.g., "1.234,56"). If the pasted text is not a valid number,
236
- * the paste is ignored.
237
- */
238
- const handlePaste = (e: ClipboardEvent) => {
239
- if (props.readonly || props.disabled) {
240
- return;
241
- }
242
-
243
- e.preventDefault();
244
-
245
- const pastedText = e.clipboardData?.getData("text") || "";
246
- if (!pastedText) {
247
- return;
248
- }
249
-
250
- // Use parse() to convert Italian format to number
251
- const parsed = parse(pastedText);
252
-
253
- if (!isNaN(parsed) && isFinite(parsed)) {
254
- // Truncate decimals to maximumFractionDigits
255
- const processed = truncateDecimals(parsed, props.maximumFractionDigits);
256
-
257
- // Update v-model
258
- isInternalUpdate = true;
259
- model.value = processed;
260
- isInternalUpdate = false;
261
-
262
- // Convert number to normalized string format for display (e.g., 1234.56 -> "1234,56")
263
- const numberString = String(processed);
264
- const normalized = normalizeInput(numberString);
265
- fzInputModel.value = normalized;
266
- }
267
- // If invalid, ignore paste (do nothing)
268
- };
269
-
270
- /**
271
- * Handles input updates from FzInput
272
- *
273
- * Validates and normalizes input, updates v-model with parsed number.
274
- * Does NOT format the display value - shows raw input (e.g., "123" stays "123", not "123,00").
275
- * Does NOT apply step quantization - quantization happens only on blur.
276
- * Formatting and quantization happen only on blur.
277
- */
278
- const handleInputUpdate = (newValue: string | undefined) => {
279
- if (!newValue) {
280
- const emptyValue = getEmptyValue();
281
- isInternalUpdate = true;
282
- model.value = emptyValue;
283
- isInternalUpdate = false;
284
-
285
- // During typing, always show empty string. Formatting to "0,00" happens only on blur
286
- fzInputModel.value = "";
287
- return;
288
- }
289
-
290
- const normalized = normalizeInput(newValue);
291
- fzInputModel.value = normalized;
292
-
293
- // Parse to number and update v-model (but don't format display - keep raw)
294
- const parsed = parse(normalized);
295
- if (!isNaN(parsed) && isFinite(parsed)) {
296
- // Truncate decimals to maximumFractionDigits before updating v-model
297
- const processed = truncateDecimals(parsed, props.maximumFractionDigits);
298
-
299
- isInternalUpdate = true;
300
- model.value = processed;
301
- isInternalUpdate = false;
302
- } else {
303
- // If invalid, keep the normalized string but don't update v-model
304
- isInternalUpdate = true;
305
- model.value = getEmptyValue();
306
- isInternalUpdate = false;
307
- }
308
- };
309
-
310
- /**
311
- * Handles blur event to format the value
312
- *
313
- * Formats the value to Italian format (e.g., "123" -> "123,00", "123,4" -> "123,40").
314
- * Applies step quantization if forceStep is enabled (quantization happens only on blur, not during typing).
315
- */
316
- const handleBlur = () => {
317
- if (props.readonly || props.disabled) {
318
- return;
319
- }
320
-
321
- isFocused.value = false;
322
-
323
- const currentValue = normalizeModelValue(model.value);
324
- if (currentValue === undefined || currentValue === null) {
325
- // Ensure v-model matches the expected empty value based on nullOnEmpty/zeroOnEmpty
326
- const expectedEmptyValue = getEmptyValue();
327
- if (model.value !== expectedEmptyValue) {
328
- isInternalUpdate = true;
329
- model.value = expectedEmptyValue;
330
- isInternalUpdate = false;
331
- }
332
-
333
- // Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
334
- fzInputModel.value = getEmptyDisplayValue(
335
- expectedEmptyValue === 0,
336
- false, // Not focused during blur
337
- );
338
- return;
339
- }
340
-
341
- // Apply step quantization if forceStep is enabled
342
- let processed = currentValue;
343
- if (props.forceStep) {
344
- processed = roundTo(props.step, processed);
345
- }
346
-
347
- // Apply min/max constraints
348
- processed = clamp(props.min, processed, props.max);
349
-
350
- // Update v-model if processed value differs
351
- if (processed !== currentValue) {
352
- isInternalUpdate = true;
353
- model.value = processed;
354
- isInternalUpdate = false;
355
- }
356
-
357
- // Format the value for display
358
- const formatted = formatValue(processed, {
359
- minimumFractionDigits: props.minimumFractionDigits,
360
- maximumFractionDigits: props.maximumFractionDigits,
361
- roundDecimals: false,
362
- useGrouping: true,
363
- });
364
- fzInputModel.value = formatted;
365
- };
366
-
367
- /**
368
- * Handles focus event
369
- *
370
- * When input gains focus, shows raw value (without formatting)
371
- */
372
- const handleFocus = () => {
373
- if (props.readonly || props.disabled) {
374
- return;
375
- }
376
-
377
- isFocused.value = true;
378
-
379
- // Convert formatted value back to raw for editing
380
- const currentValue = normalizeModelValue(model.value);
381
- if (currentValue !== undefined) {
382
- // Get raw value from formatted: remove thousand separators, keep decimal separator
383
- const formatted = formatValue(currentValue, {
384
- minimumFractionDigits: props.minimumFractionDigits,
385
- maximumFractionDigits: props.maximumFractionDigits,
386
- roundDecimals: false,
387
- useGrouping: true,
388
- });
389
- const rawValue = formatted.replace(/\./g, ""); // Remove thousand separators
390
- fzInputModel.value = rawValue;
391
- }
392
- };
393
-
394
- /**
395
- * Normalizes model value to number | undefined | null
396
- *
397
- * Converts string values to numbers (with deprecation warning) and handles
398
- * null/undefined/empty string cases.
399
- *
400
- * @param value - Input value (number, string, undefined, or null)
401
- * @returns Normalized number value, undefined, or null
402
- */
403
- const normalizeModelValue = (
404
- value: number | string | undefined | null,
405
- ): number | undefined | null => {
406
- if (value === undefined || value === null || value === "") {
407
- return value === null ? null : undefined;
408
- }
409
- if (typeof value === "number") {
410
- return value;
411
- }
412
- if (typeof value === "string") {
413
- console.warn(
414
- "[FzCurrencyInput] String values in v-model are deprecated. Please use number instead. " +
415
- `Received: "${value}". This will be parsed to a number for retrocompatibility, but string support may be removed in a future version.`,
416
- );
417
- const parsed = parse(value);
418
- return isNaN(parsed) ? undefined : parsed;
419
- }
420
- return undefined;
421
- };
422
-
423
- /**
424
- * Handles step up button click
425
- *
426
- * Increments the current value by step amount, applying truncation and clamping.
427
- */
428
- const handleStepUp = () => {
429
- if (props.readonly || props.disabled) {
430
- return;
431
- }
432
-
433
- const currentValue = normalizeModelValue(model.value);
434
- // If value is undefined/null, start from 0 or empty value based on props
435
- const baseValue =
436
- currentValue === undefined || currentValue === null ? 0 : currentValue;
437
-
438
- // Add step
439
- const newValue = baseValue + props.step;
440
-
441
- // Truncate decimals
442
- const truncated = truncateDecimals(newValue, props.maximumFractionDigits);
443
-
444
- // Apply min/max constraints
445
- const clamped = clamp(props.min, truncated, props.max);
446
-
447
- // Update v-model
448
- isInternalUpdate = true;
449
- model.value = clamped;
450
- isInternalUpdate = false;
451
-
452
- // Format and update display
453
- const formatted = formatValue(clamped, {
454
- minimumFractionDigits: props.minimumFractionDigits,
455
- maximumFractionDigits: props.maximumFractionDigits,
456
- roundDecimals: false,
457
- useGrouping: true,
458
- });
459
- fzInputModel.value = formatted;
460
- };
461
-
462
- /**
463
- * Handles step down button click
464
- *
465
- * Decrements the current value by step amount, applying truncation and clamping.
466
- */
467
- const handleStepDown = () => {
468
- if (props.readonly || props.disabled) {
469
- return;
470
- }
471
-
472
- const currentValue = normalizeModelValue(model.value);
473
- // If value is undefined/null, start from 0 or empty value based on props
474
- const baseValue =
475
- currentValue === undefined || currentValue === null ? 0 : currentValue;
476
-
477
- // Subtract step
478
- const newValue = baseValue - props.step;
479
-
480
- // Truncate decimals
481
- const truncated = truncateDecimals(newValue, props.maximumFractionDigits);
482
-
483
- // Apply min/max constraints
484
- const clamped = clamp(props.min, truncated, props.max);
485
-
486
- // Update v-model
487
- isInternalUpdate = true;
488
- model.value = clamped;
489
- isInternalUpdate = false;
490
-
491
- // Format and update display
492
- const formatted = formatValue(clamped, {
493
- minimumFractionDigits: props.minimumFractionDigits,
494
- maximumFractionDigits: props.maximumFractionDigits,
495
- roundDecimals: false,
496
- useGrouping: true,
497
- });
498
- fzInputModel.value = formatted;
499
- };
500
-
501
- /**
502
- * Initializes fzInputModel with the value from v-model on mount
503
- *
504
- * Formats the value only if not focused (formatted display).
505
- */
506
- onMounted(() => {
507
- const initialValue = model.value;
508
-
509
- if (initialValue === undefined || initialValue === null) {
510
- // Ensure v-model matches the expected empty value based on nullOnEmpty/zeroOnEmpty
511
- const expectedEmptyValue = getEmptyValue();
512
- if (initialValue !== expectedEmptyValue) {
513
- isInternalUpdate = true;
514
- model.value = expectedEmptyValue;
515
- isInternalUpdate = false;
516
- }
517
-
518
- // Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
519
- fzInputModel.value = getEmptyDisplayValue(
520
- expectedEmptyValue === 0,
521
- false, // Not focused during mount
522
- );
523
- return;
524
- }
525
-
526
- if (typeof initialValue === "number") {
527
- // Truncate decimals to maximumFractionDigits before updating v-model
528
- let processed = truncateDecimals(initialValue, props.maximumFractionDigits);
529
-
530
- // Apply step quantization if forceStep is enabled
531
- if (props.forceStep) {
532
- processed = roundTo(props.step, processed);
533
- }
534
-
535
- // Apply min/max constraints
536
- processed = clamp(props.min, processed, props.max);
537
-
538
- // Update v-model if processed value differs (to ensure v-model always respects max decimals and step quantization)
539
- if (processed !== initialValue) {
540
- isInternalUpdate = true;
541
- model.value = processed;
542
- isInternalUpdate = false;
543
- }
544
-
545
- // Format number to Italian format (comma as decimal separator)
546
- const formatted = formatValue(processed, {
547
- minimumFractionDigits: props.minimumFractionDigits,
548
- maximumFractionDigits: props.maximumFractionDigits,
549
- roundDecimals: false,
550
- useGrouping: true,
551
- });
552
- fzInputModel.value = formatted;
553
- return;
554
- }
555
-
556
- if (typeof initialValue === "string") {
557
- // Normalize string value (handles Italian format: "1.234,56" and shows deprecation warning)
558
- const normalized = normalizeModelValue(initialValue);
559
- if (normalized !== undefined && normalized !== null) {
560
- const parsed = normalized;
561
- // Truncate decimals to maximumFractionDigits before updating v-model
562
- let processed = truncateDecimals(parsed, props.maximumFractionDigits);
563
-
564
- // Apply step quantization if forceStep is enabled
565
- if (props.forceStep) {
566
- processed = roundTo(props.step, processed);
567
- }
568
-
569
- // Apply min/max constraints
570
- processed = clamp(props.min, processed, props.max);
571
-
572
- // Update v-model to number (this will trigger watch, but will be handled as number)
573
- isInternalUpdate = true;
574
- model.value = processed;
575
- isInternalUpdate = false;
576
- // Format and display
577
- const formatted = formatValue(processed, {
578
- minimumFractionDigits: props.minimumFractionDigits,
579
- maximumFractionDigits: props.maximumFractionDigits,
580
- roundDecimals: false,
581
- useGrouping: true,
582
- });
583
- fzInputModel.value = formatted;
584
- } else {
585
- // Invalid string, clear input
586
- const emptyValue = getEmptyValue();
587
- isInternalUpdate = true;
588
- model.value = emptyValue;
589
- isInternalUpdate = false;
590
-
591
- // Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
592
- fzInputModel.value = getEmptyDisplayValue(
593
- emptyValue === 0,
594
- false, // Not focused during mount
595
- );
596
- }
597
- return;
598
- }
599
- });
600
-
601
- /**
602
- * Syncs external v-model changes to fzInputModel
603
- *
604
- * Point 1: v-model undefined -> fzInputModel = "" (empty string), v-model stays undefined
605
- * Point 2: v-model number 1234.56 -> fzInputModel = "1234,56" (formatted), v-model stays 1234.56
606
- * Point 3: v-model string "1.234,56" -> fzInputModel = "1234,56" (formatted), v-model will be 1234.56
607
- *
608
- * Formats only when not focused (when focused, shows raw value for editing).
609
- */
610
- watch(
611
- () => model.value,
612
- (newVal) => {
613
- // Skip if this is an internal update (from handleInputUpdate)
614
- if (isInternalUpdate) {
615
- return;
616
- }
617
-
618
- if (newVal === undefined || newVal === null) {
619
- // Ensure v-model matches the expected empty value based on nullOnEmpty/zeroOnEmpty
620
- const expectedEmptyValue = getEmptyValue();
621
- if (newVal !== expectedEmptyValue) {
622
- isInternalUpdate = true;
623
- model.value = expectedEmptyValue;
624
- isInternalUpdate = false;
625
- }
626
-
627
- // Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
628
- fzInputModel.value = getEmptyDisplayValue(
629
- expectedEmptyValue === 0,
630
- isFocused.value,
631
- );
632
- return;
633
- }
634
-
635
- if (typeof newVal === "number") {
636
- // Truncate decimals to maximumFractionDigits before updating v-model
637
- let processed = truncateDecimals(newVal, props.maximumFractionDigits);
638
-
639
- // Apply step quantization if forceStep is enabled
640
- if (props.forceStep) {
641
- processed = roundTo(props.step, processed);
642
- }
643
-
644
- // Apply min/max constraints only when input is not focused
645
- // When focused, allow values outside range temporarily (clamping happens on blur)
646
- if (!isFocused.value) {
647
- processed = clamp(props.min, processed, props.max);
648
- }
649
-
650
- // Update v-model if processed value differs (to ensure v-model always respects max decimals and step quantization)
651
- if (processed !== newVal) {
652
- isInternalUpdate = true;
653
- model.value = processed;
654
- isInternalUpdate = false;
655
- }
656
-
657
- // Format number to Italian format (comma as decimal separator)
658
- // But only if not focused (when focused, show raw value)
659
- if (!isFocused.value) {
660
- const formatted = formatValue(processed, {
661
- minimumFractionDigits: props.minimumFractionDigits,
662
- maximumFractionDigits: props.maximumFractionDigits,
663
- roundDecimals: false,
664
- useGrouping: true,
665
- });
666
- fzInputModel.value = formatted;
667
- }
668
- return;
669
- }
670
-
671
- if (typeof newVal === "string") {
672
- // Normalize string value (handles Italian format: "1.234,56" and shows deprecation warning)
673
- const normalized = normalizeModelValue(newVal);
674
- if (normalized !== undefined && normalized !== null) {
675
- const parsed = normalized;
676
- // Truncate decimals to maximumFractionDigits before updating v-model
677
- let processed = truncateDecimals(parsed, props.maximumFractionDigits);
678
-
679
- // Apply step quantization if forceStep is enabled
680
- if (props.forceStep) {
681
- processed = roundTo(props.step, processed);
682
- }
683
-
684
- // Apply min/max constraints only when input is not focused
685
- // When focused, allow values outside range temporarily (clamping happens on blur)
686
- if (!isFocused.value) {
687
- processed = clamp(props.min, processed, props.max);
688
- }
689
-
690
- // Update v-model to number (this will trigger watch again, but will be handled as number)
691
- isInternalUpdate = true;
692
- model.value = processed;
693
- isInternalUpdate = false;
694
- // Format and display (only if not focused)
695
- if (!isFocused.value) {
696
- const formatted = formatValue(processed, {
697
- minimumFractionDigits: props.minimumFractionDigits,
698
- maximumFractionDigits: props.maximumFractionDigits,
699
- roundDecimals: false,
700
- useGrouping: true,
701
- });
702
- fzInputModel.value = formatted;
703
- }
704
- } else {
705
- // Invalid string, clear input
706
- const emptyValue = getEmptyValue();
707
- isInternalUpdate = true;
708
- model.value = emptyValue;
709
- isInternalUpdate = false;
710
-
711
- // Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
712
- fzInputModel.value = getEmptyDisplayValue(
713
- emptyValue === 0,
714
- isFocused.value,
715
- );
716
- }
717
- return;
718
- }
43
+ const innerModel = computed({
44
+ get: () => model.value as number | null | undefined,
45
+ set: (value: number | null | undefined) => {
46
+ model.value = value;
719
47
  },
720
- );
48
+ });
721
49
 
722
50
  defineExpose({
723
51
  inputRef,
@@ -729,13 +57,8 @@ defineExpose({
729
57
  <FzInput
730
58
  ref="fzInputRef"
731
59
  v-bind="props"
732
- :modelValue="fzInputModel"
733
- type="text"
734
- @update:modelValue="handleInputUpdate"
735
- @keydown="handleKeydown"
736
- @focus="handleFocus"
737
- @blur="handleBlur"
738
- @paste="handlePaste"
60
+ type="currency"
61
+ v-model="innerModel"
739
62
  @fzinput:clear="emit('fzcurrencyinput:clear')"
740
63
  >
741
64
  <template v-if="$slots.label" #label>
@@ -744,55 +67,6 @@ defineExpose({
744
67
  <template v-if="$slots['left-icon']" #left-icon>
745
68
  <slot name="left-icon"></slot>
746
69
  </template>
747
- <template #right-icon>
748
- <div class="flex items-center gap-4">
749
- <FzIcon
750
- v-if="props.valid"
751
- name="check"
752
- size="md"
753
- class="text-semantic-success"
754
- aria-hidden="true"
755
- />
756
- <div class="flex flex-col justify-between items-center">
757
- <FzIcon
758
- name="angle-up"
759
- size="xs"
760
- role="button"
761
- :aria-label="stepUpAriaLabel"
762
- :aria-disabled="isStepDisabled ? 'true' : undefined"
763
- :tabindex="isStepDisabled ? undefined : '0'"
764
- class="fz__currencyinput__arrowup cursor-pointer"
765
- @click="handleStepUp"
766
- @keydown="
767
- (e: KeyboardEvent) => {
768
- if ((e.key === 'Enter' || e.key === ' ') && !isStepDisabled) {
769
- e.preventDefault();
770
- handleStepUp();
771
- }
772
- }
773
- "
774
- ></FzIcon>
775
- <FzIcon
776
- name="angle-down"
777
- size="xs"
778
- role="button"
779
- :aria-label="stepDownAriaLabel"
780
- :aria-disabled="isStepDisabled ? 'true' : undefined"
781
- :tabindex="isStepDisabled ? undefined : '0'"
782
- class="fz__currencyinput__arrowdown cursor-pointer"
783
- @click="handleStepDown"
784
- @keydown="
785
- (e: KeyboardEvent) => {
786
- if ((e.key === 'Enter' || e.key === ' ') && !isStepDisabled) {
787
- e.preventDefault();
788
- handleStepDown();
789
- }
790
- }
791
- "
792
- ></FzIcon>
793
- </div>
794
- </div>
795
- </template>
796
70
  <template v-if="$slots.helpText" #helpText>
797
71
  <slot name="helpText"></slot>
798
72
  </template>