@snapdragonsnursery/react-components 1.17.9 → 1.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snapdragonsnursery/react-components",
3
- "version": "1.17.9",
3
+ "version": "1.18.0",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -1,5 +1,6 @@
1
1
  // EmployeeSelect Component
2
2
  // A lightweight, searchable employee selector built with shadcn/ui primitives.
3
+ // Redesigned as a combobox with search as the primary input.
3
4
  // Use this for simple employee selection with pre-loaded data. For advanced search
4
5
  // with server-side filtering, pagination, and complex filters, use EmployeeSearchModal instead.
5
6
  //
@@ -26,30 +27,23 @@
26
27
  // - showSiteName?: boolean // show site below name (default: true)
27
28
  // - showEmployeeId?: boolean // show employee ID below name (default: false)
28
29
  // - showEmail?: boolean // show email below name (default: false)
29
- // - placeholder?: string // select trigger placeholder (default: 'Select employee…')
30
- // - searchPlaceholder?: string // search input placeholder (default: 'Search employees...')
30
+ // - placeholder?: string // search input placeholder (default: 'Search employees...')
31
+ // - searchPlaceholder?: string // alias for placeholder prop
31
32
  // - disabled?: boolean
32
33
  // - className?: string
33
34
  // - allowAll?: boolean // include "All employees" option
34
35
  // - allLabel?: string // custom label for all option
35
- // - maxHeight?: string // max height for dropdown (default: '160px')
36
+ // - maxHeight?: string // max height for dropdown (default: '300px')
36
37
  // - enableServerSearch?: boolean // enable server-side search (default: false)
37
38
  // - onSearchChange?: (query: string) => void // callback for search query changes
38
39
  // - searchResults?: Array<employee> // search results from server
39
40
  // - isSearching?: boolean // loading state for server search
41
+ // - requireSearch?: boolean // hide results until user types (default: false)
40
42
 
41
43
  import React from "react";
42
- import { Users, Search } from "lucide-react";
44
+ import { Users, Search, X } from "lucide-react";
43
45
  import { cn } from "../lib/utils";
44
46
 
45
- import {
46
- Select,
47
- SelectContent,
48
- SelectItem,
49
- SelectTrigger,
50
- SelectValue,
51
- } from "./ui/radix-select";
52
-
53
47
  export const EmployeeSelect = ({
54
48
  value,
55
49
  onChange,
@@ -60,42 +54,42 @@ export const EmployeeSelect = ({
60
54
  showSiteName = true,
61
55
  showEmployeeId = false,
62
56
  showEmail = false,
63
- placeholder = "Select employee…",
64
- searchPlaceholder = "Search employees...",
57
+ placeholder = "Search employees...",
58
+ searchPlaceholder,
65
59
  disabled = false,
66
60
  className,
67
61
  allowAll = false,
68
62
  allLabel = "All employees",
69
- maxHeight = "160px",
70
- // New props for server-side search
63
+ maxHeight = "300px",
71
64
  enableServerSearch = false,
72
65
  onSearchChange = null,
73
66
  searchResults = [],
74
67
  isSearching = false,
68
+ requireSearch = false,
75
69
  }) => {
76
70
  const [searchTerm, setSearchTerm] = React.useState("");
77
71
  const [isOpen, setIsOpen] = React.useState(false);
78
- const [debouncedSearchTerm, setDebouncedSearchTerm] = React.useState("");
79
- const searchInputRef = React.useRef(null);
72
+ const inputRef = React.useRef(null);
73
+
74
+ // Use searchPlaceholder if provided, otherwise use placeholder
75
+ const effectivePlaceholder = searchPlaceholder || placeholder;
80
76
 
81
77
  // Debounce search term for server-side search
82
78
  React.useEffect(() => {
83
79
  const timer = setTimeout(() => {
84
- setDebouncedSearchTerm(searchTerm);
85
80
  if (enableServerSearch && onSearchChange) {
86
81
  onSearchChange(searchTerm);
87
82
  }
88
- }, 300); // 300ms debounce
83
+ }, 300);
89
84
 
90
85
  return () => clearTimeout(timer);
91
86
  }, [searchTerm, enableServerSearch, onSearchChange]);
92
87
 
93
88
  // Maintain focus on search input when server results update
94
89
  React.useEffect(() => {
95
- if (enableServerSearch && isOpen && searchInputRef.current && document.activeElement !== searchInputRef.current) {
96
- // Only refocus if we previously had focus (i.e., user was typing)
90
+ if (enableServerSearch && isOpen && inputRef.current && document.activeElement !== inputRef.current) {
97
91
  if (searchTerm && !isSearching) {
98
- searchInputRef.current.focus();
92
+ inputRef.current.focus();
99
93
  }
100
94
  }
101
95
  }, [searchResults, enableServerSearch, isOpen, searchTerm, isSearching]);
@@ -114,7 +108,6 @@ export const EmployeeSelect = ({
114
108
  // Helper function to get avatar color based on name
115
109
  const getAvatarColor = (name) => {
116
110
  if (!name) return "#6b7280";
117
- // Generate a consistent color based on the name
118
111
  let hash = 0;
119
112
  for (let i = 0; i < name.length; i++) {
120
113
  hash = name.charCodeAt(i) + ((hash << 5) - hash);
@@ -124,19 +117,16 @@ export const EmployeeSelect = ({
124
117
  };
125
118
 
126
119
  const processedItems = React.useMemo(() => {
127
- // Use server search results if enabled, otherwise use items
128
120
  const sourceList = enableServerSearch ? searchResults : items;
129
121
  const list = Array.isArray(sourceList) ? [...sourceList] : [];
130
122
 
131
- // Filter by employee status
132
123
  const filtered = list.filter((e) => {
133
124
  if (filter === "active") {
134
125
  return e.employee_status === "Active";
135
126
  }
136
- return true; // 'all' - no filtering
127
+ return true;
137
128
  });
138
129
 
139
- // Map to consistent structure
140
130
  const mapped = filtered
141
131
  .map((e) => ({
142
132
  entraId: String(e.entra_id || ""),
@@ -146,11 +136,10 @@ export const EmployeeSelect = ({
146
136
  employeeId: String(e.employee_id || ""),
147
137
  email: String(e.email || ""),
148
138
  }))
149
- .filter((e) => e.entraId); // Remove items without entra_id
139
+ .filter((e) => e.entraId);
150
140
 
151
- // Apply client-side search filter only if not using server search
152
- const searchFiltered = enableServerSearch
153
- ? mapped // Server search already filtered
141
+ const searchFiltered = enableServerSearch
142
+ ? mapped
154
143
  : mapped.filter((e) => {
155
144
  if (!searchTerm) return true;
156
145
  const searchLower = searchTerm.toLowerCase();
@@ -163,7 +152,6 @@ export const EmployeeSelect = ({
163
152
  );
164
153
  });
165
154
 
166
- // Sort based on grouping
167
155
  searchFiltered.sort((a, b) => {
168
156
  if (groupBy === "site") {
169
157
  const siteCompare = a.siteName.localeCompare(b.siteName);
@@ -178,38 +166,44 @@ export const EmployeeSelect = ({
178
166
  return a.fullName.localeCompare(b.fullName);
179
167
  });
180
168
 
169
+ if (requireSearch && !searchTerm) {
170
+ return searchFiltered.filter((e) => e.entraId === value);
171
+ }
172
+
181
173
  return searchFiltered;
182
- }, [items, filter, groupBy, searchTerm, enableServerSearch, searchResults]);
174
+ }, [items, filter, groupBy, searchTerm, enableServerSearch, searchResults, requireSearch, value]);
183
175
 
184
- const selectedEmployee = processedItems.find((e) => e.entraId === value);
176
+ const selectedEmployee = processedItems.find((e) => e.entraId === value) ||
177
+ (enableServerSearch ? searchResults.find((e) => String(e.entra_id) === value) : items.find((e) => String(e.entra_id) === value));
185
178
 
186
- const renderEmployeeItem = (employee, isSelected = false) => (
187
- <div className="flex items-center gap-2">
188
- {showAvatar && !isSelected && (
179
+ const handleSelect = (employeeId) => {
180
+ onChange?.(employeeId);
181
+ setSearchTerm("");
182
+ setIsOpen(false);
183
+ };
184
+
185
+ const handleClear = () => {
186
+ onChange?.(undefined);
187
+ setSearchTerm("");
188
+ };
189
+
190
+ const renderEmployeeItem = (employee) => (
191
+ <div className="flex items-center gap-2 p-2 hover:bg-accent rounded-md cursor-pointer">
192
+ {showAvatar && (
189
193
  <div
190
- className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white"
194
+ className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium text-white flex-shrink-0"
191
195
  style={{ backgroundColor: getAvatarColor(employee.fullName) }}
192
196
  >
193
197
  {getInitials(employee.fullName)}
194
198
  </div>
195
199
  )}
196
200
  <div className="flex-1 min-w-0">
197
- <div className="flex items-center gap-2">
198
- <span className="truncate">{employee.fullName}</span>
199
- </div>
200
- {showSiteName && employee.siteName && (
201
+ <div className="text-sm font-medium truncate">{employee.fullName}</div>
202
+ {(showSiteName || showEmail) && (
201
203
  <div className="text-xs text-muted-foreground truncate">
202
- {employee.siteName}
203
- </div>
204
- )}
205
- {showEmployeeId && employee.employeeId && (
206
- <div className="text-xs text-muted-foreground truncate">
207
- ID: {employee.employeeId}
208
- </div>
209
- )}
210
- {showEmail && employee.email && (
211
- <div className="text-xs text-muted-foreground truncate">
212
- {employee.email}
204
+ {showSiteName && employee.siteName && <span>{employee.siteName}</span>}
205
+ {showSiteName && showEmail && employee.siteName && employee.email && <span> • </span>}
206
+ {showEmail && employee.email && <span>{employee.email}</span>}
213
207
  </div>
214
208
  )}
215
209
  </div>
@@ -217,120 +211,174 @@ export const EmployeeSelect = ({
217
211
  );
218
212
 
219
213
  return (
220
- <Select
221
- value={value || (allowAll ? "__all__" : undefined)}
222
- onValueChange={(val) => {
223
- if (val === "__all__") {
224
- onChange?.(undefined);
225
- } else {
226
- onChange?.(val);
227
- }
228
- setSearchTerm(""); // Clear search when selection is made
229
- setIsOpen(false);
230
- }}
231
- disabled={disabled}
232
- open={isOpen}
233
- onOpenChange={setIsOpen}
234
- >
235
- <SelectTrigger className={cn("w-full", className)}>
236
- <div className="flex items-center gap-2">
237
- {selectedEmployee && showAvatar ? (
238
- <div
239
- className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white"
240
- style={{ backgroundColor: getAvatarColor(selectedEmployee.fullName) }}
241
- >
242
- {getInitials(selectedEmployee.fullName)}
243
- </div>
244
- ) : (
245
- <Users className="h-4 w-4" />
246
- )}
247
- <SelectValue placeholder={placeholder} />
248
- </div>
249
- </SelectTrigger>
250
- <SelectContent
251
- className="[&_[data-radix-select-viewport]]:max-h-[var(--select-max-height)] [&_[data-radix-select-viewport]]:overflow-y-auto [&>button[data-radix-select-scroll-up-button]]:hidden [&>button[data-radix-select-scroll-down-button]]:hidden"
252
- style={{ "--select-max-height": maxHeight }}
253
- >
254
- {/* Search input */}
255
- <div className="p-2 border-b">
256
- <div className="relative">
257
- <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
258
- <input
259
- ref={searchInputRef}
260
- type="text"
261
- placeholder={searchPlaceholder}
262
- value={searchTerm}
263
- onChange={(e) => setSearchTerm(e.target.value)}
264
- className="w-full pl-8 pr-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 bg-background"
265
- onClick={(e) => e.stopPropagation()}
266
- />
267
- {enableServerSearch && isSearching && (
268
- <div className="absolute right-2 top-1/2 transform -translate-y-1/2">
269
- <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
214
+ <div className={cn("relative", className)} disabled={disabled}>
215
+ {/* Selected value display */}
216
+ {selectedEmployee && (
217
+ <div className="mb-2 flex items-center justify-between bg-secondary px-3 py-2 rounded-md">
218
+ <div className="flex items-center gap-2 min-w-0">
219
+ {showAvatar && (
220
+ <div
221
+ className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white flex-shrink-0"
222
+ style={{ backgroundColor: getAvatarColor(selectedEmployee.fullName) }}
223
+ >
224
+ {getInitials(selectedEmployee.fullName)}
270
225
  </div>
271
226
  )}
227
+ <div className="min-w-0">
228
+ <div className="text-sm font-medium truncate">{selectedEmployee.fullName}</div>
229
+ {showSiteName && selectedEmployee.siteName && (
230
+ <div className="text-xs text-muted-foreground truncate">{selectedEmployee.siteName}</div>
231
+ )}
232
+ </div>
272
233
  </div>
234
+ <button
235
+ onClick={handleClear}
236
+ className="ml-2 p-1 hover:bg-background rounded flex-shrink-0"
237
+ aria-label="Clear selection"
238
+ >
239
+ <X className="h-4 w-4" />
240
+ </button>
273
241
  </div>
274
- {allowAll && <SelectItem value="__all__">{allLabel}</SelectItem>}
275
- {groupBy === "site"
276
- ? // Group by site
277
- Object.entries(
278
- processedItems.reduce((groups, employee) => {
279
- const site = employee.siteName || "Other";
280
- if (!groups[site]) groups[site] = [];
281
- groups[site].push(employee);
282
- return groups;
283
- }, {})
284
- ).map(([siteName, employees]) => (
285
- <div key={siteName}>
286
- <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
287
- {siteName}
242
+ )}
243
+
244
+ {/* Search input */}
245
+ <div className="relative">
246
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
247
+ <input
248
+ ref={inputRef}
249
+ type="text"
250
+ placeholder={effectivePlaceholder}
251
+ value={searchTerm}
252
+ onChange={(e) => {
253
+ setSearchTerm(e.target.value);
254
+ setIsOpen(true);
255
+ }}
256
+ onFocus={() => setIsOpen(true)}
257
+ disabled={disabled}
258
+ className={cn(
259
+ "w-full pl-10 pr-10 py-2 text-sm border rounded-md",
260
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
261
+ "bg-background transition-colors",
262
+ "placeholder:text-muted-foreground",
263
+ disabled && "opacity-50 cursor-not-allowed",
264
+ )}
265
+ />
266
+ {enableServerSearch && isSearching && (
267
+ <div className="absolute right-3 top-1/2 transform -translate-y-1/2">
268
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
269
+ </div>
270
+ )}
271
+ {searchTerm && !isSearching && (
272
+ <button
273
+ onClick={() => {
274
+ setSearchTerm("");
275
+ inputRef.current?.focus();
276
+ }}
277
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground"
278
+ aria-label="Clear search"
279
+ >
280
+ <X className="h-4 w-4" />
281
+ </button>
282
+ )}
283
+ </div>
284
+
285
+ {/* Results dropdown */}
286
+ {isOpen && (
287
+ <>
288
+ {/* Backdrop to close dropdown */}
289
+ <div
290
+ className="fixed inset-0 z-40"
291
+ onClick={() => setIsOpen(false)}
292
+ onKeyDown={(e) => {
293
+ if (e.key === "Escape") setIsOpen(false);
294
+ }}
295
+ />
296
+
297
+ {/* Results popover */}
298
+ <div
299
+ className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-md z-50 overflow-hidden"
300
+ style={{ maxHeight }}
301
+ >
302
+ <div className="overflow-y-auto">
303
+ {requireSearch && !searchTerm && processedItems.length === 0 ? (
304
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
305
+ Start typing to search for employees...
288
306
  </div>
289
- {employees.map((employee) => {
290
- const isSelected = value === employee.entraId;
291
- return (
292
- <SelectItem key={employee.entraId} value={employee.entraId}>
293
- {renderEmployeeItem(employee, isSelected)}
294
- </SelectItem>
295
- );
296
- })}
297
- </div>
298
- ))
299
- : groupBy === "role"
300
- ? // Group by role
301
- Object.entries(
302
- processedItems.reduce((groups, employee) => {
303
- const role = employee.roleName || "Other";
304
- if (!groups[role]) groups[role] = [];
305
- groups[role].push(employee);
306
- return groups;
307
- }, {})
308
- ).map(([roleName, employees]) => (
309
- <div key={roleName}>
310
- <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
311
- {roleName}
312
- </div>
313
- {employees.map((employee) => {
314
- const isSelected = value === employee.entraId;
315
- return (
316
- <SelectItem key={employee.entraId} value={employee.entraId}>
317
- {renderEmployeeItem(employee, isSelected)}
318
- </SelectItem>
319
- );
320
- })}
307
+ ) : processedItems.length === 0 ? (
308
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
309
+ No employees found
321
310
  </div>
322
- ))
323
- : // No grouping
324
- processedItems.map((employee) => {
325
- const isSelected = value === employee.entraId;
326
- return (
327
- <SelectItem key={employee.entraId} value={employee.entraId}>
328
- {renderEmployeeItem(employee, isSelected)}
329
- </SelectItem>
330
- );
331
- })}
332
- </SelectContent>
333
- </Select>
311
+ ) : (
312
+ <>
313
+ {allowAll && (
314
+ <div
315
+ onClick={() => handleSelect(undefined)}
316
+ className="px-3 py-2 hover:bg-accent cursor-pointer border-b text-sm"
317
+ >
318
+ {allLabel}
319
+ </div>
320
+ )}
321
+ {groupBy === "site"
322
+ ? Object.entries(
323
+ processedItems.reduce((groups, employee) => {
324
+ const site = employee.siteName || "Other";
325
+ if (!groups[site]) groups[site] = [];
326
+ groups[site].push(employee);
327
+ return groups;
328
+ }, {})
329
+ ).map(([siteName, employees]) => (
330
+ <div key={siteName}>
331
+ <div className="px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted">
332
+ {siteName}
333
+ </div>
334
+ {employees.map((employee) => (
335
+ <div
336
+ key={employee.entraId}
337
+ onClick={() => handleSelect(employee.entraId)}
338
+ >
339
+ {renderEmployeeItem(employee)}
340
+ </div>
341
+ ))}
342
+ </div>
343
+ ))
344
+ : groupBy === "role"
345
+ ? Object.entries(
346
+ processedItems.reduce((groups, employee) => {
347
+ const role = employee.roleName || "Other";
348
+ if (!groups[role]) groups[role] = [];
349
+ groups[role].push(employee);
350
+ return groups;
351
+ }, {})
352
+ ).map(([roleName, employees]) => (
353
+ <div key={roleName}>
354
+ <div className="px-3 py-2 text-xs font-semibold text-muted-foreground bg-muted">
355
+ {roleName}
356
+ </div>
357
+ {employees.map((employee) => (
358
+ <div
359
+ key={employee.entraId}
360
+ onClick={() => handleSelect(employee.entraId)}
361
+ >
362
+ {renderEmployeeItem(employee)}
363
+ </div>
364
+ ))}
365
+ </div>
366
+ ))
367
+ : processedItems.map((employee) => (
368
+ <div
369
+ key={employee.entraId}
370
+ onClick={() => handleSelect(employee.entraId)}
371
+ >
372
+ {renderEmployeeItem(employee)}
373
+ </div>
374
+ ))}
375
+ </>
376
+ )}
377
+ </div>
378
+ </div>
379
+ </>
380
+ )}
381
+ </div>
334
382
  );
335
383
  };
336
384