@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
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 {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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<
|
|
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
|
-
|
|
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 && !!
|
|
478
|
+
return props.clearable && !!inputModel.value && !isReadonlyOrDisabled.value;
|
|
413
479
|
});
|
|
414
480
|
|
|
415
481
|
const handleClear = () => {
|
|
416
|
-
|
|
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="
|
|
455
|
-
:placeholder="showNormalPlaceholder ? placeholder : ''" v-model="
|
|
456
|
-
:class="[staticInputClass, computedInputClass]" :pattern="pattern"
|
|
457
|
-
:
|
|
458
|
-
:
|
|
459
|
-
:aria-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
});
|