@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.
- package/CHANGELOG.md +19 -0
- package/dist/input.js +428 -433
- package/dist/input.umd.cjs +1 -1
- package/dist/src/FzCurrencyInput.vue.d.ts +5 -360
- package/dist/src/FzInput.vue.d.ts +26 -61
- package/dist/src/index.d.ts +4 -0
- package/dist/src/types.d.ts +97 -54
- package/dist/src/useCurrencyInput.d.ts +59 -0
- package/dist/src/useInputStyle.d.ts +9 -2
- package/dist/src/utils.d.ts +14 -0
- package/package.json +3 -3
- package/src/FzCurrencyInput.vue +21 -747
- package/src/FzInput.vue +226 -27
- package/src/__tests__/FzCurrencyInput.spec.ts +81 -0
- package/src/__tests__/FzInput.spec.ts +743 -1
- package/src/index.ts +4 -0
- package/src/types.ts +111 -54
- package/src/useCurrencyInput.ts +537 -0
- package/src/useInputStyle.ts +12 -2
- package/src/utils.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/FzCurrencyInput.vue
CHANGED
|
@@ -2,44 +2,30 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* FzCurrencyInput Component
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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>
|