@ews-admin/global-design-system 1.0.0 → 1.1.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/README.md +50 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Input/Input.d.ts +4 -0
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Logo/Logo.d.ts +29 -0
- package/dist/components/Logo/Logo.d.ts.map +1 -0
- package/dist/components/Logo/index.d.ts +3 -0
- package/dist/components/Logo/index.d.ts.map +1 -0
- package/dist/components/Modal/Modal.d.ts +72 -0
- package/dist/components/Modal/Modal.d.ts.map +1 -0
- package/dist/components/Modal/index.d.ts +3 -0
- package/dist/components/Modal/index.d.ts.map +1 -0
- package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts +25 -0
- package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts.map +1 -0
- package/dist/components/MultiSearchAutocomplete/index.d.ts +2 -0
- package/dist/components/MultiSearchAutocomplete/index.d.ts.map +1 -0
- package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts +22 -0
- package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts.map +1 -0
- package/dist/components/SearchAutocomplete/index.d.ts +3 -0
- package/dist/components/SearchAutocomplete/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useDebounce.d.ts +15 -0
- package/dist/hooks/useDebounce.d.ts.map +1 -0
- package/dist/icons/Icon.d.ts +5 -4
- package/dist/icons/Icon.d.ts.map +1 -1
- package/dist/icons/index.d.ts +1 -3
- package/dist/icons/index.d.ts.map +1 -1
- package/dist/index.css +3 -1
- package/dist/index.d.ts +185 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +3 -1
- package/dist/index.esm.js +763 -29
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +768 -27
- package/dist/index.js.map +1 -1
- package/dist/molecules/SpecialtySearchAutocomplete/SpecialtySearchAutocomplete.d.ts +18 -0
- package/dist/molecules/SpecialtySearchAutocomplete/SpecialtySearchAutocomplete.d.ts.map +1 -0
- package/dist/molecules/SpecialtySearchAutocomplete/index.d.ts +3 -0
- package/dist/molecules/SpecialtySearchAutocomplete/index.d.ts.map +1 -0
- package/dist/molecules/index.d.ts +3 -0
- package/dist/molecules/index.d.ts.map +1 -0
- package/dist/utils/index.d.ts +5 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/package.json +17 -2
- package/src/assets/favicon.svg +6 -0
- package/src/assets/logo.svg +17 -0
- package/src/components/Button/Button.tsx +22 -8
- package/src/components/Input/Input.tsx +42 -16
- package/src/components/Logo/Logo.tsx +100 -0
- package/src/components/Logo/index.ts +2 -0
- package/src/components/Modal/Modal.tsx +257 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/MultiSearchAutocomplete/MultiSearchAutocomplete.tsx +319 -0
- package/src/components/MultiSearchAutocomplete/index.ts +1 -0
- package/src/components/SearchAutocomplete/SearchAutocomplete.tsx +315 -0
- package/src/components/SearchAutocomplete/index.ts +2 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebounce.ts +64 -0
- package/src/icons/Icon.tsx +15 -16
- package/src/icons/index.ts +39 -3
- package/src/index.ts +19 -0
- package/src/molecules/SpecialtySearchAutocomplete/SpecialtySearchAutocomplete.tsx +203 -0
- package/src/molecules/SpecialtySearchAutocomplete/index.ts +5 -0
- package/src/molecules/index.ts +5 -0
- package/src/styles/index.css +8 -5
- package/src/styles/tailwind.css +3 -0
- package/src/utils/index.ts +7 -2
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { Check, Search, X } from "lucide-react";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDebounce } from "../../hooks";
|
|
4
|
+
import { cn } from "../../utils";
|
|
5
|
+
import { Input } from "../Input/Input";
|
|
6
|
+
import { SearchableEntity } from "../SearchAutocomplete/SearchAutocomplete";
|
|
7
|
+
|
|
8
|
+
interface MultiSearchAutocompleteProps<T extends SearchableEntity> {
|
|
9
|
+
items: T[];
|
|
10
|
+
selectedItems: T[];
|
|
11
|
+
onSelectionChange: (items: T[]) => void;
|
|
12
|
+
onSearch: (searchTerm: string) => Promise<void>;
|
|
13
|
+
getEntityById?: (id: string) => Promise<T | undefined>;
|
|
14
|
+
getPrimaryText: (entity: T) => string;
|
|
15
|
+
getSecondaryText?: (entity: T) => string | null;
|
|
16
|
+
placeholder: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
loading?: boolean;
|
|
19
|
+
multiple?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
renderSelectedItem?: (entity: T) => React.ReactNode;
|
|
22
|
+
renderListItem?: (entity: T, isSelected: boolean) => React.ReactNode;
|
|
23
|
+
keepOpenOnSelect?: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
minSearchLength?: number;
|
|
26
|
+
debounceTime?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function MultiSearchAutocomplete<T extends SearchableEntity>({
|
|
30
|
+
items,
|
|
31
|
+
selectedItems,
|
|
32
|
+
onSelectionChange,
|
|
33
|
+
onSearch,
|
|
34
|
+
getEntityById,
|
|
35
|
+
getPrimaryText,
|
|
36
|
+
getSecondaryText,
|
|
37
|
+
placeholder,
|
|
38
|
+
disabled = false,
|
|
39
|
+
loading = false,
|
|
40
|
+
multiple = true,
|
|
41
|
+
className,
|
|
42
|
+
renderSelectedItem,
|
|
43
|
+
renderListItem,
|
|
44
|
+
keepOpenOnSelect = true,
|
|
45
|
+
error,
|
|
46
|
+
minSearchLength = 2,
|
|
47
|
+
debounceTime = 300,
|
|
48
|
+
}: MultiSearchAutocompleteProps<T>) {
|
|
49
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
50
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
51
|
+
const [filteredItems, setFilteredItems] = useState<T[]>(items);
|
|
52
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
53
|
+
const [searchError, setSearchError] = useState<string | null>(null);
|
|
54
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
const debouncedSearchTerm = useDebounce(searchTerm, debounceTime);
|
|
57
|
+
|
|
58
|
+
// Handle search term changes
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
// If search term is empty, show all items
|
|
61
|
+
if (!searchTerm.trim()) {
|
|
62
|
+
setFilteredItems(items);
|
|
63
|
+
setSearchError(null);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// If search term is too short, don't search
|
|
68
|
+
if (searchTerm.length < minSearchLength) {
|
|
69
|
+
setFilteredItems([]);
|
|
70
|
+
setSearchError(null);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}, [searchTerm, items, minSearchLength]);
|
|
74
|
+
|
|
75
|
+
// Debounced search with API calls
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
// If debounced search term is empty or too short, don't search
|
|
78
|
+
if (
|
|
79
|
+
!debouncedSearchTerm.trim() ||
|
|
80
|
+
debouncedSearchTerm.length < minSearchLength
|
|
81
|
+
) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const performSearch = async () => {
|
|
86
|
+
try {
|
|
87
|
+
setIsSearching(true);
|
|
88
|
+
setSearchError(null);
|
|
89
|
+
await onSearch(debouncedSearchTerm);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
setSearchError(err instanceof Error ? err.message : "Search failed");
|
|
92
|
+
} finally {
|
|
93
|
+
setIsSearching(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
performSearch();
|
|
98
|
+
}, [debouncedSearchTerm, onSearch, minSearchLength]);
|
|
99
|
+
|
|
100
|
+
// Update filtered items when items prop changes
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
setFilteredItems(items);
|
|
103
|
+
}, [items]);
|
|
104
|
+
|
|
105
|
+
const handleItemToggle = useCallback(
|
|
106
|
+
(item: T) => {
|
|
107
|
+
if (multiple) {
|
|
108
|
+
const isSelected = selectedItems.some(
|
|
109
|
+
(selected) => selected.id === item.id
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (isSelected) {
|
|
113
|
+
onSelectionChange(
|
|
114
|
+
selectedItems.filter((selected) => selected.id !== item.id)
|
|
115
|
+
);
|
|
116
|
+
} else {
|
|
117
|
+
onSelectionChange([...selectedItems, item]);
|
|
118
|
+
}
|
|
119
|
+
// Keep dropdown open for multiple selection
|
|
120
|
+
if (keepOpenOnSelect) {
|
|
121
|
+
setIsOpen(true);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
onSelectionChange([item]);
|
|
125
|
+
setIsOpen(false);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
[selectedItems, onSelectionChange, multiple, keepOpenOnSelect]
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const handleRemoveItem = useCallback(
|
|
132
|
+
(itemToRemove: T) => {
|
|
133
|
+
onSelectionChange(
|
|
134
|
+
selectedItems.filter((item) => item.id !== itemToRemove.id)
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
[selectedItems, onSelectionChange]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const handleInputFocus = () => {
|
|
141
|
+
if (!disabled) {
|
|
142
|
+
setIsOpen(true);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleInputBlur = () => {
|
|
147
|
+
// Don't close immediately to allow for clicks on dropdown items
|
|
148
|
+
// Only close if keepOpenOnSelect is false
|
|
149
|
+
if (!keepOpenOnSelect) {
|
|
150
|
+
setTimeout(() => setIsOpen(false), 150);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Close dropdown when clicking outside
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
157
|
+
if (
|
|
158
|
+
dropdownRef.current &&
|
|
159
|
+
!dropdownRef.current.contains(event.target as Node)
|
|
160
|
+
) {
|
|
161
|
+
setIsOpen(false);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleEscapeKey = (event: KeyboardEvent) => {
|
|
166
|
+
if (event.key === "Escape") {
|
|
167
|
+
setIsOpen(false);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (isOpen) {
|
|
172
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
173
|
+
document.addEventListener("keydown", handleEscapeKey);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return () => {
|
|
177
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
178
|
+
document.removeEventListener("keydown", handleEscapeKey);
|
|
179
|
+
};
|
|
180
|
+
}, [isOpen]);
|
|
181
|
+
|
|
182
|
+
// Default render functions
|
|
183
|
+
const defaultRenderSelectedItem = (entity: T) => (
|
|
184
|
+
<span className="inline-flex items-center px-3 py-1 text-sm font-medium rounded-full bg-ews-primary/10 text-ews-primary border border-ews-primary/20">
|
|
185
|
+
{getPrimaryText(entity)}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => handleRemoveItem(entity)}
|
|
189
|
+
className="ml-2 text-ews-primary/60 hover:text-ews-primary"
|
|
190
|
+
disabled={disabled}
|
|
191
|
+
title="Remove item"
|
|
192
|
+
>
|
|
193
|
+
<X className="w-3 h-3" />
|
|
194
|
+
</button>
|
|
195
|
+
</span>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const defaultRenderListItem = (entity: T, isSelected: boolean) => (
|
|
199
|
+
<div className="flex items-center space-x-3">
|
|
200
|
+
<div
|
|
201
|
+
className={cn(
|
|
202
|
+
"w-5 h-5 border-2 rounded flex items-center justify-center",
|
|
203
|
+
isSelected ? "bg-ews-primary border-ews-primary" : "border-gray-300"
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
{isSelected && <Check className="w-3 h-3 text-white" />}
|
|
207
|
+
</div>
|
|
208
|
+
<div className="flex flex-col">
|
|
209
|
+
<span
|
|
210
|
+
className={cn(
|
|
211
|
+
"font-medium",
|
|
212
|
+
isSelected ? "text-ews-primary" : "text-gray-900"
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
{getPrimaryText(entity)}
|
|
216
|
+
</span>
|
|
217
|
+
{getSecondaryText && (
|
|
218
|
+
<span
|
|
219
|
+
className={cn(
|
|
220
|
+
"text-sm",
|
|
221
|
+
isSelected ? "text-ews-primary/70" : "text-gray-500"
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{getSecondaryText(entity)}
|
|
225
|
+
</span>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div className={cn("relative", className)} ref={dropdownRef}>
|
|
233
|
+
{/* Selected items display */}
|
|
234
|
+
{selectedItems.length > 0 && (
|
|
235
|
+
<div className="flex flex-wrap gap-2 mb-3">
|
|
236
|
+
{selectedItems.map((item) => (
|
|
237
|
+
<div key={item.id}>
|
|
238
|
+
{renderSelectedItem
|
|
239
|
+
? renderSelectedItem(item)
|
|
240
|
+
: defaultRenderSelectedItem(item)}
|
|
241
|
+
</div>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{/* Search input */}
|
|
247
|
+
<Input
|
|
248
|
+
ref={inputRef}
|
|
249
|
+
type="text"
|
|
250
|
+
value={searchTerm}
|
|
251
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
252
|
+
onFocus={handleInputFocus}
|
|
253
|
+
onBlur={handleInputBlur}
|
|
254
|
+
placeholder={placeholder}
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
leftIcon={<Search className="w-4 h-4" />}
|
|
257
|
+
className="w-full"
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
{/* Dropdown */}
|
|
261
|
+
{isOpen && !disabled && (
|
|
262
|
+
<div className="absolute z-10 mt-1 w-full max-h-60 bg-white rounded-lg border border-gray-200 shadow-lg overflow-auto">
|
|
263
|
+
{loading || isSearching ? (
|
|
264
|
+
<div className="px-4 py-3 text-sm text-gray-500 flex items-center">
|
|
265
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-ews-primary mr-2"></div>
|
|
266
|
+
{isSearching ? "Searching..." : "Loading..."}
|
|
267
|
+
</div>
|
|
268
|
+
) : searchError ? (
|
|
269
|
+
<div className="px-4 py-3 text-sm text-red-600">
|
|
270
|
+
<div className="flex items-center">
|
|
271
|
+
<X className="w-4 h-4 mr-2" />
|
|
272
|
+
Error: {searchError}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
) : error ? (
|
|
276
|
+
<div className="px-4 py-3 text-sm text-red-600">
|
|
277
|
+
<div className="flex items-center">
|
|
278
|
+
<X className="w-4 h-4 mr-2" />
|
|
279
|
+
{error}
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
) : filteredItems.length === 0 ? (
|
|
283
|
+
<div className="px-4 py-3 text-sm text-gray-500">
|
|
284
|
+
{searchTerm.length < minSearchLength
|
|
285
|
+
? `Type at least ${minSearchLength} characters to search`
|
|
286
|
+
: "No items found"}
|
|
287
|
+
</div>
|
|
288
|
+
) : (
|
|
289
|
+
<div className="py-1">
|
|
290
|
+
{filteredItems.map((item) => {
|
|
291
|
+
const isSelected = selectedItems.some(
|
|
292
|
+
(selected) => selected.id === item.id
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<button
|
|
297
|
+
key={item.id}
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => handleItemToggle(item)}
|
|
300
|
+
className={cn(
|
|
301
|
+
"w-full px-4 py-3 text-left flex items-center transition-colors",
|
|
302
|
+
isSelected
|
|
303
|
+
? "bg-ews-primary/10 text-ews-primary border-l-4 border-ews-primary"
|
|
304
|
+
: "hover:bg-ews-primary/5 text-gray-900"
|
|
305
|
+
)}
|
|
306
|
+
>
|
|
307
|
+
{renderListItem
|
|
308
|
+
? renderListItem(item, isSelected)
|
|
309
|
+
: defaultRenderListItem(item, isSelected)}
|
|
310
|
+
</button>
|
|
311
|
+
);
|
|
312
|
+
})}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MultiSearchAutocomplete } from "./MultiSearchAutocomplete";
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { LucideIcon, Search, X } from "lucide-react";
|
|
2
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useDebounce } from "../../hooks";
|
|
4
|
+
import { Input } from "../Input/Input";
|
|
5
|
+
|
|
6
|
+
// Generic interface for searchable entities
|
|
7
|
+
export interface SearchableEntity {
|
|
8
|
+
id: string;
|
|
9
|
+
[key: string]: string | number | boolean | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SearchAutocompleteProps<T extends SearchableEntity> {
|
|
13
|
+
onSelect: (id: string) => void;
|
|
14
|
+
selectedId: string;
|
|
15
|
+
searchFunction: (keyword: string) => Promise<T[]>;
|
|
16
|
+
getEntityById?: (id: string) => Promise<T | undefined>;
|
|
17
|
+
getDisplayValue: (entity: T) => string;
|
|
18
|
+
getSecondaryText?: (entity: T) => string | null;
|
|
19
|
+
placeholder: string;
|
|
20
|
+
icon?: LucideIcon;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
minSearchLength?: number;
|
|
23
|
+
debounceTime?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SearchAutocomplete<T extends SearchableEntity>({
|
|
28
|
+
onSelect,
|
|
29
|
+
selectedId,
|
|
30
|
+
searchFunction,
|
|
31
|
+
getEntityById,
|
|
32
|
+
getDisplayValue,
|
|
33
|
+
getSecondaryText,
|
|
34
|
+
placeholder,
|
|
35
|
+
icon = Search,
|
|
36
|
+
disabled = false,
|
|
37
|
+
minSearchLength = 2,
|
|
38
|
+
debounceTime = 300,
|
|
39
|
+
error,
|
|
40
|
+
}: SearchAutocompleteProps<T>) {
|
|
41
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
42
|
+
const [entities, setEntities] = useState<T[]>([]);
|
|
43
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
44
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
45
|
+
const [selectedEntity, setSelectedEntity] = useState<T | null>(null);
|
|
46
|
+
const [initialLoadDone, setInitialLoadDone] = useState(false);
|
|
47
|
+
const [searchError, setSearchError] = useState<string | null>(null);
|
|
48
|
+
|
|
49
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
50
|
+
const debouncedSearchTerm = useDebounce(searchTerm, debounceTime);
|
|
51
|
+
|
|
52
|
+
// Track if this is an initial load or a user-initiated search
|
|
53
|
+
const isUserSearch = useRef(false);
|
|
54
|
+
|
|
55
|
+
// Only fetch the selected entity by ID once on initial load
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const fetchSelectedEntity = async () => {
|
|
58
|
+
// Only fetch if we have a selectedId, getEntityById function, and haven't loaded yet
|
|
59
|
+
if (!selectedId || !getEntityById || initialLoadDone) return;
|
|
60
|
+
|
|
61
|
+
setIsLoading(true);
|
|
62
|
+
try {
|
|
63
|
+
const entity = await getEntityById(selectedId);
|
|
64
|
+
if (entity) {
|
|
65
|
+
setSelectedEntity(entity);
|
|
66
|
+
setSearchTerm(getDisplayValue(entity));
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error("Error fetching selected entity:", error);
|
|
70
|
+
} finally {
|
|
71
|
+
setIsLoading(false);
|
|
72
|
+
setInitialLoadDone(true);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
fetchSelectedEntity();
|
|
77
|
+
}, [selectedId, getEntityById, getDisplayValue, initialLoadDone]);
|
|
78
|
+
|
|
79
|
+
// Handle search term changes - only for user-initiated searches
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
// Skip if this is just setting the initial value
|
|
82
|
+
if (!isUserSearch.current) return;
|
|
83
|
+
|
|
84
|
+
// Clear entities if search term is too short
|
|
85
|
+
if (searchTerm.trim().length < minSearchLength) {
|
|
86
|
+
setEntities([]);
|
|
87
|
+
setSearchError(null);
|
|
88
|
+
}
|
|
89
|
+
}, [searchTerm, minSearchLength]);
|
|
90
|
+
|
|
91
|
+
// Fetch entities when debounced search term changes - only for user searches
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const fetchEntities = async () => {
|
|
94
|
+
if (
|
|
95
|
+
!debouncedSearchTerm ||
|
|
96
|
+
debouncedSearchTerm.length < minSearchLength
|
|
97
|
+
) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setIsLoading(true);
|
|
102
|
+
setSearchError(null);
|
|
103
|
+
try {
|
|
104
|
+
const results = await searchFunction(debouncedSearchTerm);
|
|
105
|
+
setEntities(results);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error("Error fetching search results:", error);
|
|
108
|
+
setSearchError(
|
|
109
|
+
error instanceof Error ? error.message : "Search failed"
|
|
110
|
+
);
|
|
111
|
+
} finally {
|
|
112
|
+
setIsLoading(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Only fetch if this is a user-initiated search
|
|
117
|
+
if (isUserSearch.current) {
|
|
118
|
+
fetchEntities();
|
|
119
|
+
}
|
|
120
|
+
}, [debouncedSearchTerm, searchFunction, minSearchLength]);
|
|
121
|
+
|
|
122
|
+
// Handle click outside to close dropdown
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
125
|
+
if (
|
|
126
|
+
dropdownRef.current &&
|
|
127
|
+
!dropdownRef.current.contains(event.target as Node)
|
|
128
|
+
) {
|
|
129
|
+
setShowDropdown(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
134
|
+
return () => {
|
|
135
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
136
|
+
};
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
140
|
+
const value = e.target.value;
|
|
141
|
+
|
|
142
|
+
// Mark this as a user-initiated search
|
|
143
|
+
isUserSearch.current = true;
|
|
144
|
+
|
|
145
|
+
setSearchTerm(value);
|
|
146
|
+
|
|
147
|
+
if (selectedEntity) {
|
|
148
|
+
setSelectedEntity(null);
|
|
149
|
+
onSelect("");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setShowDropdown(value.trim().length >= minSearchLength);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const handleEntitySelect = (entity: T) => {
|
|
156
|
+
// This is a user selection, not an API fetch
|
|
157
|
+
isUserSearch.current = false;
|
|
158
|
+
|
|
159
|
+
console.log(`Selected entity with ID: ${entity.id}`);
|
|
160
|
+
|
|
161
|
+
setSelectedEntity(entity);
|
|
162
|
+
setSearchTerm(getDisplayValue(entity));
|
|
163
|
+
onSelect(entity.id);
|
|
164
|
+
setShowDropdown(false);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleClearSelection = () => {
|
|
168
|
+
// This is a user action, but not a search
|
|
169
|
+
isUserSearch.current = false;
|
|
170
|
+
|
|
171
|
+
setSelectedEntity(null);
|
|
172
|
+
setSearchTerm("");
|
|
173
|
+
onSelect("");
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="relative" ref={dropdownRef}>
|
|
178
|
+
<div className="relative">
|
|
179
|
+
<Input
|
|
180
|
+
type="text"
|
|
181
|
+
placeholder={`${placeholder} (min. ${minSearchLength} characters)`}
|
|
182
|
+
value={selectedEntity ? "" : searchTerm}
|
|
183
|
+
onChange={handleSearchChange}
|
|
184
|
+
onFocus={() =>
|
|
185
|
+
searchTerm.length >= minSearchLength &&
|
|
186
|
+
isUserSearch.current &&
|
|
187
|
+
setShowDropdown(true)
|
|
188
|
+
}
|
|
189
|
+
disabled={disabled}
|
|
190
|
+
autoComplete="off"
|
|
191
|
+
name="search-autocomplete"
|
|
192
|
+
leftIcon={React.createElement(icon, { size: 16 })}
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
{selectedEntity && (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
className="flex absolute inset-y-0 right-0 items-center pr-3"
|
|
199
|
+
onClick={handleClearSelection}
|
|
200
|
+
disabled={disabled}
|
|
201
|
+
>
|
|
202
|
+
<X className="w-5 h-5 text-gray-400 hover:text-gray-500" />
|
|
203
|
+
</button>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Selected Entity Display */}
|
|
208
|
+
{selectedEntity && (
|
|
209
|
+
<div className="p-4 mt-3 rounded-lg border bg-neutral-50 border-neutral-200">
|
|
210
|
+
<div className="flex justify-between items-center">
|
|
211
|
+
<div className="flex items-center">
|
|
212
|
+
{(selectedEntity as { photoUrl?: string }).photoUrl ? (
|
|
213
|
+
<img
|
|
214
|
+
src={(selectedEntity as { photoUrl?: string }).photoUrl!}
|
|
215
|
+
alt={getDisplayValue(selectedEntity)}
|
|
216
|
+
className="object-cover mr-3 w-10 h-10 rounded-full"
|
|
217
|
+
/>
|
|
218
|
+
) : (
|
|
219
|
+
React.createElement(icon, {
|
|
220
|
+
className: "w-10 h-10 text-neutral-400 mr-3",
|
|
221
|
+
})
|
|
222
|
+
)}
|
|
223
|
+
<div>
|
|
224
|
+
<div className="font-medium text-neutral-900">
|
|
225
|
+
{getDisplayValue(selectedEntity)}
|
|
226
|
+
</div>
|
|
227
|
+
{getSecondaryText && getSecondaryText(selectedEntity) && (
|
|
228
|
+
<div className="text-sm text-neutral-500">
|
|
229
|
+
{getSecondaryText(selectedEntity)}
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={handleClearSelection}
|
|
237
|
+
className="transition-colors text-neutral-400 hover:text-neutral-600"
|
|
238
|
+
disabled={disabled}
|
|
239
|
+
>
|
|
240
|
+
<X className="w-4 h-4" />
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{showDropdown && entities?.length > 0 && (
|
|
247
|
+
<div className="overflow-auto absolute z-10 mt-1 w-full max-h-60 bg-white rounded-md shadow-lg">
|
|
248
|
+
<ul className="py-1">
|
|
249
|
+
{entities.map((entity) => (
|
|
250
|
+
<li
|
|
251
|
+
key={entity.id}
|
|
252
|
+
className="px-4 py-2 cursor-pointer hover:bg-gray-100"
|
|
253
|
+
onClick={() => handleEntitySelect(entity)}
|
|
254
|
+
>
|
|
255
|
+
<div className="flex items-center">
|
|
256
|
+
{React.createElement(icon, {
|
|
257
|
+
className: "w-4 h-4 text-gray-400",
|
|
258
|
+
})}
|
|
259
|
+
<div className="ml-2">
|
|
260
|
+
<div className="font-medium">{getDisplayValue(entity)}</div>
|
|
261
|
+
{getSecondaryText && getSecondaryText(entity) && (
|
|
262
|
+
<div className="text-xs text-gray-500">
|
|
263
|
+
{getSecondaryText(entity)}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</li>
|
|
269
|
+
))}
|
|
270
|
+
</ul>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
{showDropdown && searchError && (
|
|
275
|
+
<div className="absolute z-10 p-4 mt-1 w-full text-center text-red-600 bg-white rounded-md shadow-lg border border-red-200">
|
|
276
|
+
<div className="flex items-center justify-center">
|
|
277
|
+
<X className="w-4 h-4 mr-2" />
|
|
278
|
+
Error: {searchError}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
{showDropdown && error && !searchError && (
|
|
284
|
+
<div className="absolute z-10 p-4 mt-1 w-full text-center text-red-600 bg-white rounded-md shadow-lg border border-red-200">
|
|
285
|
+
<div className="flex items-center justify-center">
|
|
286
|
+
<X className="w-4 h-4 mr-2" />
|
|
287
|
+
{error}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{showDropdown &&
|
|
293
|
+
debouncedSearchTerm &&
|
|
294
|
+
entities?.length === 0 &&
|
|
295
|
+
!isLoading &&
|
|
296
|
+
!searchError &&
|
|
297
|
+
!error && (
|
|
298
|
+
<div className="absolute z-10 p-4 mt-1 w-full text-center text-gray-500 bg-white rounded-md shadow-lg">
|
|
299
|
+
{debouncedSearchTerm.length < minSearchLength
|
|
300
|
+
? `Type at least ${minSearchLength} characters to search`
|
|
301
|
+
: "No results found. Try a different search term."}
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
{isLoading && showDropdown && (
|
|
306
|
+
<div className="absolute z-10 p-4 mt-1 w-full text-center text-gray-500 bg-white rounded-md shadow-lg">
|
|
307
|
+
<div className="flex items-center justify-center">
|
|
308
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-ews-primary mr-2"></div>
|
|
309
|
+
Loading...
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useDebounce, useDebouncedCallback } from "./useDebounce";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DELAY = 500;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A custom hook that debounces a value
|
|
7
|
+
* @param value - The value to debounce
|
|
8
|
+
* @param delay - The delay in milliseconds (optional, defaults to 300ms)
|
|
9
|
+
* @returns The debounced value
|
|
10
|
+
*/
|
|
11
|
+
export function useDebounce<T>(value: T, delay?: number): T {
|
|
12
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
13
|
+
const actualDelay = delay ?? DEFAULT_DELAY;
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const handler = setTimeout(() => {
|
|
17
|
+
setDebouncedValue(value);
|
|
18
|
+
}, actualDelay);
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
clearTimeout(handler);
|
|
22
|
+
};
|
|
23
|
+
}, [value, actualDelay]);
|
|
24
|
+
|
|
25
|
+
return debouncedValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A custom hook that provides a debounced callback function
|
|
30
|
+
* @param callback - The function to debounce
|
|
31
|
+
* @param delay - The delay in milliseconds (optional, defaults to 300ms)
|
|
32
|
+
* @returns A debounced version of the callback
|
|
33
|
+
*/
|
|
34
|
+
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
|
35
|
+
callback: T,
|
|
36
|
+
delay?: number
|
|
37
|
+
): T {
|
|
38
|
+
const [debounceTimer, setDebounceTimer] = useState<ReturnType<
|
|
39
|
+
typeof setTimeout
|
|
40
|
+
> | null>(null);
|
|
41
|
+
|
|
42
|
+
const debouncedCallback = ((...args: Parameters<T>) => {
|
|
43
|
+
if (debounceTimer) {
|
|
44
|
+
clearTimeout(debounceTimer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const actualDelay = delay ?? DEFAULT_DELAY;
|
|
48
|
+
const newTimer = setTimeout(() => {
|
|
49
|
+
callback(...args);
|
|
50
|
+
}, actualDelay);
|
|
51
|
+
|
|
52
|
+
setDebounceTimer(newTimer);
|
|
53
|
+
}) as T;
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
return () => {
|
|
57
|
+
if (debounceTimer) {
|
|
58
|
+
clearTimeout(debounceTimer);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}, [debounceTimer]);
|
|
62
|
+
|
|
63
|
+
return debouncedCallback;
|
|
64
|
+
}
|