@snapdragonsnursery/react-components 1.17.10 → 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.10",
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,13 +27,13 @@
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
@@ -40,17 +41,9 @@
40
41
  // - requireSearch?: boolean // hide results until user types (default: false)
41
42
 
42
43
  import React from "react";
43
- import { Users, Search } from "lucide-react";
44
+ import { Users, Search, X } from "lucide-react";
44
45
  import { cn } from "../lib/utils";
45
46
 
46
- import {
47
- Select,
48
- SelectContent,
49
- SelectItem,
50
- SelectTrigger,
51
- SelectValue,
52
- } from "./ui/radix-select";
53
-
54
47
  export const EmployeeSelect = ({
55
48
  value,
56
49
  onChange,
@@ -61,14 +54,13 @@ export const EmployeeSelect = ({
61
54
  showSiteName = true,
62
55
  showEmployeeId = false,
63
56
  showEmail = false,
64
- placeholder = "Select employee…",
65
- searchPlaceholder = "Search employees...",
57
+ placeholder = "Search employees...",
58
+ searchPlaceholder,
66
59
  disabled = false,
67
60
  className,
68
61
  allowAll = false,
69
62
  allLabel = "All employees",
70
- maxHeight = "160px",
71
- // New props for server-side search
63
+ maxHeight = "300px",
72
64
  enableServerSearch = false,
73
65
  onSearchChange = null,
74
66
  searchResults = [],
@@ -77,27 +69,27 @@ export const EmployeeSelect = ({
77
69
  }) => {
78
70
  const [searchTerm, setSearchTerm] = React.useState("");
79
71
  const [isOpen, setIsOpen] = React.useState(false);
80
- const [debouncedSearchTerm, setDebouncedSearchTerm] = React.useState("");
81
- 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;
82
76
 
83
77
  // Debounce search term for server-side search
84
78
  React.useEffect(() => {
85
79
  const timer = setTimeout(() => {
86
- setDebouncedSearchTerm(searchTerm);
87
80
  if (enableServerSearch && onSearchChange) {
88
81
  onSearchChange(searchTerm);
89
82
  }
90
- }, 300); // 300ms debounce
83
+ }, 300);
91
84
 
92
85
  return () => clearTimeout(timer);
93
86
  }, [searchTerm, enableServerSearch, onSearchChange]);
94
87
 
95
88
  // Maintain focus on search input when server results update
96
89
  React.useEffect(() => {
97
- if (enableServerSearch && isOpen && searchInputRef.current && document.activeElement !== searchInputRef.current) {
98
- // Only refocus if we previously had focus (i.e., user was typing)
90
+ if (enableServerSearch && isOpen && inputRef.current && document.activeElement !== inputRef.current) {
99
91
  if (searchTerm && !isSearching) {
100
- searchInputRef.current.focus();
92
+ inputRef.current.focus();
101
93
  }
102
94
  }
103
95
  }, [searchResults, enableServerSearch, isOpen, searchTerm, isSearching]);
@@ -116,7 +108,6 @@ export const EmployeeSelect = ({
116
108
  // Helper function to get avatar color based on name
117
109
  const getAvatarColor = (name) => {
118
110
  if (!name) return "#6b7280";
119
- // Generate a consistent color based on the name
120
111
  let hash = 0;
121
112
  for (let i = 0; i < name.length; i++) {
122
113
  hash = name.charCodeAt(i) + ((hash << 5) - hash);
@@ -126,19 +117,16 @@ export const EmployeeSelect = ({
126
117
  };
127
118
 
128
119
  const processedItems = React.useMemo(() => {
129
- // Use server search results if enabled, otherwise use items
130
120
  const sourceList = enableServerSearch ? searchResults : items;
131
121
  const list = Array.isArray(sourceList) ? [...sourceList] : [];
132
122
 
133
- // Filter by employee status
134
123
  const filtered = list.filter((e) => {
135
124
  if (filter === "active") {
136
125
  return e.employee_status === "Active";
137
126
  }
138
- return true; // 'all' - no filtering
127
+ return true;
139
128
  });
140
129
 
141
- // Map to consistent structure
142
130
  const mapped = filtered
143
131
  .map((e) => ({
144
132
  entraId: String(e.entra_id || ""),
@@ -148,11 +136,10 @@ export const EmployeeSelect = ({
148
136
  employeeId: String(e.employee_id || ""),
149
137
  email: String(e.email || ""),
150
138
  }))
151
- .filter((e) => e.entraId); // Remove items without entra_id
139
+ .filter((e) => e.entraId);
152
140
 
153
- // Apply client-side search filter only if not using server search
154
- const searchFiltered = enableServerSearch
155
- ? mapped // Server search already filtered
141
+ const searchFiltered = enableServerSearch
142
+ ? mapped
156
143
  : mapped.filter((e) => {
157
144
  if (!searchTerm) return true;
158
145
  const searchLower = searchTerm.toLowerCase();
@@ -165,7 +152,6 @@ export const EmployeeSelect = ({
165
152
  );
166
153
  });
167
154
 
168
- // Sort based on grouping
169
155
  searchFiltered.sort((a, b) => {
170
156
  if (groupBy === "site") {
171
157
  const siteCompare = a.siteName.localeCompare(b.siteName);
@@ -180,44 +166,44 @@ export const EmployeeSelect = ({
180
166
  return a.fullName.localeCompare(b.fullName);
181
167
  });
182
168
 
183
- // If requireSearch is true and no search term, show only selected item (if any)
184
169
  if (requireSearch && !searchTerm) {
185
- // Return only the currently selected item, or empty array if nothing selected
186
170
  return searchFiltered.filter((e) => e.entraId === value);
187
171
  }
188
172
 
189
173
  return searchFiltered;
190
174
  }, [items, filter, groupBy, searchTerm, enableServerSearch, searchResults, requireSearch, value]);
191
175
 
192
- 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));
178
+
179
+ const handleSelect = (employeeId) => {
180
+ onChange?.(employeeId);
181
+ setSearchTerm("");
182
+ setIsOpen(false);
183
+ };
184
+
185
+ const handleClear = () => {
186
+ onChange?.(undefined);
187
+ setSearchTerm("");
188
+ };
193
189
 
194
- const renderEmployeeItem = (employee, isSelected = false) => (
195
- <div className="flex items-center gap-2">
196
- {showAvatar && !isSelected && (
190
+ const renderEmployeeItem = (employee) => (
191
+ <div className="flex items-center gap-2 p-2 hover:bg-accent rounded-md cursor-pointer">
192
+ {showAvatar && (
197
193
  <div
198
- 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"
199
195
  style={{ backgroundColor: getAvatarColor(employee.fullName) }}
200
196
  >
201
197
  {getInitials(employee.fullName)}
202
198
  </div>
203
199
  )}
204
200
  <div className="flex-1 min-w-0">
205
- <div className="flex items-center gap-2">
206
- <span className="truncate">{employee.fullName}</span>
207
- </div>
208
- {showSiteName && employee.siteName && (
201
+ <div className="text-sm font-medium truncate">{employee.fullName}</div>
202
+ {(showSiteName || showEmail) && (
209
203
  <div className="text-xs text-muted-foreground truncate">
210
- {employee.siteName}
211
- </div>
212
- )}
213
- {showEmployeeId && employee.employeeId && (
214
- <div className="text-xs text-muted-foreground truncate">
215
- ID: {employee.employeeId}
216
- </div>
217
- )}
218
- {showEmail && employee.email && (
219
- <div className="text-xs text-muted-foreground truncate">
220
- {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>}
221
207
  </div>
222
208
  )}
223
209
  </div>
@@ -225,127 +211,174 @@ export const EmployeeSelect = ({
225
211
  );
226
212
 
227
213
  return (
228
- <Select
229
- value={value || (allowAll ? "__all__" : undefined)}
230
- onValueChange={(val) => {
231
- if (val === "__all__") {
232
- onChange?.(undefined);
233
- } else {
234
- onChange?.(val);
235
- }
236
- setSearchTerm(""); // Clear search when selection is made
237
- setIsOpen(false);
238
- }}
239
- disabled={disabled}
240
- open={isOpen}
241
- onOpenChange={setIsOpen}
242
- >
243
- <SelectTrigger className={cn("w-full", className)}>
244
- <div className="flex items-center gap-2">
245
- {selectedEmployee && showAvatar ? (
246
- <div
247
- className="flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium text-white"
248
- style={{ backgroundColor: getAvatarColor(selectedEmployee.fullName) }}
249
- >
250
- {getInitials(selectedEmployee.fullName)}
251
- </div>
252
- ) : (
253
- <Users className="h-4 w-4" />
254
- )}
255
- <SelectValue placeholder={placeholder} />
256
- </div>
257
- </SelectTrigger>
258
- <SelectContent
259
- 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"
260
- style={{ "--select-max-height": maxHeight }}
261
- >
262
- {/* Search input */}
263
- <div className="p-2 border-b">
264
- <div className="relative">
265
- <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
266
- <input
267
- ref={searchInputRef}
268
- type="text"
269
- placeholder={searchPlaceholder}
270
- value={searchTerm}
271
- onChange={(e) => setSearchTerm(e.target.value)}
272
- 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"
273
- onClick={(e) => e.stopPropagation()}
274
- />
275
- {enableServerSearch && isSearching && (
276
- <div className="absolute right-2 top-1/2 transform -translate-y-1/2">
277
- <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)}
278
225
  </div>
279
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>
280
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>
281
241
  </div>
282
- {allowAll && <SelectItem value="__all__">{allLabel}</SelectItem>}
283
- {requireSearch && !searchTerm && processedItems.length === 0 && (
284
- <div className="px-2 py-6 text-center text-sm text-muted-foreground">
285
- Start typing to search for employees...
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>
286
269
  </div>
287
270
  )}
288
- {(!requireSearch || searchTerm || processedItems.length > 0) && (
289
- groupBy === "site"
290
- ? // Group by site
291
- Object.entries(
292
- processedItems.reduce((groups, employee) => {
293
- const site = employee.siteName || "Other";
294
- if (!groups[site]) groups[site] = [];
295
- groups[site].push(employee);
296
- return groups;
297
- }, {})
298
- ).map(([siteName, employees]) => (
299
- <div key={siteName}>
300
- <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
301
- {siteName}
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...
302
306
  </div>
303
- {employees.map((employee) => {
304
- const isSelected = value === employee.entraId;
305
- return (
306
- <SelectItem key={employee.entraId} value={employee.entraId}>
307
- {renderEmployeeItem(employee, isSelected)}
308
- </SelectItem>
309
- );
310
- })}
311
- </div>
312
- ))
313
- : groupBy === "role"
314
- ? // Group by role
315
- Object.entries(
316
- processedItems.reduce((groups, employee) => {
317
- const role = employee.roleName || "Other";
318
- if (!groups[role]) groups[role] = [];
319
- groups[role].push(employee);
320
- return groups;
321
- }, {})
322
- ).map(([roleName, employees]) => (
323
- <div key={roleName}>
324
- <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
325
- {roleName}
326
- </div>
327
- {employees.map((employee) => {
328
- const isSelected = value === employee.entraId;
329
- return (
330
- <SelectItem key={employee.entraId} value={employee.entraId}>
331
- {renderEmployeeItem(employee, isSelected)}
332
- </SelectItem>
333
- );
334
- })}
307
+ ) : processedItems.length === 0 ? (
308
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
309
+ No employees found
335
310
  </div>
336
- ))
337
- : // No grouping
338
- processedItems.map((employee) => {
339
- const isSelected = value === employee.entraId;
340
- return (
341
- <SelectItem key={employee.entraId} value={employee.entraId}>
342
- {renderEmployeeItem(employee, isSelected)}
343
- </SelectItem>
344
- );
345
- })
346
- )}
347
- </SelectContent>
348
- </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>
349
382
  );
350
383
  };
351
384