react-admin-crud-manager 1.0.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/README.md +40 -0
- package/package.json +55 -0
- package/src/App.jsx +5 -0
- package/src/components/Button/Button.jsx +85 -0
- package/src/components/Chip/Chip.jsx +71 -0
- package/src/components/CrudPage.jsx +532 -0
- package/src/components/Details/Details.jsx +134 -0
- package/src/components/Filter/FilterDrawer.jsx +99 -0
- package/src/components/Form/Form.jsx +51 -0
- package/src/components/Form/components/Checkbox.jsx +119 -0
- package/src/components/Form/components/ImagePicker.jsx +128 -0
- package/src/components/Form/components/Input.jsx +71 -0
- package/src/components/Form/components/InputLabel.jsx +12 -0
- package/src/components/Form/components/PhoneInput.jsx +221 -0
- package/src/components/Form/components/RenderFields.jsx +181 -0
- package/src/components/Form/components/Select.jsx +191 -0
- package/src/components/Form/components/Switch.jsx +64 -0
- package/src/components/Form/components/TextArea.jsx +31 -0
- package/src/components/Form/components/TinyEditor.jsx +113 -0
- package/src/components/Loader/Spinner.jsx +21 -0
- package/src/components/Modal/Modal.jsx +152 -0
- package/src/components/Table/Table.jsx +554 -0
- package/src/components/Table/components/ImagePreview.jsx +58 -0
- package/src/components/Table/components/TableSkeleton.jsx +39 -0
- package/src/data/countries.js +252 -0
- package/src/data/teams.js +130 -0
- package/src/index.css +170 -0
- package/src/lib/utils.js +74 -0
- package/src/main.jsx +11 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import Button from "../Button/Button";
|
|
4
|
+
import RenderFields from "../Form/components/RenderFields";
|
|
5
|
+
|
|
6
|
+
const FilterDrawer = ({ isOpen, onClose, config, onApply }) => {
|
|
7
|
+
const [filters, setFilters] = useState({});
|
|
8
|
+
|
|
9
|
+
const handleChange = (key, value) => {
|
|
10
|
+
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const handleApply = () => {
|
|
14
|
+
onApply?.(filters);
|
|
15
|
+
onClose();
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const handleReset = () => {
|
|
19
|
+
setFilters({});
|
|
20
|
+
onApply?.({});
|
|
21
|
+
onClose();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
{/* Overlay */}
|
|
27
|
+
<div
|
|
28
|
+
className={`fixed inset-0 bg-black/50 z-40 transition-opacity duration-300 ${
|
|
29
|
+
isOpen
|
|
30
|
+
? "opacity-100 pointer-events-auto"
|
|
31
|
+
: "opacity-0 pointer-events-none"
|
|
32
|
+
}`}
|
|
33
|
+
onClick={onClose}
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
{/* Drawer */}
|
|
37
|
+
<div
|
|
38
|
+
className={`fixed top-0 right-0 h-full w-[28rem] bg-white dark:bg-gray-900 shadow-2xl z-50 flex flex-col border-l border-gray-200 dark:border-gray-700
|
|
39
|
+
transform transition-transform duration-300 ease-in-out
|
|
40
|
+
${isOpen ? "translate-x-0" : "translate-x-full"}
|
|
41
|
+
`}
|
|
42
|
+
>
|
|
43
|
+
{/* Header */}
|
|
44
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
45
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
46
|
+
Filters
|
|
47
|
+
</h2>
|
|
48
|
+
<button
|
|
49
|
+
onClick={onClose}
|
|
50
|
+
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition"
|
|
51
|
+
>
|
|
52
|
+
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Content */}
|
|
57
|
+
<div className="flex-1 overflow-y-auto px-4 py-3">
|
|
58
|
+
{config?.component ? (
|
|
59
|
+
<config.component filters={filters} onFilterChange={handleChange} />
|
|
60
|
+
) : (
|
|
61
|
+
<div className="space-y-4">
|
|
62
|
+
{config?.fields?.map((field) => (
|
|
63
|
+
<>
|
|
64
|
+
<RenderFields
|
|
65
|
+
key={field.key}
|
|
66
|
+
field={field}
|
|
67
|
+
formData={filters}
|
|
68
|
+
handleChange={handleChange}
|
|
69
|
+
/>
|
|
70
|
+
</>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Footer */}
|
|
77
|
+
<div className="flex gap-2 px-4 py-3 border-t border-gray-200 dark:border-gray-700">
|
|
78
|
+
<Button
|
|
79
|
+
onClick={handleApply}
|
|
80
|
+
variant="contained"
|
|
81
|
+
color="primary"
|
|
82
|
+
fullWidth
|
|
83
|
+
>
|
|
84
|
+
Apply Filters
|
|
85
|
+
</Button>
|
|
86
|
+
<Button
|
|
87
|
+
onClick={handleReset}
|
|
88
|
+
variant="contained"
|
|
89
|
+
className="min-w-[150px]"
|
|
90
|
+
>
|
|
91
|
+
Reset
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default FilterDrawer;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import RenderFields from "./components/RenderFields";
|
|
3
|
+
|
|
4
|
+
const Form = ({ config, onSubmit, initialData = {} }) => {
|
|
5
|
+
const { formClass = "grid grid-cols-12 gap-4", formFields = [] } =
|
|
6
|
+
config || {};
|
|
7
|
+
|
|
8
|
+
const [formData, setFormData] = useState(initialData);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
setFormData(initialData);
|
|
12
|
+
}, []);
|
|
13
|
+
|
|
14
|
+
const handleChange = (key, value) => {
|
|
15
|
+
setFormData((prev) => ({ ...prev, [key]: value }));
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const handleSubmit = (e) => {
|
|
19
|
+
e.preventDefault();
|
|
20
|
+
const form = e.target;
|
|
21
|
+
|
|
22
|
+
if (!form.checkValidity()) {
|
|
23
|
+
form.reportValidity();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
onSubmit(formData);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<form
|
|
32
|
+
id={config.title?.toLowerCase().includes("edit") ? "editForm" : "addForm"}
|
|
33
|
+
onSubmit={handleSubmit}
|
|
34
|
+
className={formClass}
|
|
35
|
+
noValidate={false} // enables native validation
|
|
36
|
+
>
|
|
37
|
+
{formFields.map((field) => (
|
|
38
|
+
<>
|
|
39
|
+
<RenderFields
|
|
40
|
+
key={field.key}
|
|
41
|
+
field={field}
|
|
42
|
+
formData={formData}
|
|
43
|
+
handleChange={handleChange}
|
|
44
|
+
/>
|
|
45
|
+
</>
|
|
46
|
+
))}
|
|
47
|
+
</form>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default Form;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import InputLabel from "./InputLabel";
|
|
3
|
+
|
|
4
|
+
const Checkbox = ({
|
|
5
|
+
name = "",
|
|
6
|
+
label = "", // label for single checkbox
|
|
7
|
+
options = [], // array of { label, value } for multiple
|
|
8
|
+
value = null, // boolean for single, array for multiple, or string for single select
|
|
9
|
+
onChange,
|
|
10
|
+
disabled = false,
|
|
11
|
+
required = false,
|
|
12
|
+
parentClass = "col-span-12",
|
|
13
|
+
className = "",
|
|
14
|
+
multiSelect = false, // ✅ if true, only one option can be selected (like radio)
|
|
15
|
+
}) => {
|
|
16
|
+
// Detect if multiple options are present
|
|
17
|
+
const multiple = Array.isArray(options) && options.length > 0;
|
|
18
|
+
|
|
19
|
+
// Check if a value is selected
|
|
20
|
+
const isChecked = (optionValue) => {
|
|
21
|
+
if (multiple) {
|
|
22
|
+
if (!multiSelect) return value === optionValue;
|
|
23
|
+
return Array.isArray(value) && value.includes(optionValue);
|
|
24
|
+
}
|
|
25
|
+
return Boolean(value);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Handle change for single checkbox
|
|
29
|
+
const handleSingleChange = (e) => {
|
|
30
|
+
onChange?.(e.target.checked, name);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Handle change for multiple checkboxes
|
|
34
|
+
const handleMultipleChange = (optionValue, checked) => {
|
|
35
|
+
if (!onChange) return;
|
|
36
|
+
|
|
37
|
+
if (!multiSelect) {
|
|
38
|
+
if (checked) {
|
|
39
|
+
onChange(optionValue, name);
|
|
40
|
+
} else {
|
|
41
|
+
onChange("", name);
|
|
42
|
+
}
|
|
43
|
+
// Only allow one selected
|
|
44
|
+
} else {
|
|
45
|
+
const newValue = Array.isArray(value) ? [...value] : [];
|
|
46
|
+
if (checked) {
|
|
47
|
+
if (!newValue.includes(optionValue)) newValue.push(optionValue);
|
|
48
|
+
} else {
|
|
49
|
+
const index = newValue.indexOf(optionValue);
|
|
50
|
+
if (index > -1) newValue.splice(index, 1);
|
|
51
|
+
}
|
|
52
|
+
onChange(newValue, name);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ---------------- JSX ----------------
|
|
57
|
+
if (multiple) {
|
|
58
|
+
// Multiple options
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<div className={`${parentClass}`}>
|
|
62
|
+
<InputLabel label={label} required={required} />
|
|
63
|
+
<div className={`flex flex-col space-y-2`}>
|
|
64
|
+
{options.map((opt, idx) => (
|
|
65
|
+
<div key={opt.value || opt.label} className="flex items-center">
|
|
66
|
+
<input
|
|
67
|
+
type="checkbox"
|
|
68
|
+
name={name}
|
|
69
|
+
value={opt.value}
|
|
70
|
+
checked={isChecked(opt.value)}
|
|
71
|
+
disabled={disabled || opt.disabled}
|
|
72
|
+
required={required && idx === 0}
|
|
73
|
+
onChange={(e) =>
|
|
74
|
+
handleMultipleChange(opt.value, e.target.checked)
|
|
75
|
+
}
|
|
76
|
+
key={name}
|
|
77
|
+
className={`h-4 w-4 cursor-pointer text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 ${className}`}
|
|
78
|
+
/>
|
|
79
|
+
{opt.label && (
|
|
80
|
+
<label
|
|
81
|
+
htmlFor={name}
|
|
82
|
+
className="ml-2 text-sm text-gray-700 dark:text-gray-200 select-none"
|
|
83
|
+
>
|
|
84
|
+
{opt.label}
|
|
85
|
+
</label>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Single checkbox
|
|
96
|
+
return (
|
|
97
|
+
<div className={`flex items-center ${parentClass}`}>
|
|
98
|
+
<input
|
|
99
|
+
type="checkbox"
|
|
100
|
+
name={name}
|
|
101
|
+
checked={isChecked()}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
required={required}
|
|
104
|
+
onChange={handleSingleChange}
|
|
105
|
+
className={`h-4 w-4 text-blue-600 cursor-pointer border-gray-300 rounded focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 ${className}`}
|
|
106
|
+
/>
|
|
107
|
+
{label && (
|
|
108
|
+
<label
|
|
109
|
+
htmlFor={name}
|
|
110
|
+
className="ml-2 text-sm text-gray-700 dark:text-gray-200 select-none"
|
|
111
|
+
>
|
|
112
|
+
{label}
|
|
113
|
+
</label>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export default Checkbox;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Icon } from "@iconify/react";
|
|
2
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
3
|
+
import InputLabel from "./InputLabel";
|
|
4
|
+
|
|
5
|
+
const ImagePicker = ({
|
|
6
|
+
label = "",
|
|
7
|
+
value = null,
|
|
8
|
+
onChange,
|
|
9
|
+
required = false,
|
|
10
|
+
accept = "image/*",
|
|
11
|
+
id,
|
|
12
|
+
dragDrop = false,
|
|
13
|
+
name = "",
|
|
14
|
+
parentClass = "",
|
|
15
|
+
}) => {
|
|
16
|
+
const [image, setImage] = useState(value);
|
|
17
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
18
|
+
const inputRef = useRef(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setImage(value);
|
|
22
|
+
}, [value]);
|
|
23
|
+
|
|
24
|
+
const handleFileChange = (files) => {
|
|
25
|
+
if (!files || files.length === 0) {
|
|
26
|
+
setImage(null);
|
|
27
|
+
onChange?.(null);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const file = files[0];
|
|
32
|
+
const preview = URL.createObjectURL(file);
|
|
33
|
+
const imgObj = { file, preview };
|
|
34
|
+
|
|
35
|
+
setImage(imgObj);
|
|
36
|
+
onChange?.(preview);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Drag events
|
|
40
|
+
const handleDragOver = (e) => {
|
|
41
|
+
if (!dragDrop) return;
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
setIsDragging(true);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const handleDragLeave = (e) => {
|
|
47
|
+
if (!dragDrop) return;
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
setIsDragging(false);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleDrop = (e) => {
|
|
53
|
+
if (!dragDrop) return;
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
setIsDragging(false);
|
|
56
|
+
handleFileChange(e.dataTransfer.files);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<div key={name} className={parentClass || "col-span-12"}>
|
|
62
|
+
<InputLabel label={label} required={required} />
|
|
63
|
+
<div
|
|
64
|
+
className={`relative rounded-md p-2 transition-all ${
|
|
65
|
+
isDragging
|
|
66
|
+
? "border border-dashed border-blue-500 bg-blue-50 dark:bg-blue-900"
|
|
67
|
+
: "border bg-gray-50 dark:bg-gray-700"
|
|
68
|
+
}`}
|
|
69
|
+
onDragOver={handleDragOver}
|
|
70
|
+
onDragLeave={handleDragLeave}
|
|
71
|
+
onDrop={handleDrop}
|
|
72
|
+
>
|
|
73
|
+
{/* Blur entire content when dragging */}
|
|
74
|
+
<div
|
|
75
|
+
className={`flex items-center space-x-3 transition-all ${
|
|
76
|
+
isDragging ? "filter blur-sm" : ""
|
|
77
|
+
}`}
|
|
78
|
+
>
|
|
79
|
+
{/* Left: preview clickable */}
|
|
80
|
+
<div
|
|
81
|
+
className="cursor-pointer"
|
|
82
|
+
onClick={() => inputRef.current.click()}
|
|
83
|
+
>
|
|
84
|
+
{image ? (
|
|
85
|
+
<img
|
|
86
|
+
src={image.preview || image}
|
|
87
|
+
alt="preview"
|
|
88
|
+
className="object-cover w-20 h-20 rounded-full"
|
|
89
|
+
/>
|
|
90
|
+
) : (
|
|
91
|
+
<>
|
|
92
|
+
<div className="rounded-full bg-gray-200 dark:bg-gray-800 h-20 w-20 flex items-center justify-center">
|
|
93
|
+
<Icon
|
|
94
|
+
icon="ri:image-add-fill"
|
|
95
|
+
className="text-gray-400 w-10 h-10"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Right: upload input */}
|
|
103
|
+
<input
|
|
104
|
+
ref={inputRef}
|
|
105
|
+
id={id}
|
|
106
|
+
type="file"
|
|
107
|
+
accept={accept}
|
|
108
|
+
onChange={(e) => handleFileChange(e.target.files)}
|
|
109
|
+
required={required && !image}
|
|
110
|
+
className="inline-flex items-center justify-center p-2 text-gray-400 text-sm file:cursor-pointer"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Overlay text */}
|
|
115
|
+
{dragDrop && isDragging && (
|
|
116
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
117
|
+
<span className="text-blue-500 font-semibold text-xl">
|
|
118
|
+
Drop here
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export default ImagePicker;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Icon } from "@iconify/react";
|
|
3
|
+
import InputLabel from "./InputLabel";
|
|
4
|
+
|
|
5
|
+
const Input = React.forwardRef(
|
|
6
|
+
(
|
|
7
|
+
{
|
|
8
|
+
label,
|
|
9
|
+
required,
|
|
10
|
+
parentClass = "",
|
|
11
|
+
className = "",
|
|
12
|
+
type = "text",
|
|
13
|
+
onKeyDown,
|
|
14
|
+
...props
|
|
15
|
+
},
|
|
16
|
+
ref,
|
|
17
|
+
) => {
|
|
18
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
19
|
+
|
|
20
|
+
const handleKeyDown = (e) => {
|
|
21
|
+
if (type === "number" && ["e", "E"].includes(e.key)) {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
}
|
|
24
|
+
onKeyDown?.(e);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const combinedClassName = `
|
|
28
|
+
h-10 placeholder-gray-400 dark:placeholder-gray-400
|
|
29
|
+
${type === "password" ? "pr-10" : ""}
|
|
30
|
+
${className}
|
|
31
|
+
`.trim();
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<div key={props.name} className={parentClass || "col-span-12"}>
|
|
36
|
+
<InputLabel label={label} required={required} />
|
|
37
|
+
<div className="relative">
|
|
38
|
+
<input
|
|
39
|
+
type={type === "password" && showPassword ? "text" : type}
|
|
40
|
+
ref={ref}
|
|
41
|
+
required={required}
|
|
42
|
+
onKeyDown={handleKeyDown}
|
|
43
|
+
className={combinedClassName}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
|
|
47
|
+
{type === "password" && (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
tabIndex={-1}
|
|
51
|
+
onClick={() => setShowPassword((prev) => !prev)}
|
|
52
|
+
className="absolute inset-y-0 right-3 flex items-center text-gray-400 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200"
|
|
53
|
+
>
|
|
54
|
+
<Icon
|
|
55
|
+
icon={
|
|
56
|
+
showPassword ? "mdi:eye-off-outline" : "mdi:eye-outline"
|
|
57
|
+
}
|
|
58
|
+
className="w-5 h-5"
|
|
59
|
+
/>
|
|
60
|
+
</button>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
Input.displayName = "Input";
|
|
70
|
+
|
|
71
|
+
export { Input };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export default function InputLabel({ label, required = false }) {
|
|
4
|
+
return (
|
|
5
|
+
<>
|
|
6
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
7
|
+
{label}
|
|
8
|
+
{required && <span className="ml-1">*</span>}
|
|
9
|
+
</label>
|
|
10
|
+
</>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { countries } from "../../../data/countries";
|
|
3
|
+
import { ChevronDown, Search } from "lucide-react";
|
|
4
|
+
import InputLabel from "./InputLabel";
|
|
5
|
+
|
|
6
|
+
export default function PhoneInput({
|
|
7
|
+
label = "",
|
|
8
|
+
value = "",
|
|
9
|
+
name = "",
|
|
10
|
+
parentClass = "",
|
|
11
|
+
onChange,
|
|
12
|
+
disabled = false,
|
|
13
|
+
required = false,
|
|
14
|
+
placeholder = "Phone number",
|
|
15
|
+
search = false,
|
|
16
|
+
countriesList = false,
|
|
17
|
+
defaultCountry = "",
|
|
18
|
+
}) {
|
|
19
|
+
const find_country_with_code = (value) => {
|
|
20
|
+
return countries.find((obj) => obj.code == value);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const [selectedCountry, setSelectedCountry] = useState(
|
|
24
|
+
find_country_with_code(defaultCountry) || countries[0],
|
|
25
|
+
);
|
|
26
|
+
const [fullNumber, setFullNumber] = useState("");
|
|
27
|
+
const [open, setOpen] = useState(false);
|
|
28
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
29
|
+
const dropdownRef = useRef();
|
|
30
|
+
|
|
31
|
+
// Match prefix when value starts with +code
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (typeof value === "string" && value.startsWith("+")) {
|
|
34
|
+
const match = countries
|
|
35
|
+
.filter((c) => value.startsWith("+" + c.phone))
|
|
36
|
+
.sort((a, b) => b.phone.length - a.phone.length)[0];
|
|
37
|
+
if (match) {
|
|
38
|
+
setSelectedCountry(match);
|
|
39
|
+
setFullNumber(value.replace("+" + match.phone, ""));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
setFullNumber(value);
|
|
44
|
+
}, [value]);
|
|
45
|
+
|
|
46
|
+
const handleNumberChange = (e) => {
|
|
47
|
+
const input = e.target.value.replace(/\D/g, "");
|
|
48
|
+
setFullNumber(input);
|
|
49
|
+
if (selectedCountry && onChange) {
|
|
50
|
+
onChange("+" + selectedCountry.phone + input);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleCountrySelect = (country) => {
|
|
55
|
+
setSelectedCountry(country);
|
|
56
|
+
if (onChange) onChange("+" + country.phone + fullNumber);
|
|
57
|
+
setOpen(false);
|
|
58
|
+
setSearchTerm("");
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// 🖱️ Close dropdown on outside click
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const handleClickOutside = (e) => {
|
|
64
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target))
|
|
65
|
+
setOpen(false);
|
|
66
|
+
};
|
|
67
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
68
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const filteredCountries = countries.filter(
|
|
72
|
+
(c) =>
|
|
73
|
+
c.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
74
|
+
c.phone.includes(searchTerm),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (!countriesList) {
|
|
78
|
+
const handleInputChange = (e) => {
|
|
79
|
+
const input = e.target.value.replace(/[^+\d]/g, "");
|
|
80
|
+
const formatted = input.startsWith("+")
|
|
81
|
+
? "+" + input.replace(/[+]/g, "").slice(0)
|
|
82
|
+
: input;
|
|
83
|
+
onChange(formatted);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<div key={name} className={parentClass || "col-span-12"}>
|
|
89
|
+
<InputLabel label={label} required={required} />
|
|
90
|
+
<input
|
|
91
|
+
type="text"
|
|
92
|
+
value={value}
|
|
93
|
+
onChange={handleInputChange}
|
|
94
|
+
placeholder={placeholder}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
required={required}
|
|
97
|
+
className="w-full h-10 px-3 text-sm border border-gray-300 dark:border-gray-600 rounded-md
|
|
98
|
+
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none
|
|
99
|
+
focus:ring-1 focus:ring-blue-300 dark:focus:ring-blue-200"
|
|
100
|
+
inputMode="tel"
|
|
101
|
+
pattern="^\+\d{1,15}$"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<div key={name} className={parentClass || "col-span-12"}>
|
|
111
|
+
<InputLabel label={label} required={required} />
|
|
112
|
+
<div className="relative " ref={dropdownRef}>
|
|
113
|
+
{/* Input container */}
|
|
114
|
+
<div
|
|
115
|
+
className={`h-[40px] flex items-center border rounded-md px-2 bg-white dark:bg-gray-700 transition-all
|
|
116
|
+
${
|
|
117
|
+
open
|
|
118
|
+
? "ring-0.5 ring-blue-100 border-blue-300"
|
|
119
|
+
: "border-gray-300 dark:border-gray-600"
|
|
120
|
+
}
|
|
121
|
+
${disabled ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
122
|
+
>
|
|
123
|
+
{/* Flag & dropdown */}
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
disabled={disabled}
|
|
127
|
+
onClick={() => setOpen(!open)}
|
|
128
|
+
className="flex items-center gap-1 pr-2 border-r border-gray-300 dark:border-gray-700 focus:outline-none"
|
|
129
|
+
>
|
|
130
|
+
{selectedCountry ? (
|
|
131
|
+
<img
|
|
132
|
+
src={`https://flagcdn.com/w20/${selectedCountry.code.toLowerCase()}.png`}
|
|
133
|
+
alt={selectedCountry.code}
|
|
134
|
+
className="w-5 h-3 object-cover"
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<span className="text-gray-400 text-xs">🌐</span>
|
|
138
|
+
)}
|
|
139
|
+
<ChevronDown className="w-3 h-3 text-gray-500" />
|
|
140
|
+
</button>
|
|
141
|
+
|
|
142
|
+
{/* Country code */}
|
|
143
|
+
{selectedCountry && (
|
|
144
|
+
<span className="ml-2 text-sm text-gray-700 dark:text-gray-200 whitespace-nowrap">
|
|
145
|
+
+{selectedCountry.phone}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* Number input */}
|
|
150
|
+
<input
|
|
151
|
+
type="tel"
|
|
152
|
+
value={fullNumber}
|
|
153
|
+
onChange={handleNumberChange}
|
|
154
|
+
required={required}
|
|
155
|
+
disabled={disabled || !selectedCountry}
|
|
156
|
+
placeholder={!selectedCountry ? "Select a country" : placeholder}
|
|
157
|
+
className="flex-1 ml-2 bg-transparent outline-none text-sm text-gray-800 dark:text-gray-100 placeholder-gray-400"
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
{/* Hidden input for native browser validation */}
|
|
161
|
+
<input
|
|
162
|
+
type="tel"
|
|
163
|
+
required={required}
|
|
164
|
+
tabIndex={-1}
|
|
165
|
+
readOnly
|
|
166
|
+
value={
|
|
167
|
+
selectedCountry && fullNumber
|
|
168
|
+
? "+" + selectedCountry.phone + fullNumber
|
|
169
|
+
: ""
|
|
170
|
+
}
|
|
171
|
+
style={{
|
|
172
|
+
position: "absolute",
|
|
173
|
+
opacity: 0,
|
|
174
|
+
pointerEvents: "none",
|
|
175
|
+
height: 0,
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Dropdown */}
|
|
181
|
+
{open && (
|
|
182
|
+
<div className="absolute top-full left-0 w-full mt-1 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-700 shadow-lg z-50 max-h-60 overflow-y-auto">
|
|
183
|
+
{search && (
|
|
184
|
+
<div className="p-2 border-b border-gray-200 dark:border-gray-700">
|
|
185
|
+
<div className="relative">
|
|
186
|
+
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
187
|
+
<input
|
|
188
|
+
type="text"
|
|
189
|
+
value={searchTerm}
|
|
190
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
191
|
+
placeholder="Search country..."
|
|
192
|
+
className="w-full pl-9 pr-3 py-2 text-sm border rounded-md bg-white dark:bg-gray-800 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none "
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{filteredCountries.map((c) => (
|
|
199
|
+
<button
|
|
200
|
+
key={c.code}
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={() => handleCountrySelect(c)}
|
|
203
|
+
className="w-full flex items-center gap-2 px-2 py-1 text-sm hover:bg-blue-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100"
|
|
204
|
+
>
|
|
205
|
+
<img
|
|
206
|
+
src={`https://flagcdn.com/w20/${c.code.toLowerCase()}.png`}
|
|
207
|
+
alt={c.code}
|
|
208
|
+
className="w-5 h-3 object-cover"
|
|
209
|
+
/>
|
|
210
|
+
<span>
|
|
211
|
+
{c.label} (+{c.phone})
|
|
212
|
+
</span>
|
|
213
|
+
</button>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|