@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.
@@ -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
+ }
@@ -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<FzInputProps>,
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
  *