formik-form-components 2.0.2 → 2.0.3

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.
Files changed (107) hide show
  1. package/README.md +19 -1
  2. package/dist/Form/AppAutoCompleter.d.ts +11 -0
  3. package/dist/Form/AppAutoCompleter.d.ts.map +1 -0
  4. package/dist/Form/AppCheckBox.d.ts +15 -0
  5. package/dist/Form/AppCheckBox.d.ts.map +1 -0
  6. package/dist/Form/AppDateAndTimePicker.d.ts +14 -0
  7. package/dist/Form/AppDateAndTimePicker.d.ts.map +1 -0
  8. package/dist/Form/AppDatePicker.d.ts +11 -0
  9. package/dist/Form/AppDatePicker.d.ts.map +1 -0
  10. package/dist/Form/AppFormErrorMessage.d.ts +9 -0
  11. package/dist/Form/AppFormErrorMessage.d.ts.map +1 -0
  12. package/dist/Form/AppInputField.d.ts +9 -0
  13. package/dist/Form/AppInputField.d.ts.map +1 -0
  14. package/dist/Form/AppMultiSelector.d.ts +20 -0
  15. package/dist/Form/AppMultiSelector.d.ts.map +1 -0
  16. package/dist/Form/AppPhoneNoInput.d.ts +16 -0
  17. package/dist/Form/AppPhoneNoInput.d.ts.map +1 -0
  18. package/dist/Form/AppRadioGroup.d.ts +17 -0
  19. package/dist/Form/AppRadioGroup.d.ts.map +1 -0
  20. package/dist/Form/AppRating.d.ts +12 -0
  21. package/dist/Form/AppRating.d.ts.map +1 -0
  22. package/dist/Form/AppSelectInput.d.ts +15 -0
  23. package/dist/Form/AppSelectInput.d.ts.map +1 -0
  24. package/dist/Form/AppSimpleUploadFile.d.ts +14 -0
  25. package/dist/Form/AppSimpleUploadFile.d.ts.map +1 -0
  26. package/dist/Form/AppSwitch.d.ts +10 -0
  27. package/dist/Form/AppSwitch.d.ts.map +1 -0
  28. package/dist/Form/AppTagsCreator.d.ts +11 -0
  29. package/dist/Form/AppTagsCreator.d.ts.map +1 -0
  30. package/dist/Form/AppTextArea.d.ts +10 -0
  31. package/dist/Form/AppTextArea.d.ts.map +1 -0
  32. package/dist/Form/AppUploadFile.d.ts +20 -0
  33. package/dist/Form/AppUploadFile.d.ts.map +1 -0
  34. package/dist/Form/SubmitButton.d.ts +10 -0
  35. package/dist/Form/SubmitButton.d.ts.map +1 -0
  36. package/dist/Form/index.d.ts +10 -0
  37. package/dist/Form/index.d.ts.map +1 -0
  38. package/dist/assets/illustrations/BackgroundIllustration.d.ts +7 -0
  39. package/dist/assets/illustrations/BackgroundIllustration.d.ts.map +1 -0
  40. package/dist/assets/illustrations/UploadIllustration.d.ts +5 -0
  41. package/dist/assets/illustrations/UploadIllustration.d.ts.map +1 -0
  42. package/dist/assets/illustrations/index.d.ts +2 -0
  43. package/dist/assets/illustrations/index.d.ts.map +1 -0
  44. package/dist/file-thumbnail/types.d.ts +6 -0
  45. package/dist/file-thumbnail/types.d.ts.map +1 -0
  46. package/dist/file-thumbnail/utils.d.ts +26 -0
  47. package/dist/file-thumbnail/utils.d.ts.map +1 -0
  48. package/dist/index.esm.js +1 -2
  49. package/dist/index.js +1 -2
  50. package/dist/lib/index.d.ts +29 -0
  51. package/dist/lib/index.d.ts.map +1 -0
  52. package/dist/lib/optional-deps.d.ts +13 -0
  53. package/dist/lib/optional-deps.d.ts.map +1 -0
  54. package/dist/upload/Upload.d.ts +5 -0
  55. package/dist/upload/Upload.d.ts.map +1 -0
  56. package/dist/upload/errors/RejectionFiles.d.ts +8 -0
  57. package/dist/upload/errors/RejectionFiles.d.ts.map +1 -0
  58. package/dist/upload/index.d.ts +6 -0
  59. package/dist/upload/index.d.ts.map +1 -0
  60. package/dist/upload/preview/MultiFilePreview.d.ts +11 -0
  61. package/dist/upload/preview/MultiFilePreview.d.ts.map +1 -0
  62. package/dist/upload/preview/SingleFilePreview.d.ts +9 -0
  63. package/dist/upload/preview/SingleFilePreview.d.ts.map +1 -0
  64. package/dist/upload/types.d.ts +40 -0
  65. package/dist/upload/types.d.ts.map +1 -0
  66. package/package.json +22 -16
  67. package/src/App.css +38 -0
  68. package/src/App.test.tsx +9 -0
  69. package/src/App.tsx +166 -0
  70. package/src/Form/AppAutoCompleter.tsx +252 -0
  71. package/src/Form/AppCheckBox.tsx +101 -0
  72. package/src/Form/AppDateAndTimePicker.tsx +94 -0
  73. package/src/Form/AppDatePicker.tsx +69 -0
  74. package/src/Form/AppFormErrorMessage.tsx +34 -0
  75. package/src/Form/AppInputField.tsx +80 -0
  76. package/src/Form/AppMultiSelector.tsx +163 -0
  77. package/src/Form/AppPhoneNoInput.tsx +106 -0
  78. package/src/Form/AppRadioGroup.tsx +92 -0
  79. package/src/Form/AppRating.tsx +98 -0
  80. package/src/Form/AppSelectInput.tsx +249 -0
  81. package/src/Form/AppSimpleUploadFile.tsx +154 -0
  82. package/src/Form/AppSwitch.tsx +84 -0
  83. package/src/Form/AppTagsCreator.tsx +252 -0
  84. package/src/Form/AppTextArea.tsx +90 -0
  85. package/src/Form/AppUploadFile.tsx +167 -0
  86. package/src/Form/SubmitButton.tsx +122 -0
  87. package/src/Form/index.tsx +27 -0
  88. package/src/assets/illustrations/BackgroundIllustration.tsx +42 -0
  89. package/src/assets/illustrations/UploadIllustration.tsx +659 -0
  90. package/src/assets/illustrations/index.ts +1 -0
  91. package/src/file-thumbnail/types.ts +7 -0
  92. package/src/file-thumbnail/utils.ts +162 -0
  93. package/src/index.css +9 -0
  94. package/src/index.tsx +19 -0
  95. package/src/lib/index.ts +47 -0
  96. package/src/logo.svg +1 -0
  97. package/src/react-app-env.d.ts +1 -0
  98. package/src/reportWebVitals.ts +15 -0
  99. package/src/setupTests.ts +5 -0
  100. package/src/styles/PhoneInputCustom.css +238 -0
  101. package/src/styles/compiled-tailwind.css +1 -0
  102. package/src/upload/Upload.tsx +162 -0
  103. package/src/upload/errors/RejectionFiles.tsx +49 -0
  104. package/src/upload/index.ts +5 -0
  105. package/src/upload/preview/MultiFilePreview.tsx +297 -0
  106. package/src/upload/preview/SingleFilePreview.tsx +81 -0
  107. package/src/upload/types.ts +51 -0
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useRef, useEffect } from "react";
4
+ import { useFormikContext, FormikValues } from "formik";
5
+
6
+ export interface AppSelectOption {
7
+ label: string;
8
+ value: string | number;
9
+ disabled?: boolean;
10
+ icon?: React.ReactNode;
11
+ }
12
+
13
+ interface AppMultiSelectProps
14
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
15
+ name: string;
16
+ label?: string;
17
+ options: AppSelectOption[];
18
+ maxSelections?: number;
19
+ placeholder?: string;
20
+ showSelectedCount?: boolean;
21
+ className?: string;
22
+ onChange?: (value: Array<string | number>) => void;
23
+ }
24
+
25
+ const AppMultiSelect: React.FC<AppMultiSelectProps> = ({
26
+ name,
27
+ label,
28
+ options = [],
29
+ maxSelections,
30
+ placeholder = "Select options...",
31
+ showSelectedCount = true,
32
+ className = "",
33
+ onChange: externalOnChange,
34
+ ...rest
35
+ }) => {
36
+ const { values, setFieldValue, errors, touched, setFieldTouched } =
37
+ useFormikContext<FormikValues>();
38
+
39
+ const fieldValue = Array.isArray(values[name]) ? values[name] : [];
40
+ const fieldError = errors[name] as string | undefined;
41
+ const isTouched = touched[name] as boolean | undefined;
42
+
43
+ const [isOpen, setIsOpen] = useState(false);
44
+ const [search, setSearch] = useState("");
45
+ const dropdownRef = useRef<HTMLDivElement>(null);
46
+
47
+ const filteredOptions = options.filter(
48
+ (opt) =>
49
+ !search ||
50
+ opt.label.toLowerCase().includes(search.toLowerCase()) ||
51
+ String(opt.value).toLowerCase().includes(search.toLowerCase())
52
+ );
53
+
54
+ const isMaxReached = maxSelections
55
+ ? fieldValue.length >= maxSelections
56
+ : false;
57
+
58
+ const toggleOption = (value: string | number) => {
59
+ let newValues: Array<string | number>;
60
+ if (fieldValue.includes(value)) {
61
+ newValues = fieldValue.filter((v: string | number) => v !== value);
62
+ } else {
63
+ if (isMaxReached) return;
64
+ newValues = [...fieldValue, value];
65
+ }
66
+ setFieldValue(name, newValues);
67
+ if (externalOnChange) externalOnChange(newValues);
68
+ };
69
+
70
+ const handleBlur = () => setFieldTouched(name, true);
71
+
72
+ useEffect(() => {
73
+ const handleClickOutside = (event: MouseEvent) => {
74
+ if (
75
+ dropdownRef.current &&
76
+ !dropdownRef.current.contains(event.target as Node)
77
+ ) {
78
+ setIsOpen(false);
79
+ handleBlur();
80
+ }
81
+ };
82
+ document.addEventListener("mousedown", handleClickOutside);
83
+ return () => document.removeEventListener("mousedown", handleClickOutside);
84
+ }, []);
85
+
86
+ const selectedLabels = options
87
+ .filter((opt) => fieldValue.includes(opt.value))
88
+ .map((opt) => opt.label)
89
+ .join(", ");
90
+
91
+ return (
92
+ <div className={`relative w-full ${className}`} ref={dropdownRef} {...rest}>
93
+ {label && (
94
+ <label className="block mb-1 text-sm font-medium text-gray-700">
95
+ {label}
96
+ </label>
97
+ )}
98
+
99
+ <div
100
+ className={`border rounded-md px-3 py-2 bg-white flex justify-between items-center cursor-pointer
101
+ hover:ring-1 hover:ring-blue-500
102
+ ${fieldError && isTouched ? "border-red-500" : "border-gray-300"}`}
103
+ onClick={() => setIsOpen(!isOpen)}
104
+ >
105
+ <span className="text-sm text-gray-700">
106
+ {fieldValue.length ? selectedLabels : placeholder}
107
+ </span>
108
+ <span className="ml-2">&#9662;</span>
109
+ </div>
110
+
111
+ {isOpen && (
112
+ <div className="absolute z-10 mt-1 w-full bg-white border border-gray-300 rounded-md shadow max-h-60 overflow-y-auto">
113
+ <input
114
+ type="text"
115
+ value={search}
116
+ onChange={(e) => setSearch(e.target.value)}
117
+ placeholder="Search..."
118
+ className="w-full px-3 py-2 border-b border-gray-200 text-sm focus:outline-none"
119
+ />
120
+ {filteredOptions.map((option) => {
121
+ const checked = fieldValue.includes(option.value);
122
+ const disabledOption =
123
+ option.disabled || (isMaxReached && !checked);
124
+ return (
125
+ <div
126
+ key={option.value}
127
+ className={`flex items-center px-3 py-2 cursor-pointer hover:bg-gray-100
128
+ ${disabledOption ? "opacity-50 cursor-not-allowed" : ""}`}
129
+ onClick={() => !disabledOption && toggleOption(option.value)}
130
+ >
131
+ <input
132
+ type="checkbox"
133
+ checked={checked}
134
+ readOnly
135
+ className="mr-2"
136
+ />
137
+ {option.icon}
138
+ <span className="text-sm text-gray-700">{option.label}</span>
139
+ </div>
140
+ );
141
+ })}
142
+ </div>
143
+ )}
144
+
145
+ {showSelectedCount && (
146
+ <p
147
+ className={`mt-1 text-xs ${
148
+ isMaxReached ? "text-red-500" : "text-gray-500"
149
+ }`}
150
+ >
151
+ {`${fieldValue.length} selected`}
152
+ {maxSelections ? ` (${fieldValue.length} of ${maxSelections})` : ""}
153
+ </p>
154
+ )}
155
+
156
+ {isTouched && fieldError && (
157
+ <p className="mt-1 text-xs text-red-500">{fieldError}</p>
158
+ )}
159
+ </div>
160
+ );
161
+ };
162
+
163
+ export default AppMultiSelect;
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef } from "react";
4
+ import PhoneInput from "react-phone-number-input";
5
+ import "react-phone-number-input/style.css";
6
+ import "../styles/PhoneInputCustom.css";
7
+
8
+ interface AppPhoneNoInputProps
9
+ extends Omit<
10
+ React.InputHTMLAttributes<HTMLInputElement>,
11
+ "onChange" | "value"
12
+ > {
13
+ name: string;
14
+ label: string;
15
+ international?: boolean;
16
+ withCountryCallingCode?: boolean;
17
+ className?: string;
18
+ onChange?: (value: string | undefined) => void;
19
+ value?: string;
20
+ error?: string;
21
+ }
22
+
23
+ const AppPhoneNoInput: React.FC<AppPhoneNoInputProps> = ({
24
+ name,
25
+ label,
26
+ international = true,
27
+ withCountryCallingCode = true,
28
+ className = "",
29
+ onChange,
30
+ value,
31
+ disabled,
32
+ error,
33
+ placeholder = "Enter phone number", // Default still available here
34
+ required = false, // Default still available here
35
+ ...props
36
+ }) => {
37
+ const [phoneValue, setPhoneValue] = useState<string | undefined>(value);
38
+ const [hasFocus, setHasFocus] = useState(false);
39
+
40
+ useEffect(() => {
41
+ setPhoneValue(value);
42
+ }, [value]);
43
+
44
+ const handleChange = (phoneNumber: string | undefined) => {
45
+ setPhoneValue(phoneNumber);
46
+ if (onChange) {
47
+ onChange(phoneNumber);
48
+ }
49
+ };
50
+
51
+ const inputRef = useRef<HTMLDivElement>(null);
52
+
53
+ return (
54
+ <div className={`phone-input-wrapper ${className}`}>
55
+ {label && (
56
+ <label
57
+ htmlFor={`${name}-phone-input`}
58
+ className={`phone-input-label ${
59
+ hasFocus || phoneValue ? "has-value" : ""
60
+ }`}
61
+ >
62
+ {label}
63
+ {required && (
64
+ <span className="required-star" aria-hidden="true">
65
+ *
66
+ </span>
67
+ )}
68
+ </label>
69
+ )}
70
+
71
+ <div
72
+ className={`phone-input-container ${error ? "error" : ""} ${
73
+ hasFocus ? "focus" : ""
74
+ }`}
75
+ ref={inputRef}
76
+ >
77
+ <PhoneInput
78
+ international={international}
79
+ withCountryCallingCode={withCountryCallingCode}
80
+ defaultCountry="PK"
81
+ value={phoneValue}
82
+ onChange={handleChange}
83
+ onFocus={() => setHasFocus(true)}
84
+ onBlur={() => setHasFocus(false)}
85
+ id={`${name}-phone-input`}
86
+ disabled={disabled}
87
+ placeholder={placeholder}
88
+ required={required}
89
+ aria-required={required}
90
+ className="custom-phone-input"
91
+ aria-invalid={!!error}
92
+ aria-describedby={error ? `${name}-error` : undefined}
93
+ {...props}
94
+ />
95
+ </div>
96
+
97
+ {error && (
98
+ <div id={`${name}-error`} className="error-message" role="alert">
99
+ {error}
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+ };
105
+
106
+ export default AppPhoneNoInput;
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import React from "react";
4
+ import { useFormikContext, FormikValues } from "formik";
5
+
6
+ export interface RadioOption {
7
+ label: string;
8
+ value: string | number;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ interface AppRadioGroupProps
13
+ extends Omit<
14
+ React.InputHTMLAttributes<HTMLInputElement>,
15
+ "onChange" | "value" | "name"
16
+ > {
17
+ name: string;
18
+ options: RadioOption[];
19
+ label?: string;
20
+ className?: string;
21
+ row?: boolean;
22
+ onChange?: (value: string | number) => void;
23
+ }
24
+
25
+ const AppRadioGroup: React.FC<AppRadioGroupProps> = ({
26
+ name,
27
+ options = [],
28
+ label,
29
+ className = "",
30
+ row = false,
31
+ onChange: externalOnChange,
32
+ ...rest
33
+ }) => {
34
+ const { values, setFieldValue, errors, touched, setFieldTouched } =
35
+ useFormikContext<FormikValues>();
36
+
37
+ const fieldValue = values[name] as string | number | undefined;
38
+ const fieldError = errors[name] as string | undefined;
39
+ const isTouched = touched[name] as boolean | undefined;
40
+
41
+ const handleChange = (value: string | number) => {
42
+ setFieldValue(name, value);
43
+ if (externalOnChange) externalOnChange(value);
44
+ };
45
+
46
+ const handleBlur = () => setFieldTouched(name, true);
47
+
48
+ return (
49
+ <div className={`w-full ${className}`}>
50
+ {label && (
51
+ <label className="block text-sm font-medium text-gray-700 mb-1">
52
+ {label}
53
+ {rest.required && <span className="text-red-500 ml-1">*</span>}
54
+ </label>
55
+ )}
56
+
57
+ <div
58
+ className={`flex ${row ? "flex-row space-x-4" : "flex-col space-y-2"}`}
59
+ onBlur={handleBlur}
60
+ >
61
+ {options.map((option) => (
62
+ <label
63
+ key={option.value}
64
+ className={`flex items-center cursor-pointer ${
65
+ option.disabled || rest.disabled
66
+ ? "opacity-50 cursor-not-allowed"
67
+ : ""
68
+ }`}
69
+ >
70
+ <input
71
+ type="radio"
72
+ name={name}
73
+ value={option.value}
74
+ checked={fieldValue === option.value}
75
+ disabled={option.disabled || rest.disabled}
76
+ onChange={() => handleChange(option.value)}
77
+ className="form-radio h-4 w-4 text-blue-600 focus:ring-blue-500"
78
+ {...rest}
79
+ />
80
+ <span className="ml-2 text-gray-700">{option.label}</span>
81
+ </label>
82
+ ))}
83
+ </div>
84
+
85
+ {isTouched && fieldError && (
86
+ <p className="mt-1 text-xs text-red-500">{fieldError}</p>
87
+ )}
88
+ </div>
89
+ );
90
+ };
91
+
92
+ export default AppRadioGroup;
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import React, { forwardRef, useState } from "react";
4
+ import { useFormikContext, FormikValues } from "formik";
5
+
6
+ interface AppRatingProps
7
+ extends Omit<
8
+ React.InputHTMLAttributes<HTMLInputElement>,
9
+ "onChange" | "value"
10
+ > {
11
+ name: string;
12
+ label?: string;
13
+ max?: number;
14
+ helperText?: string;
15
+ onChange?: (value: number | null) => void;
16
+ className?: string;
17
+ }
18
+
19
+ const AppRating = forwardRef<HTMLDivElement, AppRatingProps>(
20
+ (
21
+ {
22
+ name,
23
+ label,
24
+ max = 5,
25
+ helperText,
26
+ onChange: externalOnChange,
27
+ className = "",
28
+ ...rest
29
+ },
30
+ ref
31
+ ) => {
32
+ const { values, errors, touched, setFieldValue, setFieldTouched } =
33
+ useFormikContext<FormikValues>();
34
+ const [hoverValue, setHoverValue] = useState<number | null>(null);
35
+
36
+ const val = (values[name] as number | null | undefined) ?? 0;
37
+ const fieldError = errors[name] as string | undefined;
38
+ const isTouched = touched[name] as boolean | undefined;
39
+ const hasError = Boolean(fieldError) && isTouched;
40
+
41
+ const handleClick = (rating: number) => {
42
+ setFieldValue(name, rating);
43
+ setFieldTouched(name, true);
44
+ externalOnChange?.(rating);
45
+ };
46
+
47
+ return (
48
+ <div ref={ref} className={`flex flex-col w-full ${className}`} {...rest}>
49
+ {label && (
50
+ <label
51
+ className={`block text-sm font-medium mb-1 ${
52
+ hasError ? "text-red-500" : "text-gray-700"
53
+ }`}
54
+ >
55
+ {label}
56
+ </label>
57
+ )}
58
+
59
+ <div className="flex space-x-1">
60
+ {Array.from({ length: max }, (_, i) => {
61
+ const starValue = i + 1;
62
+ const filled =
63
+ hoverValue !== null ? starValue <= hoverValue : starValue <= val;
64
+
65
+ return (
66
+ <button
67
+ key={i}
68
+ type="button"
69
+ className={`text-2xl transition-colors duration-150 ${
70
+ filled ? "text-yellow-400" : "text-gray-300"
71
+ }`}
72
+ onClick={() => handleClick(starValue)}
73
+ onMouseEnter={() => setHoverValue(starValue)}
74
+ onMouseLeave={() => setHoverValue(null)}
75
+ >
76
+
77
+ </button>
78
+ );
79
+ })}
80
+ </div>
81
+
82
+ {(hasError || helperText) && (
83
+ <p
84
+ className={`mt-1 text-xs ${
85
+ hasError ? "text-red-500" : "text-gray-500"
86
+ }`}
87
+ >
88
+ {hasError ? fieldError : helperText}
89
+ </p>
90
+ )}
91
+ </div>
92
+ );
93
+ }
94
+ );
95
+
96
+ AppRating.displayName = "AppRating";
97
+
98
+ export default AppRating;
@@ -0,0 +1,249 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from "react";
4
+ import { useFormikContext, FormikValues } from "formik";
5
+
6
+ export interface AppSelectOption {
7
+ label: string;
8
+ value: string | number;
9
+ disabled?: boolean;
10
+ }
11
+
12
+ export interface AppSelectProps
13
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
14
+ name: string;
15
+ options: AppSelectOption[];
16
+ label?: string;
17
+ helperText?: string;
18
+ clearable?: boolean;
19
+ }
20
+
21
+ export default function AppSelect({
22
+ name,
23
+ options,
24
+ label,
25
+ helperText,
26
+ clearable = true,
27
+ className = "",
28
+ id,
29
+ ...divProps
30
+ }: AppSelectProps): React.ReactElement {
31
+ const { values, setFieldValue, errors, touched, setFieldTouched } =
32
+ useFormikContext<FormikValues>();
33
+
34
+ const containerRef = useRef<HTMLDivElement>(null);
35
+ const [isOpen, setIsOpen] = useState(false);
36
+ const [search, setSearch] = useState("");
37
+
38
+ const fieldValue = values[name] as string | number | undefined;
39
+ const fieldError = errors[name] as string | undefined;
40
+ const isTouched = touched[name] as boolean | undefined;
41
+ const hasError = Boolean(isTouched && fieldError);
42
+
43
+ const selectedOption = options.find((o) => o.value === fieldValue);
44
+
45
+ const filteredOptions = options.filter(
46
+ (o) =>
47
+ o.label.toLowerCase().includes(search.toLowerCase()) ||
48
+ String(o.value).toLowerCase().includes(search.toLowerCase())
49
+ );
50
+
51
+ const inputId = id ?? `select-${name}`;
52
+
53
+ const handleSelect = (value: string | number) => {
54
+ setFieldValue(name, value);
55
+ setFieldTouched(name, true);
56
+ setIsOpen(false);
57
+ setSearch("");
58
+ };
59
+
60
+ const handleClear = (e: React.MouseEvent) => {
61
+ e.stopPropagation();
62
+ setFieldValue(name, "");
63
+ setFieldTouched(name, true);
64
+ };
65
+
66
+ useEffect(() => {
67
+ const onClickOutside = (e: MouseEvent) => {
68
+ if (
69
+ containerRef.current &&
70
+ !containerRef.current.contains(e.target as Node)
71
+ ) {
72
+ setIsOpen(false);
73
+ }
74
+ };
75
+ document.addEventListener("mousedown", onClickOutside);
76
+ return () => document.removeEventListener("mousedown", onClickOutside);
77
+ }, []);
78
+
79
+ return (
80
+ <div ref={containerRef} className={`w-full ${className}`}>
81
+ {label && (
82
+ <label
83
+ htmlFor={inputId}
84
+ className="mb-2 block text-sm font-semibold text-gray-900"
85
+ >
86
+ {label}
87
+ </label>
88
+ )}
89
+
90
+ <div className="relative">
91
+ <div
92
+ id={inputId}
93
+ role="combobox"
94
+ aria-expanded={isOpen}
95
+ aria-haspopup="listbox"
96
+ aria-invalid={hasError}
97
+ tabIndex={divProps["aria-disabled"] ? -1 : 0}
98
+ {...divProps}
99
+ onClick={() => !divProps["aria-disabled"] && setIsOpen((p) => !p)}
100
+ onBlur={() => setFieldTouched(name, true)}
101
+ className={`
102
+ relative flex h-12 w-full cursor-pointer items-center rounded-xl bg-white px-4 pr-12 text-sm transition-all duration-200
103
+ focus:outline-none focus:ring-4
104
+ ${
105
+ hasError
106
+ ? "bg-red-50/50 focus:ring-red-200"
107
+ : divProps["aria-disabled"]
108
+ ? "cursor-not-allowed bg-gray-50 text-gray-400"
109
+ : isOpen
110
+ ? "bg-blue-50/30 focus:ring-blue-200"
111
+ : "hover:bg-gray-50 focus:ring-blue-200"
112
+ }
113
+ `}
114
+ >
115
+ <input
116
+ type="text"
117
+ value={search}
118
+ disabled={Boolean(divProps["aria-disabled"])}
119
+ onChange={(e) => setSearch(e.target.value)}
120
+ onFocus={() => setIsOpen(true)}
121
+ placeholder={selectedOption?.label || "Search options..."}
122
+ className="h-full w-full bg-transparent border-none outline-none shadow-none font-medium text-gray-900 placeholder:text-gray-500"
123
+ />
124
+
125
+ <span className="pointer-events-none absolute right-4">
126
+ <svg
127
+ className={`h-5 w-5 transition-transform duration-200 text-gray-400 ${
128
+ isOpen ? "rotate-180" : ""
129
+ }`}
130
+ fill="none"
131
+ stroke="currentColor"
132
+ viewBox="0 0 24 24"
133
+ >
134
+ <path
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ strokeWidth={2}
138
+ d="M19 9l-7 7-7-7"
139
+ />
140
+ </svg>
141
+ </span>
142
+ </div>
143
+
144
+ {clearable && fieldValue && !divProps["aria-disabled"] && (
145
+ <button
146
+ type="button"
147
+ onClick={handleClear}
148
+ className="absolute right-12 top-1/2 -translate-y-1/2 rounded-full p-1 text-gray-400 transition-all hover:bg-gray-100 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-200"
149
+ aria-label="Clear selection"
150
+ >
151
+ <svg
152
+ className="h-4 w-4"
153
+ fill="none"
154
+ viewBox="0 0 24 24"
155
+ stroke="currentColor"
156
+ >
157
+ <path
158
+ strokeLinecap="round"
159
+ strokeLinejoin="round"
160
+ strokeWidth={2}
161
+ d="M6 18L18 6M6 6l12 12"
162
+ />
163
+ </svg>
164
+ </button>
165
+ )}
166
+
167
+ {isOpen && (
168
+ <div className="absolute left-0 top-full z-50 mt-2 w-full max-h-60 rounded-2xl bg-white">
169
+ <div className="max-h-60 overflow-auto rounded-2xl border border-gray-100">
170
+ {filteredOptions.length > 0 ? (
171
+ filteredOptions.map((opt) => (
172
+ <button
173
+ key={opt.value}
174
+ type="button"
175
+ disabled={opt.disabled}
176
+ onClick={() => handleSelect(opt.value)}
177
+ className={`
178
+ flex w-full items-center px-4 py-3 text-left text-sm transition-all first:rounded-t-2xl last:rounded-b-2xl
179
+ ${
180
+ opt.disabled
181
+ ? "cursor-not-allowed bg-gray-50 text-gray-400"
182
+ : opt.value === fieldValue
183
+ ? "bg-gradient-to-r from-blue-50 to-indigo-50 font-semibold text-blue-900 border-r-2 border-blue-200"
184
+ : "text-gray-900 hover:bg-gray-50"
185
+ }
186
+ `}
187
+ >
188
+ {opt.value === fieldValue && (
189
+ <svg
190
+ className="mr-3 h-4 w-4 text-blue-500"
191
+ fill="currentColor"
192
+ viewBox="0 0 20 20"
193
+ >
194
+ <path
195
+ fillRule="evenodd"
196
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
197
+ clipRule="evenodd"
198
+ />
199
+ </svg>
200
+ )}
201
+ <span>{opt.label}</span>
202
+ </button>
203
+ ))
204
+ ) : (
205
+ <div className="flex flex-col items-center px-8 py-8 text-center text-sm text-gray-500">
206
+ <svg
207
+ className="mx-auto mb-2 h-12 w-12 text-gray-300"
208
+ fill="none"
209
+ viewBox="0 0 24 24"
210
+ stroke="currentColor"
211
+ >
212
+ <path
213
+ strokeLinecap="round"
214
+ strokeLinejoin="round"
215
+ strokeWidth={1}
216
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
217
+ />
218
+ </svg>
219
+ <p className="text-gray-400">No options found</p>
220
+ </div>
221
+ )}
222
+ </div>
223
+ </div>
224
+ )}
225
+ </div>
226
+
227
+ {hasError && (
228
+ <p className="mt-2 flex items-center gap-1.5 text-sm font-medium text-red-600">
229
+ <svg
230
+ className="h-4 w-4 flex-shrink-0"
231
+ fill="currentColor"
232
+ viewBox="0 0 20 20"
233
+ >
234
+ <path
235
+ fillRule="evenodd"
236
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
237
+ clipRule="evenodd"
238
+ />
239
+ </svg>
240
+ {fieldError}
241
+ </p>
242
+ )}
243
+
244
+ {!hasError && helperText && (
245
+ <p className="mt-2 text-sm text-gray-500">{helperText}</p>
246
+ )}
247
+ </div>
248
+ );
249
+ }