@fiscozen/input 0.1.17 → 1.0.0-next.1

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,10 +1,336 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FzInput Component
4
+ *
5
+ * Flexible input component with icon support, validation states, and multiple variants.
6
+ * Supports left/right icons (static or clickable buttons), floating label variant,
7
+ * error/valid states, and full accessibility features.
8
+ *
9
+ * @component
10
+ * @example
11
+ * <FzInput label="Email" type="email" v-model="email" />
12
+ * <FzInput label="Password" type="password" rightIcon="eye" @fzinput:right-icon-click="toggleVisibility" />
13
+ */
14
+ import { computed, toRefs, Ref, ref, watch, useSlots } from "vue";
15
+ import { FzInputProps, type InputEnvironment } from "./types";
16
+ import { FzIcon } from "@fiscozen/icons";
17
+ import { FzIconButton } from "@fiscozen/button";
18
+ import useInputStyle from "./useInputStyle";
19
+ import { generateInputId, sizeToEnvironmentMapping } from "./utils";
20
+
21
+ const props = withDefaults(defineProps<FzInputProps>(), {
22
+ error: false,
23
+ type: "text",
24
+ rightIconButtonVariant: "invisible",
25
+ secondRightIconButtonVariant: "invisible",
26
+ variant: "normal",
27
+ environment: "frontoffice",
28
+ autocomplete: false,
29
+ });
30
+
31
+ /**
32
+ * Deprecation warning and normalization for size prop.
33
+ * Watches the size prop and warns once on mount if it's provided.
34
+ * Normalizes size values to environment for backward compatibility.
35
+ */
36
+ watch(
37
+ () => props.size,
38
+ (size) => {
39
+ if (size !== undefined) {
40
+ const mappedEnvironment = sizeToEnvironmentMapping[size];
41
+
42
+ // Check if both environment and size are provided and conflict
43
+ if (props.environment && props.environment !== mappedEnvironment) {
44
+ console.warn(
45
+ `[FzInput] Both "size" and "environment" props are provided. ` +
46
+ `"environment=${props.environment}" will be used and "size=${size}" will be ignored. ` +
47
+ `Please remove the deprecated "size" prop.`
48
+ );
49
+ } else {
50
+ console.warn(
51
+ `[FzInput] The "size" prop is deprecated and will be removed in a future version. ` +
52
+ `Please use environment="${mappedEnvironment}" instead of size="${size}".`
53
+ );
54
+ }
55
+ }
56
+ },
57
+ { immediate: true }
58
+ );
59
+
60
+ /**
61
+ * Deprecation warning for rightIconSize prop.
62
+ * Icons now have a fixed size of "md" and this prop is ignored.
63
+ */
64
+ watch(
65
+ () => props.rightIconSize,
66
+ (rightIconSize) => {
67
+ if (rightIconSize !== undefined) {
68
+ console.warn(
69
+ `[FzInput] The "rightIconSize" prop is deprecated and will be removed in a future version. ` +
70
+ `Icons now have a fixed size of "md". The provided value "${rightIconSize}" will be ignored.`
71
+ );
72
+ }
73
+ },
74
+ { immediate: true }
75
+ );
76
+
77
+ /**
78
+ * Determines the effective environment based on environment or size prop
79
+ *
80
+ * Priority: environment prop > size prop mapped to environment > default 'frontoffice'.
81
+ * The size prop is deprecated and only used for backward compatibility.
82
+ */
83
+ const effectiveEnvironment = computed((): InputEnvironment => {
84
+ if (props.environment) {
85
+ return props.environment;
86
+ }
87
+ if (props.size) {
88
+ return sizeToEnvironmentMapping[props.size];
89
+ }
90
+ return "frontoffice";
91
+ });
92
+
93
+ const model = defineModel<string>();
94
+ const containerRef: Ref<HTMLElement | null> = ref(null);
95
+ const inputRef: Ref<HTMLInputElement | null> = ref(null);
96
+ const uniqueId = generateInputId();
97
+ const isFocused = ref(false);
98
+
99
+ const {
100
+ staticContainerClass,
101
+ computedContainerClass,
102
+ computedLabelClass,
103
+ staticInputClass,
104
+ computedInputClass,
105
+ computedHelpClass,
106
+ computedErrorClass,
107
+ containerWidth,
108
+ showNormalPlaceholder,
109
+ } = useInputStyle(
110
+ toRefs(props),
111
+ containerRef,
112
+ model,
113
+ effectiveEnvironment,
114
+ isFocused
115
+ );
116
+
117
+ const slots = defineSlots<{
118
+ label?: () => unknown;
119
+ "left-icon"?: () => unknown;
120
+ "right-icon"?: () => unknown;
121
+ errorMessage?: () => unknown;
122
+ helpText?: () => unknown;
123
+ }>();
124
+
125
+ const runtimeSlots = useSlots();
126
+
127
+ /**
128
+ * Computes aria-labelledby value linking input to label element
129
+ *
130
+ * Only set when default label element is rendered. Custom label slot replaces default label,
131
+ * so the ID doesn't exist and aria-labelledby would reference a non-existent element.
132
+ */
133
+ const ariaLabelledBy = computed(() => {
134
+ const hasLabelProp = !!props.label;
135
+ const hasCustomLabelSlot = !!runtimeSlots.label;
136
+
137
+ if (hasLabelProp && !hasCustomLabelSlot) {
138
+ return `${uniqueId}-label`;
139
+ }
140
+ return undefined;
141
+ });
142
+
143
+ /**
144
+ * Computes aria-describedby value linking input to help text or error message
145
+ *
146
+ * Uses runtimeSlots (not slots from defineSlots) because defineSlots is only for TypeScript typing.
147
+ */
148
+ const ariaDescribedBy = computed(() => {
149
+ const ids: string[] = [];
150
+ if (props.error && runtimeSlots.errorMessage) {
151
+ ids.push(`${uniqueId}-error`);
152
+ }
153
+ if (!props.error && runtimeSlots.helpText) {
154
+ ids.push(`${uniqueId}-help`);
155
+ }
156
+ return ids.length > 0 ? ids.join(" ") : undefined;
157
+ });
158
+
159
+ const emit = defineEmits<{
160
+ focus: [event: FocusEvent];
161
+ blur: [event: FocusEvent];
162
+ // Other DOM events (keydown, keyup, paste, input, change, etc.) are automatically
163
+ // forwarded to the native input element via v-bind="$attrs" and don't need to be
164
+ // explicitly declared here. They will work automatically when used on FzInput.
165
+ "fzinput:left-icon-click": [];
166
+ "fzinput:right-icon-click": [];
167
+ "fzinput:second-right-icon-click": [];
168
+ }>();
169
+
170
+ /**
171
+ * Handles container interaction (click or keyboard) to focus the input
172
+ *
173
+ * Makes the entire container area clickable and keyboard-accessible for better UX,
174
+ * especially useful for floating-label variant and mobile devices.
175
+ * Respects disabled and readonly states.
176
+ */
177
+ const handleContainerInteraction = () => {
178
+ if (!props.disabled && !props.readonly && inputRef.value) {
179
+ inputRef.value.focus();
180
+ }
181
+ };
182
+
183
+ /**
184
+ * Handles keyboard events on container to focus input
185
+ *
186
+ * Supports Enter and Space keys following accessibility best practices.
187
+ * Only prevents default when event originates from container itself (not from child elements like input),
188
+ * allowing form submission when Enter is pressed inside the input field.
189
+ *
190
+ * @param e - Keyboard event
191
+ */
192
+ const handleContainerKeydown = (e: KeyboardEvent) => {
193
+ if (e.key === "Enter" || e.key === " ") {
194
+ // Only prevent default if event originated from container itself, not from child elements
195
+ // This allows Enter key presses in the input to trigger form submission
196
+ if (e.target === e.currentTarget || e.target === containerRef.value) {
197
+ e.preventDefault();
198
+ handleContainerInteraction();
199
+ }
200
+ }
201
+ };
202
+
203
+ /**
204
+ * Handles keyboard events on clickable icons
205
+ *
206
+ * Supports Enter and Space keys following accessibility best practices.
207
+ *
208
+ * @param e - Keyboard event
209
+ * @param emitEvent - Event name to emit when key is pressed
210
+ */
211
+ const handleIconKeydown = (
212
+ e: KeyboardEvent,
213
+ emitEvent:
214
+ | "fzinput:left-icon-click"
215
+ | "fzinput:right-icon-click"
216
+ | "fzinput:second-right-icon-click"
217
+ ) => {
218
+ if (e.key === "Enter" || e.key === " ") {
219
+ e.preventDefault();
220
+ if (!isReadonlyOrDisabled.value) {
221
+ if (emitEvent === "fzinput:left-icon-click") {
222
+ emit("fzinput:left-icon-click");
223
+ } else if (emitEvent === "fzinput:right-icon-click") {
224
+ emit("fzinput:right-icon-click");
225
+ } else {
226
+ emit("fzinput:second-right-icon-click");
227
+ }
228
+ }
229
+ }
230
+ };
231
+
232
+ /**
233
+ * Handles left icon click events
234
+ *
235
+ * Respects disabled and readonly states - does not emit if input is disabled or readonly.
236
+ */
237
+ const handleLeftIconClick = () => {
238
+ if (!isReadonlyOrDisabled.value) {
239
+ emit("fzinput:left-icon-click");
240
+ }
241
+ };
242
+
243
+ /**
244
+ * Handles right icon click events
245
+ *
246
+ * Respects disabled and readonly states - does not emit if input is disabled or readonly.
247
+ */
248
+ const handleRightIconClick = () => {
249
+ if (!isReadonlyOrDisabled.value) {
250
+ emit("fzinput:right-icon-click");
251
+ }
252
+ };
253
+
254
+ /**
255
+ * Handles second right icon click events
256
+ *
257
+ * Respects disabled and readonly states - does not emit if input is disabled or readonly.
258
+ */
259
+ const handleSecondRightIconClick = () => {
260
+ if (!isReadonlyOrDisabled.value) {
261
+ emit("fzinput:second-right-icon-click");
262
+ }
263
+ };
264
+
265
+ /**
266
+ * Determines if left icon is clickable (has click handler)
267
+ */
268
+ const isLeftIconClickable = computed(() => !!props.leftIcon);
269
+
270
+ /**
271
+ * Determines if left icon is keyboard-accessible (has aria-label)
272
+ *
273
+ * Icons are only accessible via keyboard when aria-label is provided.
274
+ */
275
+ const isLeftIconAccessible = computed(
276
+ () => isLeftIconClickable.value && !!props.leftIconAriaLabel
277
+ );
278
+
279
+ /**
280
+ * Determines if input is disabled or readonly
281
+ *
282
+ * Readonly inputs have the same visual styling and behavior as disabled inputs.
283
+ */
284
+ const isReadonlyOrDisabled = computed(
285
+ () => !!props.disabled || !!props.readonly
286
+ );
287
+
288
+ /**
289
+ * Determines if right icon is clickable (not rendered as button)
290
+ */
291
+ const isRightIconClickable = computed(
292
+ () => !!props.rightIcon && !props.rightIconButton
293
+ );
294
+
295
+ /**
296
+ * Determines if right icon is keyboard-accessible (has aria-label)
297
+ *
298
+ * Icons are only accessible via keyboard when aria-label is provided.
299
+ */
300
+ const isRightIconAccessible = computed(
301
+ () => isRightIconClickable.value && !!props.rightIconAriaLabel
302
+ );
303
+
304
+ /**
305
+ * Determines if second right icon is clickable (not rendered as button)
306
+ */
307
+ const isSecondRightIconClickable = computed(
308
+ () => !!props.secondRightIcon && !props.secondRightIconButton
309
+ );
310
+
311
+ /**
312
+ * Determines if second right icon is keyboard-accessible (has aria-label)
313
+ *
314
+ * Icons are only accessible via keyboard when aria-label is provided.
315
+ */
316
+ const isSecondRightIconAccessible = computed(
317
+ () => isSecondRightIconClickable.value && !!props.secondRightIconAriaLabel
318
+ );
319
+
320
+ defineExpose({
321
+ inputRef,
322
+ containerRef,
323
+ });
324
+ </script>
325
+
1
326
  <template>
2
327
  <div class="fz-input w-full flex flex-col gap-8">
3
328
  <slot name="label">
4
329
  <label
5
- :class="['text-sm', computedLabelClass]"
6
- :for="uniqueId"
7
330
  v-if="label"
331
+ :id="`${uniqueId}-label`"
332
+ :class="computedLabelClass"
333
+ :for="uniqueId"
8
334
  >
9
335
  {{ label }}{{ required ? " *" : "" }}
10
336
  </label>
@@ -12,23 +338,43 @@
12
338
  <div
13
339
  :class="[staticContainerClass, computedContainerClass]"
14
340
  ref="containerRef"
15
- @click="inputRef?.focus()"
341
+ :tabindex="isReadonlyOrDisabled ? undefined : 0"
342
+ @click="handleContainerInteraction"
343
+ @keydown="handleContainerKeydown"
16
344
  >
17
345
  <slot name="left-icon">
18
346
  <FzIcon
19
347
  v-if="leftIcon"
20
348
  :name="leftIcon"
21
- :size="size"
349
+ size="md"
22
350
  :variant="leftIconVariant"
23
- @click.stop="emit('fzinput:left-icon-click')"
351
+ :role="isLeftIconAccessible ? 'button' : undefined"
352
+ :aria-label="isLeftIconAccessible ? leftIconAriaLabel : undefined"
353
+ :aria-disabled="
354
+ isLeftIconAccessible && isReadonlyOrDisabled ? 'true' : undefined
355
+ "
356
+ :tabindex="
357
+ isLeftIconAccessible && !isReadonlyOrDisabled ? 0 : undefined
358
+ "
24
359
  :class="leftIconClass"
360
+ @click.stop="handleLeftIconClick"
361
+ @keydown="
362
+ isLeftIconAccessible
363
+ ? (e: KeyboardEvent) =>
364
+ handleIconKeydown(e, 'fzinput:left-icon-click')
365
+ : undefined
366
+ "
25
367
  />
26
368
  </slot>
27
- <div class="flex flex-col space-around min-w-0 grow">
28
- <span v-if="!showNormalPlaceholder" class="text-xs text-gray-300 grow-0 overflow-hidden text-ellipsis whitespace-nowrap">{{ placeholder }}</span>
369
+ <div class="flex flex-col justify-around min-w-0 grow">
370
+ <span
371
+ v-if="!showNormalPlaceholder"
372
+ class="text-xs text-grey-300 grow-0 overflow-hidden text-ellipsis whitespace-nowrap"
373
+ >{{ placeholder }}</span
374
+ >
29
375
  <input
30
376
  :type="type"
31
- :required="required ? required : false"
377
+ :required="required"
32
378
  :disabled="disabled"
33
379
  :readonly="readonly"
34
380
  :placeholder="showNormalPlaceholder ? placeholder : ''"
@@ -39,53 +385,137 @@
39
385
  :pattern="pattern"
40
386
  :name
41
387
  :maxlength
42
- @blur="(e) => $emit('blur', e)"
43
- @focus="(e) => $emit('focus', e)"
44
- @paste="(e) => $emit('paste', e)"
388
+ :autocomplete="autocomplete ? 'on' : 'off'"
389
+ :aria-required="required ? 'true' : 'false'"
390
+ :aria-invalid="error ? 'true' : 'false'"
391
+ :aria-disabled="isReadonlyOrDisabled ? 'true' : 'false'"
392
+ :aria-labelledby="ariaLabelledBy"
393
+ :aria-describedby="ariaDescribedBy"
394
+ v-bind="$attrs"
395
+ @blur="
396
+ (e) => {
397
+ isFocused = false;
398
+ $emit('blur', e);
399
+ }
400
+ "
401
+ @focus="
402
+ (e) => {
403
+ isFocused = true;
404
+ $emit('focus', e);
405
+ }
406
+ "
45
407
  />
46
408
  </div>
47
409
  <slot name="right-icon">
48
- <FzIcon
49
- v-if="valid"
50
- name="check"
51
- :size="size"
52
- class="text-semantic-success"
53
- />
54
- <FzIcon
55
- v-if="rightIcon && !rightIconButton"
56
- :name="rightIcon"
57
- :size="size"
58
- :variant="rightIconVariant"
59
- @click.stop="emit('fzinput:right-icon-click')"
60
- :class="rightIconClass"
61
- />
62
- <FzIconButton
63
- v-if="rightIcon && rightIconButton"
64
- :iconName="rightIcon"
65
- :size="mappedSize"
66
- :iconVariant="rightIconVariant"
67
- :variant="disabled ? 'invisible' : rightIconButtonVariant"
68
- @click.stop="emit('fzinput:right-icon-click')"
69
- :class="[{'bg-grey-100 !text-gray-300': disabled}, rightIconClass]"
70
- />
410
+ <div class="flex items-center gap-4">
411
+ <FzIcon
412
+ v-if="secondRightIcon && !secondRightIconButton"
413
+ :name="secondRightIcon"
414
+ size="md"
415
+ :variant="secondRightIconVariant"
416
+ :role="isSecondRightIconAccessible ? 'button' : undefined"
417
+ :aria-label="
418
+ isSecondRightIconAccessible ? secondRightIconAriaLabel : undefined
419
+ "
420
+ :aria-disabled="
421
+ isSecondRightIconAccessible && isReadonlyOrDisabled
422
+ ? 'true'
423
+ : undefined
424
+ "
425
+ :tabindex="
426
+ isSecondRightIconAccessible && !isReadonlyOrDisabled
427
+ ? 0
428
+ : undefined
429
+ "
430
+ :class="secondRightIconClass"
431
+ @click.stop="handleSecondRightIconClick"
432
+ @keydown="
433
+ isSecondRightIconAccessible
434
+ ? (e: KeyboardEvent) =>
435
+ handleIconKeydown(e, 'fzinput:second-right-icon-click')
436
+ : undefined
437
+ "
438
+ />
439
+ <FzIconButton
440
+ v-if="secondRightIcon && secondRightIconButton"
441
+ :iconName="secondRightIcon"
442
+ size="md"
443
+ :iconVariant="secondRightIconVariant"
444
+ :variant="
445
+ isReadonlyOrDisabled ? 'invisible' : secondRightIconButtonVariant
446
+ "
447
+ @click.stop="handleSecondRightIconClick"
448
+ :class="[
449
+ { 'bg-grey-100 !text-grey-300': isReadonlyOrDisabled },
450
+ secondRightIconClass,
451
+ ]"
452
+ />
453
+ <FzIcon
454
+ v-if="rightIcon && !rightIconButton"
455
+ :name="rightIcon"
456
+ size="md"
457
+ :variant="rightIconVariant"
458
+ :role="isRightIconAccessible ? 'button' : undefined"
459
+ :aria-label="isRightIconAccessible ? rightIconAriaLabel : undefined"
460
+ :aria-disabled="
461
+ isRightIconAccessible && isReadonlyOrDisabled ? 'true' : undefined
462
+ "
463
+ :tabindex="
464
+ isRightIconAccessible && !isReadonlyOrDisabled ? 0 : undefined
465
+ "
466
+ :class="rightIconClass"
467
+ @click.stop="handleRightIconClick"
468
+ @keydown="
469
+ isRightIconAccessible
470
+ ? (e: KeyboardEvent) =>
471
+ handleIconKeydown(e, 'fzinput:right-icon-click')
472
+ : undefined
473
+ "
474
+ />
475
+ <FzIconButton
476
+ v-if="rightIcon && rightIconButton"
477
+ :iconName="rightIcon"
478
+ size="md"
479
+ :iconVariant="rightIconVariant"
480
+ :variant="
481
+ isReadonlyOrDisabled ? 'invisible' : rightIconButtonVariant
482
+ "
483
+ @click.stop="handleRightIconClick"
484
+ :class="[
485
+ { 'bg-grey-100 !text-grey-300': isReadonlyOrDisabled },
486
+ rightIconClass,
487
+ ]"
488
+ />
489
+ <FzIcon
490
+ v-if="valid"
491
+ name="check"
492
+ size="md"
493
+ class="text-semantic-success"
494
+ aria-hidden="true"
495
+ />
496
+ </div>
71
497
  </slot>
72
498
  </div>
73
499
  <div
74
500
  v-if="error && $slots.errorMessage"
75
- class="flex gap-4"
501
+ :id="`${uniqueId}-error`"
502
+ role="alert"
503
+ class="flex items-start gap-[6px]"
76
504
  :style="{ width: containerWidth }"
77
505
  >
78
506
  <FzIcon
79
- name="triangle-exclamation"
80
- class="text-semantic-error"
81
- :size="size"
507
+ name="circle-xmark"
508
+ class="text-semantic-error-200"
509
+ size="md"
510
+ aria-hidden="true"
82
511
  />
83
- <div :class="['mt-1', computedErrorClass]">
512
+ <div :class="computedErrorClass">
84
513
  <slot name="errorMessage"></slot>
85
514
  </div>
86
515
  </div>
87
516
  <span
88
517
  v-else-if="$slots.helpText"
518
+ :id="`${uniqueId}-help`"
89
519
  :class="[computedHelpClass]"
90
520
  :style="{ width: containerWidth }"
91
521
  >
@@ -94,62 +524,4 @@
94
524
  </div>
95
525
  </template>
96
526
 
97
- <script setup lang="ts">
98
- import { computed, toRefs, Ref, ref } from "vue";
99
- import { FzInputProps } from "./types";
100
- import { FzIcon } from "@fiscozen/icons";
101
- import { FzIconButton } from "@fiscozen/button";
102
- import useInputStyle from "./useInputStyle";
103
-
104
- const props = withDefaults(defineProps<FzInputProps>(), {
105
- size: "md",
106
- error: false,
107
- type: "text",
108
- rightIconButtonVariant: 'invisible',
109
- variant: 'normal'
110
- });
111
-
112
- const model = defineModel();
113
- const containerRef: Ref<HTMLElement | null> = ref(null);
114
- const inputRef: Ref<HTMLInputElement | null> = ref(null);
115
- const uniqueId = `fz-input-${Math.random().toString(36).slice(2, 9)}`;
116
-
117
- const {
118
- staticContainerClass,
119
- computedContainerClass,
120
- computedLabelClass,
121
- staticInputClass,
122
- computedInputClass,
123
- computedHelpClass,
124
- computedErrorClass,
125
- containerWidth,
126
- showNormalPlaceholder
127
- } = useInputStyle(toRefs(props), containerRef, model);
128
-
129
-
130
- const sizeMap = {
131
- xl: 'lg',
132
- lg: 'md',
133
- md: 'sm',
134
- sm: 'xs',
135
- }
136
-
137
- const mappedSize = computed(() => {
138
- return sizeMap[props.size];
139
- });
140
-
141
- const emit = defineEmits([
142
- "input",
143
- "focus",
144
- "paste",
145
- "blur",
146
- "fzinput:left-icon-click",
147
- "fzinput:right-icon-click",
148
- ]);
149
- defineExpose({
150
- inputRef,
151
- containerRef,
152
- });
153
- </script>
154
-
155
527
  <style scoped></style>