@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/src/FzInput.vue CHANGED
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="TType extends FzInputType = 'text'">
2
2
  /**
3
3
  * FzInput Component
4
4
  *
@@ -6,22 +6,39 @@
6
6
  * Supports left/right icons (static or clickable buttons), floating label variant,
7
7
  * error/valid states, and full accessibility features.
8
8
  *
9
+ * With `type="currency"` the v-model is numeric (`number | null | undefined`) and the
10
+ * input applies locale-aware currency formatting, min/max clamping and step controls.
11
+ * With `type="number"` the native spinners are replaced by the same step controls,
12
+ * and pasted content the native input would reject (e.g. Italian-formatted
13
+ * "1.234,56") is normalized to a plain decimal instead of being lost.
14
+ *
9
15
  * @component
10
16
  * @example
11
17
  * <FzInput label="Email" type="email" v-model="email" />
12
18
  * <FzInput label="Password" type="password" rightIcon="eye" @fzinput:right-icon-click="toggleVisibility" />
19
+ * <FzInput label="Amount" type="currency" v-model="amount" :min="0" />
13
20
  */
14
21
  import { computed, toRefs, Ref, ref, watch, useSlots, useAttrs } from "vue";
15
- import { FzInputProps, type InputEnvironment } from "./types";
22
+ import {
23
+ FzInputProps,
24
+ type FzInputModelValue,
25
+ type FzInputType,
26
+ type InputEnvironment,
27
+ } from "./types";
16
28
  import { FzAlert } from "@fiscozen/alert";
17
29
  import { FzIcon } from "@fiscozen/icons";
18
30
  import { FzIconButton } from "@fiscozen/button";
19
31
  import useInputStyle from "./useInputStyle";
20
- import { generateInputId, sizeToEnvironmentMapping } from "./utils";
21
-
22
- const props = withDefaults(defineProps<FzInputProps>(), {
32
+ import useCurrencyInput from "./useCurrencyInput";
33
+ import {
34
+ generateInputId,
35
+ isNativeFloatString,
36
+ parseClipboardNumber,
37
+ sizeToEnvironmentMapping,
38
+ } from "./utils";
39
+
40
+ const props = withDefaults(defineProps<FzInputProps<TType>>(), {
23
41
  error: false,
24
- type: "text",
25
42
  rightIconButtonVariant: "invisible",
26
43
  secondRightIconButtonVariant: "invisible",
27
44
  variant: "normal",
@@ -34,6 +51,9 @@
34
51
  disableEmphasisReset: false,
35
52
  clearable: false,
36
53
  clearAriaLabel: "Cancella",
54
+ step: 1,
55
+ minimumFractionDigits: 2,
56
+ maximumFractionDigits: 2,
37
57
  });
38
58
 
39
59
  defineOptions({
@@ -118,13 +138,59 @@
118
138
  return "frontoffice";
119
139
  });
120
140
 
121
- const model = defineModel<string>();
141
+ const model = defineModel<FzInputModelValue<TType>>();
122
142
  const containerRef: Ref<HTMLElement | null> = ref(null);
123
143
  const inputRef: Ref<HTMLInputElement | null> = ref(null);
124
144
  const uniqueId = generateInputId();
125
145
  const effectiveId = computed(() => props.id || uniqueId);
126
146
  const isFocused = ref(false);
127
147
 
148
+ /**
149
+ * The model widened to the full runtime range: the TType-conditional typing only
150
+ * narrows the public API, internally all value kinds must be handled (currency mode
151
+ * also tolerates deprecated string values at runtime).
152
+ */
153
+ const anyModel = model as unknown as Ref<string | number | null | undefined>;
154
+
155
+ /**
156
+ * The effective input type. The "text" fallback lives here instead of in
157
+ * withDefaults because generic-typed prop defaults cannot be inferred.
158
+ */
159
+ const effectiveType = computed<FzInputType>(() => props.type ?? "text");
160
+
161
+ const isCurrencyType = computed(() => effectiveType.value === "currency");
162
+ const isNumberType = computed(() => effectiveType.value === "number");
163
+
164
+ /** `type="currency"` renders a plain text input; formatting is handled in JS */
165
+ const nativeType = computed(() =>
166
+ isCurrencyType.value ? "text" : effectiveType.value,
167
+ );
168
+
169
+ const currency = useCurrencyInput({
170
+ props,
171
+ model: anyModel,
172
+ enabled: isCurrencyType,
173
+ });
174
+
175
+ /**
176
+ * The string bound to the native input element. In currency mode this is the
177
+ * formatted display value managed by useCurrencyInput (the v-model stays numeric);
178
+ * for every other type it proxies the v-model directly.
179
+ */
180
+ const inputModel = computed<string | undefined>({
181
+ get: () =>
182
+ isCurrencyType.value
183
+ ? currency.displayValue.value
184
+ : (anyModel.value as string | undefined),
185
+ set: (value) => {
186
+ if (isCurrencyType.value) {
187
+ currency.handleDisplayUpdate(value);
188
+ } else {
189
+ anyModel.value = value;
190
+ }
191
+ },
192
+ });
193
+
128
194
  /**
129
195
  * Internal visual state for emphasis props.
130
196
  * These track the effective visual state which can differ from props when
@@ -181,7 +247,7 @@
181
247
  aiReasoning: effectiveAiReasoning,
182
248
  },
183
249
  containerRef,
184
- model,
250
+ inputModel,
185
251
  effectiveEnvironment,
186
252
  isFocused,
187
253
  );
@@ -409,15 +475,143 @@
409
475
  );
410
476
 
411
477
  const shouldShowClearIcon = computed(() => {
412
- return props.clearable && !!model.value && !isReadonlyOrDisabled.value;
478
+ return props.clearable && !!inputModel.value && !isReadonlyOrDisabled.value;
413
479
  });
414
480
 
415
481
  const handleClear = () => {
416
- model.value = "";
482
+ inputModel.value = "";
417
483
  emit("fzinput:clear");
418
484
  inputRef.value?.focus();
419
485
  };
420
486
 
487
+ const handleFocusEvent = (e: FocusEvent) => {
488
+ isFocused.value = true;
489
+ if (isCurrencyType.value) {
490
+ currency.handleFocus();
491
+ }
492
+ emit("focus", e);
493
+ };
494
+
495
+ const handleBlurEvent = (e: FocusEvent) => {
496
+ isFocused.value = false;
497
+ if (isCurrencyType.value) {
498
+ currency.handleBlur();
499
+ }
500
+ emit("blur", e);
501
+ };
502
+
503
+ /**
504
+ * Currency-mode character filtering. For other types this is a no-op and
505
+ * consumer keydown listeners (forwarded via attrs) keep working unchanged.
506
+ */
507
+ const handleNativeKeydown = (e: KeyboardEvent) => {
508
+ if (isCurrencyType.value) {
509
+ currency.handleKeydown(e);
510
+ }
511
+ };
512
+
513
+ /**
514
+ * Rescue paste for `type="number"`. Clipboard text the native input accepts
515
+ * wholesale (HTML floating-point grammar) is left to the browser, preserving
516
+ * cursor-position insertion. Content the native input would reject or blank
517
+ * out (e.g. Italian-formatted "1.234,56", padded copies from spreadsheets)
518
+ * is normalized via the shared clipboard parser and replaces the whole
519
+ * value; unparseable text is ignored, keeping the previous value intact.
520
+ * Unlike currency mode, no decimal truncation or min/max clamping is
521
+ * applied: validation semantics stay native.
522
+ */
523
+ const handleNumberPaste = (e: ClipboardEvent) => {
524
+ if (props.readonly || props.disabled) {
525
+ return;
526
+ }
527
+
528
+ const pastedText = e.clipboardData?.getData("text") || "";
529
+ if (!pastedText || isNativeFloatString(pastedText)) {
530
+ return;
531
+ }
532
+
533
+ e.preventDefault();
534
+
535
+ const parsed = parseClipboardNumber(pastedText);
536
+ if (parsed !== null) {
537
+ anyModel.value = String(parsed);
538
+ }
539
+ };
540
+
541
+ /**
542
+ * Paste interception: currency mode normalizes every paste (Italian format
543
+ * parsing), number mode rescues non-native clipboard content; no-op for all
544
+ * other types.
545
+ */
546
+ const handleNativePaste = (e: ClipboardEvent) => {
547
+ if (isCurrencyType.value) {
548
+ currency.handlePaste(e);
549
+ } else if (isNumberType.value) {
550
+ handleNumberPaste(e);
551
+ }
552
+ };
553
+
554
+ /**
555
+ * Step controls (up/down arrows) are shown for currency and number inputs.
556
+ * For `type="number"` the native browser spinners are hidden in favor of these.
557
+ */
558
+ const showStepControls = computed(
559
+ () => isCurrencyType.value || isNumberType.value,
560
+ );
561
+
562
+ const stepUpAriaLabel = computed(() => {
563
+ return props.stepUpAriaLabel || `Incrementa di ${props.step}`;
564
+ });
565
+
566
+ const stepDownAriaLabel = computed(() => {
567
+ return props.stepDownAriaLabel || `Decrementa di ${props.step}`;
568
+ });
569
+
570
+ /**
571
+ * Steps a `type="number"` input via the native stepUp()/stepDown() algorithm
572
+ * (respects the min/max/step attributes). Native stepping does not fire events,
573
+ * so an input event is dispatched to sync the v-model and notify listeners.
574
+ */
575
+ const numberStep = (direction: 1 | -1) => {
576
+ const el = inputRef.value;
577
+ if (!el) {
578
+ return;
579
+ }
580
+ try {
581
+ if (direction === 1) {
582
+ el.stepUp();
583
+ } else {
584
+ el.stepDown();
585
+ }
586
+ } catch {
587
+ // InvalidStateError: stepping not applicable (e.g. step="any" via attrs)
588
+ return;
589
+ }
590
+ el.dispatchEvent(new Event("input", { bubbles: true }));
591
+ };
592
+
593
+ const handleStep = (direction: 1 | -1) => {
594
+ if (isReadonlyOrDisabled.value) {
595
+ return;
596
+ }
597
+ if (isCurrencyType.value) {
598
+ currency.stepBy(direction);
599
+ } else if (isNumberType.value) {
600
+ numberStep(direction);
601
+ }
602
+ };
603
+
604
+ const handleStepKeydown = (e: KeyboardEvent, direction: 1 | -1) => {
605
+ if ((e.key === "Enter" || e.key === " ") && !isReadonlyOrDisabled.value) {
606
+ e.preventDefault();
607
+ handleStep(direction);
608
+ }
609
+ };
610
+
611
+ /** Hides the native number spinners, replaced by the step controls */
612
+ const numberSpinnerClass =
613
+ "[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none";
614
+
421
615
  defineExpose({
422
616
  inputRef,
423
617
  containerRef,
@@ -451,23 +645,16 @@
451
645
  <div class="flex flex-col justify-around min-w-0 grow">
452
646
  <span v-if="!showNormalPlaceholder"
453
647
  class="text-xs text-grey-300 grow-0 overflow-hidden text-ellipsis whitespace-nowrap">{{ placeholder }}</span>
454
- <input :type="type" :required="required" :disabled="disabled" :readonly="readonly"
455
- :placeholder="showNormalPlaceholder ? placeholder : ''" v-model="model" ref="inputRef"
456
- :class="[staticInputClass, computedInputClass]" :pattern="pattern" :name :maxlength
457
- :autocomplete="autocomplete ? 'on' : 'off'" :aria-required="required ? 'true' : 'false'"
458
- :aria-invalid="error ? 'true' : 'false'" :aria-disabled="isReadonlyOrDisabled ? 'true' : 'false'"
459
- :aria-labelledby="ariaLabelledBy" :aria-describedby="ariaDescribedBy" :aria-description="emphasisDescription"
460
- v-bind="inputAttrs" :id="effectiveId" @input="handleUserInput" @blur="
461
- (e) => {
462
- isFocused = false;
463
- $emit('blur', e);
464
- }
465
- " @focus="
466
- (e) => {
467
- isFocused = true;
468
- $emit('focus', e);
469
- }
470
- " />
648
+ <input :type="nativeType" :required="required" :disabled="disabled" :readonly="readonly"
649
+ :placeholder="showNormalPlaceholder ? placeholder : ''" v-model="inputModel" ref="inputRef"
650
+ :class="[staticInputClass, computedInputClass, isNumberType ? numberSpinnerClass : '']" :pattern="pattern"
651
+ :name :maxlength :min="isNumberType ? min : undefined" :max="isNumberType ? max : undefined"
652
+ :step="isNumberType ? step : undefined" :autocomplete="autocomplete ? 'on' : 'off'"
653
+ :aria-required="required ? 'true' : 'false'" :aria-invalid="error ? 'true' : 'false'"
654
+ :aria-disabled="isReadonlyOrDisabled ? 'true' : 'false'" :aria-labelledby="ariaLabelledBy"
655
+ :aria-describedby="ariaDescribedBy" :aria-description="emphasisDescription" v-bind="inputAttrs"
656
+ :id="effectiveId" @input="handleUserInput" @keydown="handleNativeKeydown" @paste="handleNativePaste"
657
+ @blur="handleBlurEvent" @focus="handleFocusEvent" />
471
658
  </div>
472
659
  <div class="flex items-center gap-4">
473
660
  <FzIconButton v-if="shouldShowClearIcon" iconName="xmark" size="md" variant="invisible"
@@ -511,6 +698,18 @@
511
698
  ]" />
512
699
  <FzIcon v-if="valid" name="check" size="md" class="text-semantic-success" aria-hidden="true" />
513
700
  </slot>
701
+ <div v-if="showStepControls" class="flex flex-col justify-between items-center">
702
+ <FzIcon name="angle-up" size="xs" role="button" :aria-label="stepUpAriaLabel"
703
+ :aria-disabled="isReadonlyOrDisabled ? 'true' : undefined"
704
+ :tabindex="isReadonlyOrDisabled ? undefined : '0'"
705
+ class="fz__input__arrowup fz__currencyinput__arrowup cursor-pointer" @click="handleStep(1)"
706
+ @keydown="(e: KeyboardEvent) => handleStepKeydown(e, 1)"></FzIcon>
707
+ <FzIcon name="angle-down" size="xs" role="button" :aria-label="stepDownAriaLabel"
708
+ :aria-disabled="isReadonlyOrDisabled ? 'true' : undefined"
709
+ :tabindex="isReadonlyOrDisabled ? undefined : '0'"
710
+ class="fz__input__arrowdown fz__currencyinput__arrowdown cursor-pointer" @click="handleStep(-1)"
711
+ @keydown="(e: KeyboardEvent) => handleStepKeydown(e, -1)"></FzIcon>
712
+ </div>
514
713
  </div>
515
714
  </div>
516
715
  <FzAlert v-if="error && $slots.errorMessage" :id="`${effectiveId}-error`" role="alert" tone="error" variant="text">
@@ -1794,4 +1794,85 @@ describe("FzCurrencyInput", () => {
1794
1794
  expect(wrapper.find(".fz__currencyinput__arrowdown").exists()).toBe(true);
1795
1795
  });
1796
1796
  });
1797
+
1798
+ describe("Paste handling (forwarded to FzInput currency mode)", () => {
1799
+ it("parses Italian-formatted clipboard text into the numeric model", async () => {
1800
+ let modelValue: number | undefined = undefined;
1801
+ let wrapper: ReturnType<typeof mount> | null = null;
1802
+ wrapper = mount(FzCurrencyInput, {
1803
+ props: {
1804
+ label: "Label",
1805
+ modelValue,
1806
+ "onUpdate:modelValue": (e) => {
1807
+ modelValue = e as number;
1808
+ if (wrapper) wrapper.setProps({ modelValue });
1809
+ },
1810
+ },
1811
+ });
1812
+
1813
+ const inputElement = wrapper.find("input");
1814
+ await inputElement.trigger("focus");
1815
+ await inputElement.trigger("paste", {
1816
+ clipboardData: { getData: () => "1.234,56" },
1817
+ });
1818
+ await wrapper.vm.$nextTick();
1819
+
1820
+ expect(modelValue).toBe(1234.56);
1821
+ // While focused the pasted value is shown raw (no grouping)
1822
+ expect(inputElement.element.value).toBe("1234,56");
1823
+
1824
+ await inputElement.trigger("blur");
1825
+ await new Promise((resolve) => window.setTimeout(resolve, 100));
1826
+ expect(inputElement.element.value).toBe("1.234,56");
1827
+ });
1828
+
1829
+ it("ignores clipboard text that cannot be parsed", async () => {
1830
+ let modelValue: number | undefined = 99;
1831
+ let wrapper: ReturnType<typeof mount> | null = null;
1832
+ wrapper = mount(FzCurrencyInput, {
1833
+ props: {
1834
+ label: "Label",
1835
+ modelValue,
1836
+ "onUpdate:modelValue": (e) => {
1837
+ modelValue = e as number;
1838
+ if (wrapper) wrapper.setProps({ modelValue });
1839
+ },
1840
+ },
1841
+ });
1842
+
1843
+ const inputElement = wrapper.find("input");
1844
+ await inputElement.trigger("focus");
1845
+ const displayBefore = inputElement.element.value;
1846
+ await inputElement.trigger("paste", {
1847
+ clipboardData: { getData: () => "abc" },
1848
+ });
1849
+ await wrapper.vm.$nextTick();
1850
+
1851
+ expect(modelValue).toBe(99);
1852
+ expect(inputElement.element.value).toBe(displayBefore);
1853
+ });
1854
+
1855
+ it("does not react to paste when readonly", async () => {
1856
+ let modelValue: number | undefined = 10;
1857
+ let wrapper: ReturnType<typeof mount> | null = null;
1858
+ wrapper = mount(FzCurrencyInput, {
1859
+ props: {
1860
+ label: "Label",
1861
+ readonly: true,
1862
+ modelValue,
1863
+ "onUpdate:modelValue": (e) => {
1864
+ modelValue = e as number;
1865
+ if (wrapper) wrapper.setProps({ modelValue });
1866
+ },
1867
+ },
1868
+ });
1869
+
1870
+ await wrapper.find("input").trigger("paste", {
1871
+ clipboardData: { getData: () => "1234,56" },
1872
+ });
1873
+ await wrapper.vm.$nextTick();
1874
+
1875
+ expect(modelValue).toBe(10);
1876
+ });
1877
+ });
1797
1878
  });