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.
- package/README.md +19 -1
- package/dist/Form/AppAutoCompleter.d.ts +11 -0
- package/dist/Form/AppAutoCompleter.d.ts.map +1 -0
- package/dist/Form/AppCheckBox.d.ts +15 -0
- package/dist/Form/AppCheckBox.d.ts.map +1 -0
- package/dist/Form/AppDateAndTimePicker.d.ts +14 -0
- package/dist/Form/AppDateAndTimePicker.d.ts.map +1 -0
- package/dist/Form/AppDatePicker.d.ts +11 -0
- package/dist/Form/AppDatePicker.d.ts.map +1 -0
- package/dist/Form/AppFormErrorMessage.d.ts +9 -0
- package/dist/Form/AppFormErrorMessage.d.ts.map +1 -0
- package/dist/Form/AppInputField.d.ts +9 -0
- package/dist/Form/AppInputField.d.ts.map +1 -0
- package/dist/Form/AppMultiSelector.d.ts +20 -0
- package/dist/Form/AppMultiSelector.d.ts.map +1 -0
- package/dist/Form/AppPhoneNoInput.d.ts +16 -0
- package/dist/Form/AppPhoneNoInput.d.ts.map +1 -0
- package/dist/Form/AppRadioGroup.d.ts +17 -0
- package/dist/Form/AppRadioGroup.d.ts.map +1 -0
- package/dist/Form/AppRating.d.ts +12 -0
- package/dist/Form/AppRating.d.ts.map +1 -0
- package/dist/Form/AppSelectInput.d.ts +15 -0
- package/dist/Form/AppSelectInput.d.ts.map +1 -0
- package/dist/Form/AppSimpleUploadFile.d.ts +14 -0
- package/dist/Form/AppSimpleUploadFile.d.ts.map +1 -0
- package/dist/Form/AppSwitch.d.ts +10 -0
- package/dist/Form/AppSwitch.d.ts.map +1 -0
- package/dist/Form/AppTagsCreator.d.ts +11 -0
- package/dist/Form/AppTagsCreator.d.ts.map +1 -0
- package/dist/Form/AppTextArea.d.ts +10 -0
- package/dist/Form/AppTextArea.d.ts.map +1 -0
- package/dist/Form/AppUploadFile.d.ts +20 -0
- package/dist/Form/AppUploadFile.d.ts.map +1 -0
- package/dist/Form/SubmitButton.d.ts +10 -0
- package/dist/Form/SubmitButton.d.ts.map +1 -0
- package/dist/Form/index.d.ts +10 -0
- package/dist/Form/index.d.ts.map +1 -0
- package/dist/assets/illustrations/BackgroundIllustration.d.ts +7 -0
- package/dist/assets/illustrations/BackgroundIllustration.d.ts.map +1 -0
- package/dist/assets/illustrations/UploadIllustration.d.ts +5 -0
- package/dist/assets/illustrations/UploadIllustration.d.ts.map +1 -0
- package/dist/assets/illustrations/index.d.ts +2 -0
- package/dist/assets/illustrations/index.d.ts.map +1 -0
- package/dist/file-thumbnail/types.d.ts +6 -0
- package/dist/file-thumbnail/types.d.ts.map +1 -0
- package/dist/file-thumbnail/utils.d.ts +26 -0
- package/dist/file-thumbnail/utils.d.ts.map +1 -0
- package/dist/index.esm.js +1 -2
- package/dist/index.js +1 -2
- package/dist/lib/index.d.ts +29 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/optional-deps.d.ts +13 -0
- package/dist/lib/optional-deps.d.ts.map +1 -0
- package/dist/upload/Upload.d.ts +5 -0
- package/dist/upload/Upload.d.ts.map +1 -0
- package/dist/upload/errors/RejectionFiles.d.ts +8 -0
- package/dist/upload/errors/RejectionFiles.d.ts.map +1 -0
- package/dist/upload/index.d.ts +6 -0
- package/dist/upload/index.d.ts.map +1 -0
- package/dist/upload/preview/MultiFilePreview.d.ts +11 -0
- package/dist/upload/preview/MultiFilePreview.d.ts.map +1 -0
- package/dist/upload/preview/SingleFilePreview.d.ts +9 -0
- package/dist/upload/preview/SingleFilePreview.d.ts.map +1 -0
- package/dist/upload/types.d.ts +40 -0
- package/dist/upload/types.d.ts.map +1 -0
- package/package.json +22 -16
- package/src/App.css +38 -0
- package/src/App.test.tsx +9 -0
- package/src/App.tsx +166 -0
- package/src/Form/AppAutoCompleter.tsx +252 -0
- package/src/Form/AppCheckBox.tsx +101 -0
- package/src/Form/AppDateAndTimePicker.tsx +94 -0
- package/src/Form/AppDatePicker.tsx +69 -0
- package/src/Form/AppFormErrorMessage.tsx +34 -0
- package/src/Form/AppInputField.tsx +80 -0
- package/src/Form/AppMultiSelector.tsx +163 -0
- package/src/Form/AppPhoneNoInput.tsx +106 -0
- package/src/Form/AppRadioGroup.tsx +92 -0
- package/src/Form/AppRating.tsx +98 -0
- package/src/Form/AppSelectInput.tsx +249 -0
- package/src/Form/AppSimpleUploadFile.tsx +154 -0
- package/src/Form/AppSwitch.tsx +84 -0
- package/src/Form/AppTagsCreator.tsx +252 -0
- package/src/Form/AppTextArea.tsx +90 -0
- package/src/Form/AppUploadFile.tsx +167 -0
- package/src/Form/SubmitButton.tsx +122 -0
- package/src/Form/index.tsx +27 -0
- package/src/assets/illustrations/BackgroundIllustration.tsx +42 -0
- package/src/assets/illustrations/UploadIllustration.tsx +659 -0
- package/src/assets/illustrations/index.ts +1 -0
- package/src/file-thumbnail/types.ts +7 -0
- package/src/file-thumbnail/utils.ts +162 -0
- package/src/index.css +9 -0
- package/src/index.tsx +19 -0
- package/src/lib/index.ts +47 -0
- package/src/logo.svg +1 -0
- package/src/react-app-env.d.ts +1 -0
- package/src/reportWebVitals.ts +15 -0
- package/src/setupTests.ts +5 -0
- package/src/styles/PhoneInputCustom.css +238 -0
- package/src/styles/compiled-tailwind.css +1 -0
- package/src/upload/Upload.tsx +162 -0
- package/src/upload/errors/RejectionFiles.tsx +49 -0
- package/src/upload/index.ts +5 -0
- package/src/upload/preview/MultiFilePreview.tsx +297 -0
- package/src/upload/preview/SingleFilePreview.tsx +81 -0
- 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">▾</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
|
+
}
|