@fiscozen/input 0.1.17 → 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.
- package/README.md +718 -1
- package/coverage/FzCurrencyInput.vue.html +640 -0
- package/coverage/FzInput.vue.html +709 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +494 -0
- package/coverage/coverage-final.json +4 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +146 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +196 -0
- package/coverage/useInputStyle.ts.html +343 -0
- package/dist/input.js +5282 -2948
- package/dist/input.umd.cjs +15 -10
- package/dist/src/FzCurrencyInput.vue.d.ts +82 -46
- package/dist/src/FzInput.vue.d.ts +141 -42
- package/dist/src/index.d.ts +1 -0
- package/dist/src/types.d.ts +172 -36
- package/dist/src/useInputStyle.d.ts +23 -8
- package/dist/src/utils.d.ts +21 -0
- package/dist/style.css +1 -0
- package/package.json +6 -6
- package/src/FzCurrencyInput.vue +746 -106
- package/src/FzInput.vue +467 -97
- package/src/__tests__/FzCurrencyInput.test.ts +1528 -0
- package/src/__tests__/FzInput.test.ts +1005 -0
- package/src/index.ts +3 -0
- package/src/types.ts +171 -46
- package/src/useInputStyle.ts +96 -38
- package/src/utils.ts +64 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/__tests__/FzCurrencyInput.spec.ts +0 -204
- package/src/__tests__/FzInput.spec.ts +0 -181
package/src/FzCurrencyInput.vue
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
const pastedText = e.clipboardData?.getData("text") || "";
|
|
240
|
+
if (!pastedText) {
|
|
66
241
|
return;
|
|
67
242
|
}
|
|
68
243
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
unknownSeparator = separators[0];
|
|
109
|
-
const splitted = rawPastedText.split(unknownSeparator);
|
|
315
|
+
isFocused.value = false;
|
|
110
316
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
453
|
+
fzInputModel.value = formatted;
|
|
454
|
+
};
|
|
135
455
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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>
|