@ews-admin/global-design-system 1.0.0 → 1.1.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.
Files changed (68) hide show
  1. package/README.md +50 -1
  2. package/dist/components/Button/Button.d.ts.map +1 -1
  3. package/dist/components/Input/Input.d.ts +4 -0
  4. package/dist/components/Input/Input.d.ts.map +1 -1
  5. package/dist/components/Logo/Logo.d.ts +29 -0
  6. package/dist/components/Logo/Logo.d.ts.map +1 -0
  7. package/dist/components/Logo/index.d.ts +3 -0
  8. package/dist/components/Logo/index.d.ts.map +1 -0
  9. package/dist/components/Modal/Modal.d.ts +72 -0
  10. package/dist/components/Modal/Modal.d.ts.map +1 -0
  11. package/dist/components/Modal/index.d.ts +3 -0
  12. package/dist/components/Modal/index.d.ts.map +1 -0
  13. package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts +25 -0
  14. package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts.map +1 -0
  15. package/dist/components/MultiSearchAutocomplete/index.d.ts +2 -0
  16. package/dist/components/MultiSearchAutocomplete/index.d.ts.map +1 -0
  17. package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts +22 -0
  18. package/dist/components/SearchAutocomplete/SearchAutocomplete.d.ts.map +1 -0
  19. package/dist/components/SearchAutocomplete/index.d.ts +3 -0
  20. package/dist/components/SearchAutocomplete/index.d.ts.map +1 -0
  21. package/dist/hooks/index.d.ts +2 -0
  22. package/dist/hooks/index.d.ts.map +1 -0
  23. package/dist/hooks/useDebounce.d.ts +15 -0
  24. package/dist/hooks/useDebounce.d.ts.map +1 -0
  25. package/dist/icons/Icon.d.ts +5 -4
  26. package/dist/icons/Icon.d.ts.map +1 -1
  27. package/dist/icons/index.d.ts +1 -3
  28. package/dist/icons/index.d.ts.map +1 -1
  29. package/dist/index.css +3 -1
  30. package/dist/index.d.ts +185 -12
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.esm.css +3 -1
  33. package/dist/index.esm.js +763 -29
  34. package/dist/index.esm.js.map +1 -1
  35. package/dist/index.js +768 -27
  36. package/dist/index.js.map +1 -1
  37. package/dist/molecules/SpecialtySearchAutocomplete/SpecialtySearchAutocomplete.d.ts +18 -0
  38. package/dist/molecules/SpecialtySearchAutocomplete/SpecialtySearchAutocomplete.d.ts.map +1 -0
  39. package/dist/molecules/SpecialtySearchAutocomplete/index.d.ts +3 -0
  40. package/dist/molecules/SpecialtySearchAutocomplete/index.d.ts.map +1 -0
  41. package/dist/molecules/index.d.ts +3 -0
  42. package/dist/molecules/index.d.ts.map +1 -0
  43. package/dist/utils/index.d.ts +5 -1
  44. package/dist/utils/index.d.ts.map +1 -1
  45. package/package.json +17 -2
  46. package/src/assets/favicon.svg +6 -0
  47. package/src/assets/logo.svg +17 -0
  48. package/src/components/Button/Button.tsx +22 -8
  49. package/src/components/Input/Input.tsx +42 -16
  50. package/src/components/Logo/Logo.tsx +100 -0
  51. package/src/components/Logo/index.ts +2 -0
  52. package/src/components/Modal/Modal.tsx +257 -0
  53. package/src/components/Modal/index.ts +2 -0
  54. package/src/components/MultiSearchAutocomplete/MultiSearchAutocomplete.tsx +319 -0
  55. package/src/components/MultiSearchAutocomplete/index.ts +1 -0
  56. package/src/components/SearchAutocomplete/SearchAutocomplete.tsx +315 -0
  57. package/src/components/SearchAutocomplete/index.ts +2 -0
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/useDebounce.ts +64 -0
  60. package/src/icons/Icon.tsx +15 -16
  61. package/src/icons/index.ts +39 -3
  62. package/src/index.ts +19 -0
  63. package/src/molecules/SpecialtySearchAutocomplete/SpecialtySearchAutocomplete.tsx +203 -0
  64. package/src/molecules/SpecialtySearchAutocomplete/index.ts +5 -0
  65. package/src/molecules/index.ts +5 -0
  66. package/src/styles/index.css +8 -5
  67. package/src/styles/tailwind.css +3 -0
  68. 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,2 @@
1
+ export { SearchAutocomplete } from "./SearchAutocomplete";
2
+ export type { SearchableEntity } from "./SearchAutocomplete";
@@ -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
+ }