@fiscozen/input 3.4.3 → 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 +26 -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 +5 -5
- 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
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { onMounted, ref, watch, type ComputedRef, type Ref } from "vue";
|
|
2
|
+
import {
|
|
3
|
+
clamp,
|
|
4
|
+
format as formatValue,
|
|
5
|
+
parse,
|
|
6
|
+
roundTo,
|
|
7
|
+
truncateDecimals,
|
|
8
|
+
} from "@fiscozen/composables";
|
|
9
|
+
import { parseClipboardNumber } from "./utils";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Subset of FzInput props the currency behavior depends on.
|
|
13
|
+
* Matches the prop names/semantics of the former FzCurrencyInput component.
|
|
14
|
+
*/
|
|
15
|
+
export interface UseCurrencyInputProps {
|
|
16
|
+
/** Minimum allowed value. Values below this are clamped to min */
|
|
17
|
+
min?: number;
|
|
18
|
+
/** Maximum allowed value. Values above this are clamped to max */
|
|
19
|
+
max?: number;
|
|
20
|
+
/** Step increment for arrow buttons. When forceStep is true, values are rounded to nearest step multiple */
|
|
21
|
+
step?: number;
|
|
22
|
+
/** Enforces quantization: values are automatically rounded to nearest step multiple */
|
|
23
|
+
forceStep?: boolean;
|
|
24
|
+
/** Minimum decimal places in formatted output */
|
|
25
|
+
minimumFractionDigits?: number;
|
|
26
|
+
/** Maximum decimal places in formatted output */
|
|
27
|
+
maximumFractionDigits?: number;
|
|
28
|
+
/** Converts empty input to null instead of undefined */
|
|
29
|
+
nullOnEmpty?: boolean;
|
|
30
|
+
/** Converts empty input to 0 instead of undefined */
|
|
31
|
+
zeroOnEmpty?: boolean;
|
|
32
|
+
/** Native readonly attribute */
|
|
33
|
+
readonly?: boolean;
|
|
34
|
+
/** Disables input interaction */
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseCurrencyInputOptions {
|
|
39
|
+
/** Reactive props object (the component's props proxy) */
|
|
40
|
+
props: UseCurrencyInputProps;
|
|
41
|
+
/**
|
|
42
|
+
* The numeric v-model ref. Accepts `number | string | null | undefined` for
|
|
43
|
+
* retrocompatibility, but the composable always writes `number | null | undefined`.
|
|
44
|
+
*/
|
|
45
|
+
model: Ref<number | string | null | undefined>;
|
|
46
|
+
/** Whether currency behavior is active (i.e. FzInput type === "currency") */
|
|
47
|
+
enabled: ComputedRef<boolean>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Currency input behavior for FzInput with `type="currency"`.
|
|
52
|
+
*
|
|
53
|
+
* Manages a display string (`displayValue`) bound to the native input and keeps it
|
|
54
|
+
* in sync with the numeric v-model: number formatting via Intl.NumberFormat with
|
|
55
|
+
* Italian locale separators, input normalization/filtering, min/max clamping and
|
|
56
|
+
* step quantization. Formatting and quantization happen on blur; while focused the
|
|
57
|
+
* raw value is shown for editing.
|
|
58
|
+
*
|
|
59
|
+
* Ported from the FzCurrencyInput component, which is now a thin deprecated
|
|
60
|
+
* wrapper around `<FzInput type="currency">`.
|
|
61
|
+
*/
|
|
62
|
+
export default function useCurrencyInput(options: UseCurrencyInputOptions) {
|
|
63
|
+
const { props, model, enabled } = options;
|
|
64
|
+
|
|
65
|
+
/** Display string bound to the native input element */
|
|
66
|
+
const displayValue = ref<string | undefined>();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Focus state tracked independently from FzInput's own focus flag: it is only
|
|
70
|
+
* set when the input is interactive (not readonly/disabled), mirroring the
|
|
71
|
+
* former FzCurrencyInput semantics used by the formatting logic.
|
|
72
|
+
*/
|
|
73
|
+
const isFocused = ref(false);
|
|
74
|
+
|
|
75
|
+
let isInternalUpdate = false;
|
|
76
|
+
|
|
77
|
+
const minValue = () => props.min ?? -Infinity;
|
|
78
|
+
const maxValue = () => props.max ?? Infinity;
|
|
79
|
+
const stepValue = () => props.step ?? 1;
|
|
80
|
+
const minFractionDigits = () => props.minimumFractionDigits ?? 2;
|
|
81
|
+
const maxFractionDigits = () => props.maximumFractionDigits ?? 2;
|
|
82
|
+
|
|
83
|
+
/** Writes the model flagging the update as internal so the model watcher can skip it */
|
|
84
|
+
const setModel = (value: number | null | undefined) => {
|
|
85
|
+
isInternalUpdate = true;
|
|
86
|
+
model.value = value;
|
|
87
|
+
isInternalUpdate = false;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Formats a number to Italian format (comma decimal separator, point thousand separator) */
|
|
91
|
+
const formatForDisplay = (value: number | null | undefined): string =>
|
|
92
|
+
formatValue(value, {
|
|
93
|
+
minimumFractionDigits: minFractionDigits(),
|
|
94
|
+
maximumFractionDigits: maxFractionDigits(),
|
|
95
|
+
roundDecimals: false,
|
|
96
|
+
useGrouping: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Determines the value to emit when input is empty based on nullOnEmpty and zeroOnEmpty props
|
|
101
|
+
*
|
|
102
|
+
* Priority: nullOnEmpty > zeroOnEmpty > undefined
|
|
103
|
+
*
|
|
104
|
+
* @returns null if nullOnEmpty is true, 0 if zeroOnEmpty is true, undefined otherwise
|
|
105
|
+
*/
|
|
106
|
+
const getEmptyValue = (): number | null | undefined => {
|
|
107
|
+
if (props.nullOnEmpty) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (props.zeroOnEmpty) {
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Determines the display value when input is empty
|
|
118
|
+
*
|
|
119
|
+
* When zeroOnEmpty is true and the empty value is 0:
|
|
120
|
+
* - During typing (focused): returns empty string (formatting happens on blur)
|
|
121
|
+
* - On blur (not focused): returns formatted "0,00"
|
|
122
|
+
*
|
|
123
|
+
* Otherwise returns empty string.
|
|
124
|
+
*
|
|
125
|
+
* @param isEmptyValueZero - Whether the empty value is 0 (from getEmptyValue())
|
|
126
|
+
* @param isCurrentlyFocused - Whether the input is currently focused
|
|
127
|
+
* @returns Display string to show in the input field
|
|
128
|
+
*/
|
|
129
|
+
const getEmptyDisplayValue = (
|
|
130
|
+
isEmptyValueZero: boolean,
|
|
131
|
+
isCurrentlyFocused: boolean,
|
|
132
|
+
): string => {
|
|
133
|
+
if (isEmptyValueZero) {
|
|
134
|
+
// During typing, show empty string. Formatting happens on blur
|
|
135
|
+
if (isCurrentlyFocused) {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
// On blur or when not focused, show formatted zero
|
|
139
|
+
return formatForDisplay(0);
|
|
140
|
+
}
|
|
141
|
+
return "";
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validates and normalizes user input
|
|
146
|
+
*
|
|
147
|
+
* Allows only digits, "." and ",". Converts "." to ",".
|
|
148
|
+
* Allows minus sign only at the beginning for negative values.
|
|
149
|
+
* Handles double comma case: "123,45" -> "12,3,45" -> "12,34"
|
|
150
|
+
* (keeps only the first comma, everything after becomes decimal part)
|
|
151
|
+
*
|
|
152
|
+
* @param inputValue - Raw input value from user
|
|
153
|
+
* @returns Normalized value with only one comma and optional leading minus sign
|
|
154
|
+
*/
|
|
155
|
+
const normalizeInput = (inputValue: string): string => {
|
|
156
|
+
// Allow only digits, "." "," and "-"
|
|
157
|
+
let filtered = inputValue.replace(/[^0-9.,-]/g, "");
|
|
158
|
+
|
|
159
|
+
// Check if minus sign is at the beginning (after removing invalid chars)
|
|
160
|
+
const hasLeadingMinus = filtered.startsWith("-");
|
|
161
|
+
|
|
162
|
+
// Remove all minus signs (we'll reattach only one at the beginning if needed)
|
|
163
|
+
filtered = filtered.replace(/-/g, "");
|
|
164
|
+
|
|
165
|
+
// Convert "." to ","
|
|
166
|
+
filtered = filtered.replace(/\./g, ",");
|
|
167
|
+
|
|
168
|
+
// Handle multiple commas: keep only the first one
|
|
169
|
+
const firstCommaIndex = filtered.indexOf(",");
|
|
170
|
+
if (firstCommaIndex !== -1) {
|
|
171
|
+
// Keep everything before first comma + first comma + everything after first comma (remove other commas)
|
|
172
|
+
const beforeComma = filtered.substring(0, firstCommaIndex);
|
|
173
|
+
const afterComma = filtered
|
|
174
|
+
.substring(firstCommaIndex + 1)
|
|
175
|
+
.replace(/,/g, "");
|
|
176
|
+
filtered = beforeComma + "," + afterComma;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reattach minus sign at the beginning if it was present at the start
|
|
180
|
+
return hasLeadingMinus ? "-" + filtered : filtered;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Normalizes model value to number | undefined | null
|
|
185
|
+
*
|
|
186
|
+
* Converts string values to numbers (with deprecation warning) and handles
|
|
187
|
+
* null/undefined/empty string cases.
|
|
188
|
+
*
|
|
189
|
+
* @param value - Input value (number, string, undefined, or null)
|
|
190
|
+
* @returns Normalized number value, undefined, or null
|
|
191
|
+
*/
|
|
192
|
+
const normalizeModelValue = (
|
|
193
|
+
value: number | string | undefined | null,
|
|
194
|
+
): number | undefined | null => {
|
|
195
|
+
if (value === undefined || value === null || value === "") {
|
|
196
|
+
return value === null ? null : undefined;
|
|
197
|
+
}
|
|
198
|
+
if (typeof value === "number") {
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
if (typeof value === "string") {
|
|
202
|
+
// The "[FzCurrencyInput]" tag is kept on purpose: existing consumers and
|
|
203
|
+
// tests assert on it. It will switch to "[FzInput]" once FzCurrencyInput
|
|
204
|
+
// is removed at the end of the migration.
|
|
205
|
+
console.warn(
|
|
206
|
+
"[FzCurrencyInput] String values in v-model are deprecated. Please use number instead. " +
|
|
207
|
+
`Received: "${value}". This will be parsed to a number for retrocompatibility, but string support may be removed in a future version.`,
|
|
208
|
+
);
|
|
209
|
+
const parsed = parse(value);
|
|
210
|
+
return isNaN(parsed) ? undefined : parsed;
|
|
211
|
+
}
|
|
212
|
+
return undefined;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Prevents invalid characters from being typed
|
|
217
|
+
*
|
|
218
|
+
* Allows only digits, "." and ",". Allows minus sign only at the beginning.
|
|
219
|
+
* Blocks all other characters.
|
|
220
|
+
* Also allows control keys (Backspace, Delete, Arrow keys, Tab, etc.)
|
|
221
|
+
* Multiple commas are handled by normalizeInput.
|
|
222
|
+
*
|
|
223
|
+
* @param e - Keyboard event
|
|
224
|
+
*/
|
|
225
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
226
|
+
// Allow control keys (Backspace, Delete, Arrow keys, Tab, etc.)
|
|
227
|
+
if (
|
|
228
|
+
e.ctrlKey ||
|
|
229
|
+
e.metaKey ||
|
|
230
|
+
e.altKey ||
|
|
231
|
+
[
|
|
232
|
+
"Backspace",
|
|
233
|
+
"Delete",
|
|
234
|
+
"ArrowLeft",
|
|
235
|
+
"ArrowRight",
|
|
236
|
+
"ArrowUp",
|
|
237
|
+
"ArrowDown",
|
|
238
|
+
"Tab",
|
|
239
|
+
"Enter",
|
|
240
|
+
"Home",
|
|
241
|
+
"End",
|
|
242
|
+
].includes(e.key)
|
|
243
|
+
) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Allow minus sign only at the beginning (position 0) or when entire value is selected
|
|
248
|
+
if (e.key === "-") {
|
|
249
|
+
const target = e.target as HTMLInputElement;
|
|
250
|
+
const cursorPosition = target.selectionStart ?? 0;
|
|
251
|
+
const selectionLength = (target.selectionEnd ?? 0) - cursorPosition;
|
|
252
|
+
const valueLength = target.value.length;
|
|
253
|
+
|
|
254
|
+
// Allow minus if:
|
|
255
|
+
// 1. Cursor is at position 0 (beginning)
|
|
256
|
+
// 2. Entire value is selected (user can replace with negative)
|
|
257
|
+
if (cursorPosition !== 0 && selectionLength !== valueLength) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Allow only digits, "." and ","
|
|
264
|
+
if (!/^[0-9.,]$/.test(e.key)) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Handles paste event to replace entire input value
|
|
271
|
+
*
|
|
272
|
+
* Prevents default paste behavior and replaces the entire input value with the pasted content.
|
|
273
|
+
* Uses parse() to handle Italian format (e.g., "1.234,56"). If the pasted text is not a valid number,
|
|
274
|
+
* the paste is ignored.
|
|
275
|
+
*/
|
|
276
|
+
const handlePaste = (e: ClipboardEvent) => {
|
|
277
|
+
if (props.readonly || props.disabled) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
|
|
283
|
+
const pastedText = e.clipboardData?.getData("text") || "";
|
|
284
|
+
if (!pastedText) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Shared clipboard parser (handles Italian format, returns null if invalid)
|
|
289
|
+
const parsed = parseClipboardNumber(pastedText);
|
|
290
|
+
|
|
291
|
+
if (parsed !== null) {
|
|
292
|
+
// Truncate decimals to maximumFractionDigits
|
|
293
|
+
const processed = truncateDecimals(parsed, maxFractionDigits());
|
|
294
|
+
|
|
295
|
+
setModel(processed);
|
|
296
|
+
|
|
297
|
+
// Convert number to normalized string format for display (e.g., 1234.56 -> "1234,56")
|
|
298
|
+
displayValue.value = normalizeInput(String(processed));
|
|
299
|
+
}
|
|
300
|
+
// If invalid, ignore paste (do nothing)
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Handles display string updates coming from the native input
|
|
305
|
+
*
|
|
306
|
+
* Validates and normalizes input, updates v-model with parsed number.
|
|
307
|
+
* Does NOT format the display value - shows raw input (e.g., "123" stays "123", not "123,00").
|
|
308
|
+
* Does NOT apply step quantization - quantization happens only on blur.
|
|
309
|
+
* Formatting and quantization happen only on blur.
|
|
310
|
+
*/
|
|
311
|
+
const handleDisplayUpdate = (newValue: string | undefined) => {
|
|
312
|
+
if (!newValue) {
|
|
313
|
+
setModel(getEmptyValue());
|
|
314
|
+
|
|
315
|
+
// During typing, always show empty string. Formatting to "0,00" happens only on blur
|
|
316
|
+
displayValue.value = "";
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const normalized = normalizeInput(newValue);
|
|
321
|
+
displayValue.value = normalized;
|
|
322
|
+
|
|
323
|
+
// Parse to number and update v-model (but don't format display - keep raw)
|
|
324
|
+
const parsed = parse(normalized);
|
|
325
|
+
if (!isNaN(parsed) && isFinite(parsed)) {
|
|
326
|
+
// Truncate decimals to maximumFractionDigits before updating v-model
|
|
327
|
+
setModel(truncateDecimals(parsed, maxFractionDigits()));
|
|
328
|
+
} else {
|
|
329
|
+
// If invalid, keep the normalized string but don't update v-model
|
|
330
|
+
setModel(getEmptyValue());
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Handles blur event to format the value
|
|
336
|
+
*
|
|
337
|
+
* Formats the value to Italian format (e.g., "123" -> "123,00", "123,4" -> "123,40").
|
|
338
|
+
* Applies step quantization if forceStep is enabled (quantization happens only on blur, not during typing).
|
|
339
|
+
*/
|
|
340
|
+
const handleBlur = () => {
|
|
341
|
+
if (props.readonly || props.disabled) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
isFocused.value = false;
|
|
346
|
+
|
|
347
|
+
const currentValue = normalizeModelValue(model.value);
|
|
348
|
+
if (currentValue === undefined || currentValue === null) {
|
|
349
|
+
// Ensure v-model matches the expected empty value based on nullOnEmpty/zeroOnEmpty
|
|
350
|
+
const expectedEmptyValue = getEmptyValue();
|
|
351
|
+
if (model.value !== expectedEmptyValue) {
|
|
352
|
+
setModel(expectedEmptyValue);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
|
|
356
|
+
displayValue.value = getEmptyDisplayValue(
|
|
357
|
+
expectedEmptyValue === 0,
|
|
358
|
+
false, // Not focused during blur
|
|
359
|
+
);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Apply step quantization if forceStep is enabled
|
|
364
|
+
let processed = currentValue;
|
|
365
|
+
if (props.forceStep) {
|
|
366
|
+
processed = roundTo(stepValue(), processed);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Apply min/max constraints
|
|
370
|
+
processed = clamp(minValue(), processed, maxValue());
|
|
371
|
+
|
|
372
|
+
// Update v-model if processed value differs
|
|
373
|
+
if (processed !== currentValue) {
|
|
374
|
+
setModel(processed);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Format the value for display
|
|
378
|
+
displayValue.value = formatForDisplay(processed);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Handles focus event
|
|
383
|
+
*
|
|
384
|
+
* When input gains focus, shows raw value (without formatting)
|
|
385
|
+
*/
|
|
386
|
+
const handleFocus = () => {
|
|
387
|
+
if (props.readonly || props.disabled) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
isFocused.value = true;
|
|
392
|
+
|
|
393
|
+
// Convert formatted value back to raw for editing
|
|
394
|
+
const currentValue = normalizeModelValue(model.value);
|
|
395
|
+
if (currentValue !== undefined) {
|
|
396
|
+
// Get raw value from formatted: remove thousand separators, keep decimal separator
|
|
397
|
+
const formatted = formatForDisplay(currentValue);
|
|
398
|
+
displayValue.value = formatted.replace(/\./g, ""); // Remove thousand separators
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Steps the value up or down by the step amount, applying truncation and clamping.
|
|
404
|
+
* If value is undefined/null, starts from 0.
|
|
405
|
+
*/
|
|
406
|
+
const stepBy = (direction: 1 | -1) => {
|
|
407
|
+
if (props.readonly || props.disabled) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const currentValue = normalizeModelValue(model.value);
|
|
412
|
+
const baseValue =
|
|
413
|
+
currentValue === undefined || currentValue === null ? 0 : currentValue;
|
|
414
|
+
|
|
415
|
+
const newValue = baseValue + direction * stepValue();
|
|
416
|
+
|
|
417
|
+
// Truncate decimals
|
|
418
|
+
const truncated = truncateDecimals(newValue, maxFractionDigits());
|
|
419
|
+
|
|
420
|
+
// Apply min/max constraints
|
|
421
|
+
const clamped = clamp(minValue(), truncated, maxValue());
|
|
422
|
+
|
|
423
|
+
setModel(clamped);
|
|
424
|
+
|
|
425
|
+
// Format and update display
|
|
426
|
+
displayValue.value = formatForDisplay(clamped);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Syncs the display string (and re-processes the model) from a model value.
|
|
431
|
+
*
|
|
432
|
+
* Used both on mount and when the v-model changes externally:
|
|
433
|
+
* - undefined/null -> normalized to the expected empty value, display empty (or "0,00" with zeroOnEmpty)
|
|
434
|
+
* - number -> truncated to max decimals, step-quantized (forceStep), clamped (only when not
|
|
435
|
+
* focused) and formatted to Italian format (only when not focused: when focused the raw
|
|
436
|
+
* value stays in place for editing)
|
|
437
|
+
* - string -> parsed with deprecation warning, then handled as number; invalid strings clear the input
|
|
438
|
+
*
|
|
439
|
+
* @param newVal - The model value to sync from
|
|
440
|
+
* @param focused - Whether the input is currently focused
|
|
441
|
+
*/
|
|
442
|
+
const syncFromModel = (
|
|
443
|
+
newVal: number | string | null | undefined,
|
|
444
|
+
focused: boolean,
|
|
445
|
+
) => {
|
|
446
|
+
if (newVal === undefined || newVal === null) {
|
|
447
|
+
// Ensure v-model matches the expected empty value based on nullOnEmpty/zeroOnEmpty
|
|
448
|
+
const expectedEmptyValue = getEmptyValue();
|
|
449
|
+
if (newVal !== expectedEmptyValue) {
|
|
450
|
+
setModel(expectedEmptyValue);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Display empty value (formatted zero if zeroOnEmpty is true, empty string otherwise)
|
|
454
|
+
displayValue.value = getEmptyDisplayValue(
|
|
455
|
+
expectedEmptyValue === 0,
|
|
456
|
+
focused,
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Normalize string values (handles Italian format "1.234,56" and shows deprecation warning)
|
|
462
|
+
const normalized = normalizeModelValue(newVal);
|
|
463
|
+
if (normalized === undefined || normalized === null) {
|
|
464
|
+
// Invalid string, clear input
|
|
465
|
+
const emptyValue = getEmptyValue();
|
|
466
|
+
setModel(emptyValue);
|
|
467
|
+
|
|
468
|
+
displayValue.value = getEmptyDisplayValue(emptyValue === 0, focused);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Truncate decimals to maximumFractionDigits before updating v-model
|
|
473
|
+
let processed = truncateDecimals(normalized, maxFractionDigits());
|
|
474
|
+
|
|
475
|
+
// Apply step quantization if forceStep is enabled
|
|
476
|
+
if (props.forceStep) {
|
|
477
|
+
processed = roundTo(stepValue(), processed);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Apply min/max constraints only when input is not focused
|
|
481
|
+
// When focused, allow values outside range temporarily (clamping happens on blur)
|
|
482
|
+
if (!focused) {
|
|
483
|
+
processed = clamp(minValue(), processed, maxValue());
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Update v-model if processed value differs (to ensure v-model always respects
|
|
487
|
+
// max decimals and step quantization)
|
|
488
|
+
if (processed !== newVal) {
|
|
489
|
+
setModel(processed);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Format for display, but only if not focused (when focused, show raw value)
|
|
493
|
+
if (!focused) {
|
|
494
|
+
displayValue.value = formatForDisplay(processed);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Initializes the display value from the v-model on mount (and when currency
|
|
500
|
+
* mode is enabled dynamically by switching the input type).
|
|
501
|
+
*/
|
|
502
|
+
onMounted(() => {
|
|
503
|
+
if (enabled.value) {
|
|
504
|
+
syncFromModel(model.value, false);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
watch(enabled, (isEnabled) => {
|
|
509
|
+
if (isEnabled) {
|
|
510
|
+
syncFromModel(model.value, false);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Syncs external v-model changes to the display value.
|
|
516
|
+
* Skips updates flagged as internal (originated from this composable).
|
|
517
|
+
*/
|
|
518
|
+
watch(
|
|
519
|
+
() => model.value,
|
|
520
|
+
(newVal) => {
|
|
521
|
+
if (!enabled.value || isInternalUpdate) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
syncFromModel(newVal, isFocused.value);
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
displayValue,
|
|
530
|
+
handleDisplayUpdate,
|
|
531
|
+
handleKeydown,
|
|
532
|
+
handlePaste,
|
|
533
|
+
handleFocus,
|
|
534
|
+
handleBlur,
|
|
535
|
+
stepBy,
|
|
536
|
+
};
|
|
537
|
+
}
|
package/src/useInputStyle.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { computed, ToRefs, Ref, ComputedRef } from "vue";
|
|
2
2
|
import { FzInputProps, type InputEnvironment } from "./types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The subset of FzInput props that drive styling. Styling is independent of the
|
|
6
|
+
* input `type`, so this is intentionally decoupled from `FzInputProps<TType>`'s
|
|
7
|
+
* generic parameter — only these visual-state props are read here.
|
|
8
|
+
*/
|
|
9
|
+
type FzInputStyleProps = Pick<
|
|
10
|
+
FzInputProps,
|
|
11
|
+
"variant" | "disabled" | "readonly" | "error" | "highlighted" | "aiReasoning"
|
|
12
|
+
>;
|
|
13
|
+
|
|
4
14
|
/**
|
|
5
15
|
* Composable for managing FzInput component styles and computed classes
|
|
6
16
|
*
|
|
7
17
|
* Handles dynamic styling based on props, environment, variant, and state.
|
|
8
18
|
* Returns computed classes for container, label, input, help text, and error messages.
|
|
9
19
|
*
|
|
10
|
-
* @param props - Reactive props from FzInput component
|
|
20
|
+
* @param props - Reactive styling props from FzInput component
|
|
11
21
|
* @param container - Reference to container DOM element
|
|
12
22
|
* @param model - Reactive model value (string | undefined)
|
|
13
23
|
* @param effectiveEnvironment - Computed effective environment (backoffice | frontoffice)
|
|
@@ -15,7 +25,7 @@ import { FzInputProps, type InputEnvironment } from "./types";
|
|
|
15
25
|
* @returns Object containing computed classes and style-related properties
|
|
16
26
|
*/
|
|
17
27
|
export default function useInputStyle(
|
|
18
|
-
props: ToRefs<
|
|
28
|
+
props: ToRefs<FzInputStyleProps>,
|
|
19
29
|
container: Ref<HTMLElement | null>,
|
|
20
30
|
model: Ref<string | undefined>,
|
|
21
31
|
effectiveEnvironment: ComputedRef<InputEnvironment>,
|
package/src/utils.ts
CHANGED
|
@@ -4,10 +4,39 @@
|
|
|
4
4
|
* @module @fiscozen/input/utils
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { parse } from "@fiscozen/composables";
|
|
7
8
|
import type { InputEnvironment } from "./types";
|
|
8
9
|
|
|
9
10
|
type InputSize = "sm" | "md" | "lg";
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* HTML "valid floating-point number" grammar: the only content a native
|
|
14
|
+
* `<input type="number">` is guaranteed to accept wholesale across browsers.
|
|
15
|
+
*
|
|
16
|
+
* @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number
|
|
17
|
+
*/
|
|
18
|
+
const NATIVE_FLOAT_RE = /^-?\d+(\.\d+)?([eE][-+]?\d+)?$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether `text` matches the native floating-point grammar exactly (no
|
|
22
|
+
* trimming: padded or grouped content is rejected by some browsers and
|
|
23
|
+
* blanked out by others, so it cannot be considered natively safe).
|
|
24
|
+
*/
|
|
25
|
+
export const isNativeFloatString = (text: string): boolean =>
|
|
26
|
+
NATIVE_FLOAT_RE.test(text);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses clipboard text into a finite number, accepting the same formats as
|
|
30
|
+
* currency-mode paste (Italian "1.234,56", comma decimals, padded whitespace).
|
|
31
|
+
*
|
|
32
|
+
* @returns The parsed number, or null when the text cannot be interpreted as
|
|
33
|
+
* a finite number
|
|
34
|
+
*/
|
|
35
|
+
export const parseClipboardNumber = (text: string): number | null => {
|
|
36
|
+
const parsed = parse(text);
|
|
37
|
+
return isNaN(parsed) || !isFinite(parsed) ? null : parsed;
|
|
38
|
+
};
|
|
39
|
+
|
|
11
40
|
/**
|
|
12
41
|
* Maps deprecated InputSize to InputEnvironment
|
|
13
42
|
*
|