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