@fiscozen/input 3.0.0 → 3.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiscozen/input",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Design System Input component",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -8,8 +8,8 @@
8
8
  "author": "Cristian Barraco",
9
9
  "dependencies": {
10
10
  "@fiscozen/alert": "3.0.0",
11
- "@fiscozen/button": "3.0.0",
12
- "@fiscozen/composables": "1.0.2"
11
+ "@fiscozen/composables": "1.0.3",
12
+ "@fiscozen/button": "3.0.0"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "tailwindcss": "^3.4.1",
package/src/FzInput.vue CHANGED
@@ -11,7 +11,7 @@
11
11
  * <FzInput label="Email" type="email" v-model="email" />
12
12
  * <FzInput label="Password" type="password" rightIcon="eye" @fzinput:right-icon-click="toggleVisibility" />
13
13
  */
14
- import { computed, toRefs, Ref, ref, watch, useSlots } from "vue";
14
+ import { computed, toRefs, Ref, ref, watch, useSlots, useAttrs } from "vue";
15
15
  import { FzInputProps, type InputEnvironment } from "./types";
16
16
  import { FzAlert } from "@fiscozen/alert";
17
17
  import { FzIcon } from "@fiscozen/icons";
@@ -29,6 +29,26 @@ const props = withDefaults(defineProps<FzInputProps>(), {
29
29
  autocomplete: false,
30
30
  });
31
31
 
32
+ defineOptions({
33
+ inheritAttrs: false,
34
+ });
35
+
36
+ const attrs = useAttrs();
37
+
38
+ /**
39
+ * Attrs forwarded to the native input element.
40
+ * Excludes class which are applied to the root wrapper div
41
+ * so that consumers can control layout/positioning of the component.
42
+ */
43
+ const inputAttrs = computed(() => {
44
+ return {
45
+ ...attrs,
46
+ class: undefined,
47
+ };
48
+ });
49
+
50
+ const rootClass = computed(() => attrs.class);
51
+
32
52
  /**
33
53
  * Deprecation warning and normalization for size prop.
34
54
  * Watches the size prop and warns once on mount if it's provided.
@@ -45,17 +65,17 @@ watch(
45
65
  console.warn(
46
66
  `[FzInput] Both "size" and "environment" props are provided. ` +
47
67
  `"environment=${props.environment}" will be used and "size=${size}" will be ignored. ` +
48
- `Please remove the deprecated "size" prop.`
68
+ `Please remove the deprecated "size" prop.`,
49
69
  );
50
70
  } else {
51
71
  console.warn(
52
72
  `[FzInput] The "size" prop is deprecated and will be removed in a future version. ` +
53
- `Please use environment="${mappedEnvironment}" instead of size="${size}".`
73
+ `Please use environment="${mappedEnvironment}" instead of size="${size}".`,
54
74
  );
55
75
  }
56
76
  }
57
77
  },
58
- { immediate: true }
78
+ { immediate: true },
59
79
  );
60
80
 
61
81
  /**
@@ -68,11 +88,11 @@ watch(
68
88
  if (rightIconSize !== undefined) {
69
89
  console.warn(
70
90
  `[FzInput] The "rightIconSize" prop is deprecated and will be removed in a future version. ` +
71
- `Icons now have a fixed size of "md". The provided value "${rightIconSize}" will be ignored.`
91
+ `Icons now have a fixed size of "md". The provided value "${rightIconSize}" will be ignored.`,
72
92
  );
73
93
  }
74
94
  },
75
- { immediate: true }
95
+ { immediate: true },
76
96
  );
77
97
 
78
98
  /**
@@ -111,7 +131,7 @@ const {
111
131
  containerRef,
112
132
  model,
113
133
  effectiveEnvironment,
114
- isFocused
134
+ isFocused,
115
135
  );
116
136
 
117
137
  const slots = defineSlots<{
@@ -213,7 +233,7 @@ const handleIconKeydown = (
213
233
  emitEvent:
214
234
  | "fzinput:left-icon-click"
215
235
  | "fzinput:right-icon-click"
216
- | "fzinput:second-right-icon-click"
236
+ | "fzinput:second-right-icon-click",
217
237
  ) => {
218
238
  if (e.key === "Enter" || e.key === " ") {
219
239
  e.preventDefault();
@@ -273,7 +293,7 @@ const isLeftIconClickable = computed(() => !!props.leftIcon);
273
293
  * Icons are only accessible via keyboard when aria-label is provided.
274
294
  */
275
295
  const isLeftIconAccessible = computed(
276
- () => isLeftIconClickable.value && !!props.leftIconAriaLabel
296
+ () => isLeftIconClickable.value && !!props.leftIconAriaLabel,
277
297
  );
278
298
 
279
299
  /**
@@ -282,14 +302,14 @@ const isLeftIconAccessible = computed(
282
302
  * Readonly inputs have the same visual styling and behavior as disabled inputs.
283
303
  */
284
304
  const isReadonlyOrDisabled = computed(
285
- () => !!props.disabled || !!props.readonly
305
+ () => !!props.disabled || !!props.readonly,
286
306
  );
287
307
 
288
308
  /**
289
309
  * Determines if right icon is clickable (not rendered as button)
290
310
  */
291
311
  const isRightIconClickable = computed(
292
- () => !!props.rightIcon && !props.rightIconButton
312
+ () => !!props.rightIcon && !props.rightIconButton,
293
313
  );
294
314
 
295
315
  /**
@@ -298,14 +318,14 @@ const isRightIconClickable = computed(
298
318
  * Icons are only accessible via keyboard when aria-label is provided.
299
319
  */
300
320
  const isRightIconAccessible = computed(
301
- () => isRightIconClickable.value && !!props.rightIconAriaLabel
321
+ () => isRightIconClickable.value && !!props.rightIconAriaLabel,
302
322
  );
303
323
 
304
324
  /**
305
325
  * Determines if second right icon is clickable (not rendered as button)
306
326
  */
307
327
  const isSecondRightIconClickable = computed(
308
- () => !!props.secondRightIcon && !props.secondRightIconButton
328
+ () => !!props.secondRightIcon && !props.secondRightIconButton,
309
329
  );
310
330
 
311
331
  /**
@@ -314,7 +334,7 @@ const isSecondRightIconClickable = computed(
314
334
  * Icons are only accessible via keyboard when aria-label is provided.
315
335
  */
316
336
  const isSecondRightIconAccessible = computed(
317
- () => isSecondRightIconClickable.value && !!props.secondRightIconAriaLabel
337
+ () => isSecondRightIconClickable.value && !!props.secondRightIconAriaLabel,
318
338
  );
319
339
 
320
340
  defineExpose({
@@ -324,7 +344,7 @@ defineExpose({
324
344
  </script>
325
345
 
326
346
  <template>
327
- <div class="fz-input w-full flex flex-col gap-8">
347
+ <div class="fz-input w-full flex flex-col gap-8" :class="rootClass">
328
348
  <slot name="label">
329
349
  <label
330
350
  v-if="label"
@@ -391,7 +411,7 @@ defineExpose({
391
411
  :aria-disabled="isReadonlyOrDisabled ? 'true' : 'false'"
392
412
  :aria-labelledby="ariaLabelledBy"
393
413
  :aria-describedby="ariaDescribedBy"
394
- v-bind="$attrs"
414
+ v-bind="inputAttrs"
395
415
  @blur="
396
416
  (e) => {
397
417
  isFocused = false;
@@ -734,12 +734,11 @@ describe('FzCurrencyInput', () => {
734
734
  const inputElement = wrapper.find('input')
735
735
  await inputElement.trigger('blur')
736
736
  await new Promise((resolve) => window.setTimeout(resolve, 100))
737
- // Should be formatted with thousand separators
738
- // Note: decimals are truncated to maximumFractionDigits (2), so 89 becomes 88 (truncated, not rounded)
739
- expect(inputElement.element.value).toBe('1.234.567,88')
740
- // Verify the numeric value in v-model is correct (truncated to 2 decimals)
737
+ // Should be formatted with thousand separators and preserve original decimals
738
+ expect(inputElement.element.value).toBe('1.234.567,89')
739
+ // Verify the numeric value in v-model is correct
741
740
  expect(typeof modelValue).toBe('number')
742
- expect(modelValue).toBeCloseTo(1234567.88, 2)
741
+ expect(modelValue).toBeCloseTo(1234567.89, 2)
743
742
 
744
743
  consoleSpy.mockRestore()
745
744
  })
@@ -1523,6 +1522,71 @@ describe('FzCurrencyInput', () => {
1523
1522
  expect(wrapper.props('modelValue')).toBe(1)
1524
1523
  })
1525
1524
  })
1525
+
1526
+ describe('Floating-point precision (regression)', () => {
1527
+ it.each([
1528
+ ['40,30', 40.3, '40,30'],
1529
+ ['40,20', 40.2, '40,20'],
1530
+ ['40,40', 40.4, '40,40'],
1531
+ ['299,96', 299.96, '299,96'],
1532
+ ['0,30', 0.3, '0,30'],
1533
+ ['10,30', 10.3, '10,30'],
1534
+ ['99,99', 99.99, '99,99'],
1535
+ ])('should preserve "%s" without floating-point drift', async (input, expectedModel, expectedDisplay) => {
1536
+ let modelValue: number | string | undefined = undefined
1537
+ let wrapper: ReturnType<typeof mount> | null = null
1538
+ wrapper = mount(FzCurrencyInput, {
1539
+ props: {
1540
+ label: 'Label',
1541
+ modelValue,
1542
+ 'onUpdate:modelValue': (e) => {
1543
+ modelValue = e as number
1544
+ if (wrapper) wrapper.setProps({ modelValue })
1545
+ },
1546
+ },
1547
+ })
1548
+
1549
+ const inputElement = wrapper.find('input')
1550
+
1551
+ // Focus, type the value, blur
1552
+ await inputElement.trigger('focus')
1553
+ await inputElement.setValue(input)
1554
+ await wrapper.vm.$nextTick()
1555
+ await inputElement.trigger('blur')
1556
+ await new Promise((resolve) => window.setTimeout(resolve, 100))
1557
+
1558
+ expect(wrapper.props('modelValue')).toBe(expectedModel)
1559
+ expect(inputElement.element.value).toBe(expectedDisplay)
1560
+ })
1561
+
1562
+ it.each([
1563
+ [40.3, '40,30'],
1564
+ [40.2, '40,20'],
1565
+ [40.4, '40,40'],
1566
+ [299.96, '299,96'],
1567
+ [1234567.89, '1.234.567,89'],
1568
+ ])('should display correct raw value on focus for v-model %f', async (numericValue, expectedRaw) => {
1569
+ const wrapper = mount(FzCurrencyInput, {
1570
+ props: {
1571
+ label: 'Label',
1572
+ modelValue: numericValue,
1573
+ 'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e }),
1574
+ },
1575
+ })
1576
+
1577
+ await wrapper.vm.$nextTick()
1578
+ await new Promise((resolve) => window.setTimeout(resolve, 100))
1579
+
1580
+ const inputElement = wrapper.find('input')
1581
+
1582
+ // Focus to see raw value (without thousand separators)
1583
+ await inputElement.trigger('focus')
1584
+ await wrapper.vm.$nextTick()
1585
+
1586
+ const rawExpected = expectedRaw.replace(/\./g, '')
1587
+ expect(inputElement.element.value).toBe(rawExpected)
1588
+ })
1589
+ })
1526
1590
  })
1527
1591
  })
1528
1592
 
@@ -1024,6 +1024,85 @@ describe('FzInput', () => {
1024
1024
  })
1025
1025
  })
1026
1026
 
1027
+ describe('Attribute forwarding (inheritAttrs: false)', () => {
1028
+ it('applies consumer class to root wrapper div', async () => {
1029
+ const wrapper = mount(FzInput, {
1030
+ props: { label: 'Label' },
1031
+ attrs: { class: 'max-w-xs custom-class' },
1032
+ })
1033
+
1034
+ await wrapper.vm.$nextTick()
1035
+
1036
+ const rootDiv = wrapper.element as HTMLElement
1037
+ expect(rootDiv.classList.contains('max-w-xs')).toBe(true)
1038
+ expect(rootDiv.classList.contains('custom-class')).toBe(true)
1039
+ expect(rootDiv.classList.contains('fz-input')).toBe(true)
1040
+ })
1041
+
1042
+ it('does not apply consumer class to native input element', async () => {
1043
+ const wrapper = mount(FzInput, {
1044
+ props: { label: 'Label' },
1045
+ attrs: { class: 'max-w-xs' },
1046
+ })
1047
+
1048
+ await wrapper.vm.$nextTick()
1049
+
1050
+ const input = wrapper.find('input').element as HTMLInputElement
1051
+ expect(input.classList.contains('max-w-xs')).toBe(false)
1052
+ })
1053
+
1054
+ it('forwards data-* attributes to native input element', async () => {
1055
+ const wrapper = mount(FzInput, {
1056
+ props: { label: 'Label' },
1057
+ attrs: { 'data-cy': 'my-input', 'data-testid': 'test-input' },
1058
+ })
1059
+
1060
+ await wrapper.vm.$nextTick()
1061
+
1062
+ const input = wrapper.find('input').element as HTMLInputElement
1063
+ expect(input.getAttribute('data-cy')).toBe('my-input')
1064
+ expect(input.getAttribute('data-testid')).toBe('test-input')
1065
+
1066
+ const rootDiv = wrapper.element as HTMLElement
1067
+ expect(rootDiv.getAttribute('data-cy')).toBeNull()
1068
+ })
1069
+
1070
+ it('forwards consumer id to native input element (overriding internal id)', async () => {
1071
+ const wrapper = mount(FzInput, {
1072
+ props: { label: 'Label' },
1073
+ attrs: { id: 'custom-id' },
1074
+ })
1075
+
1076
+ await wrapper.vm.$nextTick()
1077
+
1078
+ const input = wrapper.find('input').element as HTMLInputElement
1079
+ expect(input.getAttribute('id')).toBe('custom-id')
1080
+
1081
+ const rootDiv = wrapper.element as HTMLElement
1082
+ expect(rootDiv.getAttribute('id')).toBeNull()
1083
+ })
1084
+
1085
+ it('forwards consumer aria-* attributes to native input element', async () => {
1086
+ const wrapper = mount(FzInput, {
1087
+ props: { label: 'Label' },
1088
+ attrs: {
1089
+ 'aria-expanded': 'true',
1090
+ 'aria-haspopup': 'listbox',
1091
+ },
1092
+ })
1093
+
1094
+ await wrapper.vm.$nextTick()
1095
+
1096
+ const input = wrapper.find('input').element as HTMLInputElement
1097
+ expect(input.getAttribute('aria-expanded')).toBe('true')
1098
+ expect(input.getAttribute('aria-haspopup')).toBe('listbox')
1099
+
1100
+ const rootDiv = wrapper.element as HTMLElement
1101
+ expect(rootDiv.getAttribute('aria-expanded')).toBeNull()
1102
+ expect(rootDiv.getAttribute('aria-haspopup')).toBeNull()
1103
+ })
1104
+ })
1105
+
1027
1106
  describe('Edge cases', () => {
1028
1107
  it(`renders ${NUMBER_OF_INPUTS} input with different ids`, async () => {
1029
1108
  const wrapperList = Array.from({ length: NUMBER_OF_INPUTS }).map((_, i) =>