@ews-admin/global-design-system 1.1.6 → 1.1.7

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 (45) hide show
  1. package/dist/components/Logo/Logo.d.ts +1 -1
  2. package/dist/components/Logo/Logo.d.ts.map +1 -1
  3. package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts +1 -1
  4. package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts.map +1 -1
  5. package/dist/components/Select/Select.d.ts +91 -0
  6. package/dist/components/Select/Select.d.ts.map +1 -0
  7. package/dist/components/Select/index.d.ts +3 -0
  8. package/dist/components/Select/index.d.ts.map +1 -0
  9. package/dist/components/ThemeDebugger/ThemeDebugger.d.ts +6 -0
  10. package/dist/components/ThemeDebugger/ThemeDebugger.d.ts.map +1 -0
  11. package/dist/components/ThemeDebugger/index.d.ts +3 -0
  12. package/dist/components/ThemeDebugger/index.d.ts.map +1 -0
  13. package/dist/hooks/index.d.ts +2 -0
  14. package/dist/hooks/index.d.ts.map +1 -1
  15. package/dist/hooks/useSelectField.d.ts +16 -0
  16. package/dist/hooks/useSelectField.d.ts.map +1 -0
  17. package/dist/icons/ArrowRightIcon.d.ts +4 -0
  18. package/dist/icons/ArrowRightIcon.d.ts.map +1 -0
  19. package/dist/icons/CheckIcon.d.ts +4 -0
  20. package/dist/icons/CheckIcon.d.ts.map +1 -0
  21. package/dist/icons/EyeIcon.d.ts +4 -0
  22. package/dist/icons/EyeIcon.d.ts.map +1 -0
  23. package/dist/icons/EyeOffIcon.d.ts +4 -0
  24. package/dist/icons/EyeOffIcon.d.ts.map +1 -0
  25. package/dist/icons/SearchIcon.d.ts +4 -0
  26. package/dist/icons/SearchIcon.d.ts.map +1 -0
  27. package/dist/index.css +2 -2
  28. package/dist/index.d.ts +114 -4
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.esm.css +2 -2
  31. package/dist/index.esm.js +761 -8
  32. package/dist/index.esm.js.map +1 -1
  33. package/dist/index.js +762 -6
  34. package/dist/index.js.map +1 -1
  35. package/package.json +7 -6
  36. package/src/components/Logo/Logo.tsx +1 -1
  37. package/src/components/MultiSearchAutocomplete/MultiSearchAutocomplete.tsx +1 -1
  38. package/src/components/Select/Select.tsx +553 -0
  39. package/src/components/Select/index.ts +2 -0
  40. package/src/components/ThemeDebugger/ThemeDebugger.tsx +101 -0
  41. package/src/components/ThemeDebugger/index.ts +2 -0
  42. package/src/hooks/index.ts +2 -0
  43. package/src/hooks/useSelectField.ts +53 -0
  44. package/src/index.ts +8 -1
  45. package/src/styles/index.css +49 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ews-admin/global-design-system",
3
- "version": "1.1.6",
3
+ "version": "1.1.7",
4
4
  "description": "EWS Global Design System - Reusable components for EWS applications",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.esm.js",
@@ -28,6 +28,7 @@
28
28
  "@babel/preset-env": "^7.23.0",
29
29
  "@babel/preset-react": "^7.22.0",
30
30
  "@babel/preset-typescript": "^7.23.0",
31
+ "@eslint/js": "^9.36.0",
31
32
  "@rollup/plugin-babel": "^6.0.4",
32
33
  "@rollup/plugin-commonjs": "^25.0.7",
33
34
  "@rollup/plugin-node-resolve": "^15.2.3",
@@ -41,13 +42,13 @@
41
42
  "@storybook/testing-library": "^0.2.2",
42
43
  "@types/react": "^18.2.37",
43
44
  "@types/react-dom": "^18.2.15",
44
- "@typescript-eslint/eslint-plugin": "^6.10.0",
45
- "@typescript-eslint/parser": "^6.10.0",
45
+ "@typescript-eslint/eslint-plugin": "^8.44.0",
46
+ "@typescript-eslint/parser": "^8.44.0",
46
47
  "@vitejs/plugin-react": "^4.2.1",
47
48
  "autoprefixer": "^10.4.21",
48
- "eslint": "^8.53.0",
49
- "eslint-plugin-react": "^7.33.2",
50
- "eslint-plugin-react-hooks": "^4.6.0",
49
+ "eslint": "^9.36.0",
50
+ "eslint-plugin-react": "^7.37.5",
51
+ "eslint-plugin-react-hooks": "^5.2.0",
51
52
  "postcss": "^8.5.6",
52
53
  "rollup": "^4.6.1",
53
54
  "rollup-plugin-dts": "^6.1.0",
@@ -29,7 +29,7 @@ export interface LogoProps {
29
29
 
30
30
  const Logo = ({
31
31
  size = "md",
32
- showTagline = true,
32
+ showTagline: _showTagline = true,
33
33
  iconOnly = false,
34
34
  variant = "normal",
35
35
  className,
@@ -31,7 +31,7 @@ export function MultiSearchAutocomplete<T extends SearchableEntity>({
31
31
  selectedItems,
32
32
  onSelectionChange,
33
33
  onSearch,
34
- getEntityById,
34
+ getEntityById: _getEntityById,
35
35
  getPrimaryText,
36
36
  getSecondaryText,
37
37
  placeholder,
@@ -0,0 +1,553 @@
1
+ import { ChevronDown, Search, X } from "lucide-react";
2
+ import React, { forwardRef, useEffect, useId, useRef, useState } from "react";
3
+ import { cn } from "../../utils";
4
+
5
+ export interface SelectOption<T = any> {
6
+ value: T;
7
+ label: string;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ export interface SelectProps<T = any> {
12
+ /**
13
+ * Array of options to display
14
+ */
15
+ options: SelectOption<T>[];
16
+ /**
17
+ * Current selected value
18
+ */
19
+ value?: T;
20
+ /**
21
+ * Callback when selection changes
22
+ */
23
+ onChange?: (value: T, option: SelectOption<T>) => void;
24
+ /**
25
+ * Placeholder text when no option is selected
26
+ */
27
+ placeholder?: string;
28
+ /**
29
+ * Label for the select
30
+ */
31
+ label?: string;
32
+ /**
33
+ * Helper text to display below the select
34
+ */
35
+ helperText?: string;
36
+ /**
37
+ * Error message to display
38
+ */
39
+ error?: string;
40
+ /**
41
+ * Whether the select is in an error state
42
+ */
43
+ isError?: boolean;
44
+ /**
45
+ * Size variant
46
+ */
47
+ size?: "sm" | "md" | "lg";
48
+ /**
49
+ * Whether the select is disabled
50
+ */
51
+ disabled?: boolean;
52
+ /**
53
+ * Whether the select is required
54
+ */
55
+ required?: boolean;
56
+ /**
57
+ * Whether the select is searchable
58
+ */
59
+ searchable?: boolean;
60
+ /**
61
+ * Whether the select allows multiple selections
62
+ */
63
+ multiple?: boolean;
64
+ /**
65
+ * Custom class name for the select element
66
+ */
67
+ selectClassName?: string;
68
+ /**
69
+ * Custom class name for the container
70
+ */
71
+ containerClassName?: string;
72
+ /**
73
+ * Custom class name for the dropdown
74
+ */
75
+ dropdownClassName?: string;
76
+ /**
77
+ * Maximum height of the dropdown
78
+ */
79
+ maxHeight?: number;
80
+ /**
81
+ * Whether to show clear button
82
+ */
83
+ clearable?: boolean;
84
+ /**
85
+ * Custom render function for options
86
+ */
87
+ renderOption?: (
88
+ option: SelectOption<T>,
89
+ isSelected: boolean
90
+ ) => React.ReactNode;
91
+ /**
92
+ * Custom render function for selected value
93
+ */
94
+ renderValue?: (option: SelectOption<T> | null) => React.ReactNode;
95
+ }
96
+
97
+ const Select = forwardRef<HTMLDivElement, SelectProps>(
98
+ (
99
+ {
100
+ options = [],
101
+ value,
102
+ onChange,
103
+ placeholder = "Select an option...",
104
+ label,
105
+ helperText,
106
+ error,
107
+ isError = false,
108
+ size = "md",
109
+ disabled = false,
110
+ required = false,
111
+ searchable = false,
112
+ multiple: _multiple = false,
113
+ selectClassName,
114
+ containerClassName,
115
+ dropdownClassName,
116
+ maxHeight = 200,
117
+ clearable = false,
118
+ renderOption,
119
+ renderValue,
120
+ ...props
121
+ },
122
+ ref
123
+ ) => {
124
+ const generatedId = useId();
125
+ const selectId = `select-${generatedId}`;
126
+ const hasError = isError || !!error;
127
+
128
+ const [isOpen, setIsOpen] = useState(false);
129
+ const [searchTerm, setSearchTerm] = useState("");
130
+ const [focusedIndex, setFocusedIndex] = useState(-1);
131
+ const [dropdownPosition, setDropdownPosition] = useState<"bottom" | "top">(
132
+ "bottom"
133
+ );
134
+
135
+ const containerRef = useRef<HTMLDivElement>(null);
136
+ const inputRef = useRef<HTMLInputElement>(null);
137
+ const dropdownRef = useRef<HTMLDivElement>(null);
138
+ const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
139
+
140
+ // Find selected option
141
+ const selectedOption = options.find((option) => option.value === value);
142
+
143
+ // Filter options based on search term
144
+ const filteredOptions = searchable
145
+ ? options.filter((option) =>
146
+ option.label.toLowerCase().includes(searchTerm.toLowerCase())
147
+ )
148
+ : options;
149
+
150
+ // Calculate dropdown position based on available space
151
+ const calculateDropdownPosition = () => {
152
+ if (!containerRef.current) return;
153
+
154
+ const containerRect = containerRef.current.getBoundingClientRect();
155
+ const viewportHeight = window.innerHeight;
156
+
157
+ // More accurate height calculation
158
+ const optionHeight = 40; // Approximate height per option
159
+ const searchHeight = searchable ? 60 : 0;
160
+ const padding = 16; // py-1 = 8px top + 8px bottom
161
+ const dropdownHeight = Math.min(
162
+ maxHeight,
163
+ filteredOptions.length * optionHeight + searchHeight + padding
164
+ );
165
+
166
+ const spaceBelow = viewportHeight - containerRect.bottom;
167
+ const spaceAbove = containerRect.top;
168
+
169
+ // Add some buffer (20px) to prevent edge cases
170
+ const buffer = 20;
171
+
172
+ console.log("Position calculation:", {
173
+ spaceBelow,
174
+ spaceAbove,
175
+ dropdownHeight,
176
+ viewportHeight,
177
+ containerBottom: containerRect.bottom,
178
+ shouldOpenTop:
179
+ spaceBelow < dropdownHeight + buffer &&
180
+ spaceAbove > dropdownHeight + buffer,
181
+ });
182
+
183
+ // If there's not enough space below but enough space above, position on top
184
+ if (
185
+ spaceBelow < dropdownHeight + buffer &&
186
+ spaceAbove > dropdownHeight + buffer
187
+ ) {
188
+ setDropdownPosition("top");
189
+ } else {
190
+ setDropdownPosition("bottom");
191
+ }
192
+ };
193
+
194
+ // Alternative calculation using actual dropdown element when available
195
+ const calculateDropdownPositionWithElement = () => {
196
+ if (!containerRef.current || !dropdownRef.current) return;
197
+
198
+ const containerRect = containerRef.current.getBoundingClientRect();
199
+ const dropdownRect = dropdownRef.current.getBoundingClientRect();
200
+ const viewportHeight = window.innerHeight;
201
+
202
+ const spaceBelow = viewportHeight - containerRect.bottom;
203
+ const spaceAbove = containerRect.top;
204
+ const actualDropdownHeight = dropdownRect.height;
205
+
206
+ console.log("Position calculation with element:", {
207
+ spaceBelow,
208
+ spaceAbove,
209
+ actualDropdownHeight,
210
+ viewportHeight,
211
+ containerBottom: containerRect.bottom,
212
+ });
213
+
214
+ // If there's not enough space below but enough space above, position on top
215
+ if (
216
+ spaceBelow < actualDropdownHeight &&
217
+ spaceAbove > actualDropdownHeight
218
+ ) {
219
+ setDropdownPosition("top");
220
+ } else {
221
+ setDropdownPosition("bottom");
222
+ }
223
+ };
224
+
225
+ // Handle click outside
226
+ useEffect(() => {
227
+ const handleClickOutside = (event: MouseEvent) => {
228
+ if (
229
+ containerRef.current &&
230
+ !containerRef.current.contains(event.target as Node)
231
+ ) {
232
+ setIsOpen(false);
233
+ setSearchTerm("");
234
+ setFocusedIndex(-1);
235
+ }
236
+ };
237
+
238
+ document.addEventListener("mousedown", handleClickOutside);
239
+ return () =>
240
+ document.removeEventListener("mousedown", handleClickOutside);
241
+ }, []);
242
+
243
+ // Calculate position when dropdown opens
244
+ useEffect(() => {
245
+ if (isOpen) {
246
+ // First calculation based on estimated height
247
+ calculateDropdownPosition();
248
+
249
+ // Second calculation after dropdown is rendered with actual height
250
+ requestAnimationFrame(() => {
251
+ calculateDropdownPositionWithElement();
252
+ });
253
+ }
254
+ }, [isOpen, filteredOptions.length, searchable, maxHeight]);
255
+
256
+ // Recalculate position on window resize
257
+ useEffect(() => {
258
+ const handleResize = () => {
259
+ if (isOpen) {
260
+ calculateDropdownPosition();
261
+ }
262
+ };
263
+
264
+ window.addEventListener("resize", handleResize);
265
+ window.addEventListener("scroll", handleResize);
266
+
267
+ return () => {
268
+ window.removeEventListener("resize", handleResize);
269
+ window.removeEventListener("scroll", handleResize);
270
+ };
271
+ }, [isOpen]);
272
+
273
+ // Handle keyboard navigation
274
+ const handleKeyDown = (event: React.KeyboardEvent) => {
275
+ if (disabled) return;
276
+
277
+ switch (event.key) {
278
+ case "ArrowDown":
279
+ event.preventDefault();
280
+ if (!isOpen) {
281
+ setIsOpen(true);
282
+ } else {
283
+ setFocusedIndex((prev) =>
284
+ prev < filteredOptions.length - 1 ? prev + 1 : 0
285
+ );
286
+ }
287
+ break;
288
+ case "ArrowUp":
289
+ event.preventDefault();
290
+ if (!isOpen) {
291
+ setIsOpen(true);
292
+ } else {
293
+ setFocusedIndex((prev) =>
294
+ prev > 0 ? prev - 1 : filteredOptions.length - 1
295
+ );
296
+ }
297
+ break;
298
+ case "Enter":
299
+ event.preventDefault();
300
+ if (
301
+ isOpen &&
302
+ focusedIndex >= 0 &&
303
+ focusedIndex < filteredOptions.length
304
+ ) {
305
+ handleSelect(filteredOptions[focusedIndex]);
306
+ } else if (!isOpen) {
307
+ setIsOpen(true);
308
+ }
309
+ break;
310
+ case "Escape":
311
+ event.preventDefault();
312
+ setIsOpen(false);
313
+ setSearchTerm("");
314
+ setFocusedIndex(-1);
315
+ break;
316
+ case "Tab":
317
+ setIsOpen(false);
318
+ setSearchTerm("");
319
+ setFocusedIndex(-1);
320
+ break;
321
+ }
322
+ };
323
+
324
+ // Handle option selection
325
+ const handleSelect = (option: SelectOption) => {
326
+ if (option.disabled) return;
327
+
328
+ onChange?.(option.value, option);
329
+ setIsOpen(false);
330
+ setSearchTerm("");
331
+ setFocusedIndex(-1);
332
+ };
333
+
334
+ // Handle clear
335
+ const handleClear = (event: React.MouseEvent) => {
336
+ event.stopPropagation();
337
+ onChange?.(undefined as any, {} as SelectOption);
338
+ };
339
+
340
+ // Handle toggle
341
+ const handleToggle = () => {
342
+ if (disabled) return;
343
+ setIsOpen(!isOpen);
344
+ if (!isOpen && searchable) {
345
+ setTimeout(() => inputRef.current?.focus(), 0);
346
+ }
347
+ };
348
+
349
+ // Scroll focused option into view
350
+ useEffect(() => {
351
+ if (focusedIndex >= 0 && optionRefs.current[focusedIndex]) {
352
+ optionRefs.current[focusedIndex]?.scrollIntoView({
353
+ block: "nearest",
354
+ });
355
+ }
356
+ }, [focusedIndex]);
357
+
358
+ const sizeClasses = {
359
+ sm: "text-sm px-3 py-1.5",
360
+ md: "text-sm px-3 py-2",
361
+ lg: "text-base px-4 py-2.5",
362
+ };
363
+
364
+ const iconSizeClasses = {
365
+ sm: "w-4 h-4",
366
+ md: "w-4 h-4",
367
+ lg: "w-5 h-5",
368
+ };
369
+
370
+ return (
371
+ <div className={cn("w-full", containerClassName)} ref={containerRef}>
372
+ {label && (
373
+ <label
374
+ htmlFor={selectId}
375
+ className={cn(
376
+ "block text-sm font-medium mb-1",
377
+ hasError ? "text-ews-error" : "text-ews-gray-700",
378
+ disabled && "text-ews-gray-400"
379
+ )}
380
+ >
381
+ {label}
382
+ {required && <span className="ml-1 text-ews-error">*</span>}
383
+ </label>
384
+ )}
385
+
386
+ <div className="relative">
387
+ {/* Custom select trigger */}
388
+ <div
389
+ ref={ref as React.RefObject<HTMLDivElement>}
390
+ role="combobox"
391
+ aria-expanded={isOpen}
392
+ aria-haspopup="listbox"
393
+ aria-labelledby={label ? selectId : undefined}
394
+ tabIndex={disabled ? -1 : 0}
395
+ onKeyDown={handleKeyDown}
396
+ onClick={handleToggle}
397
+ className={cn(
398
+ // Base styles
399
+ "ews-select-trigger w-full bg-white border rounded-md shadow-sm transition-colors cursor-pointer",
400
+ "focus:outline-none focus:ring-2 focus:ring-offset-0",
401
+ "disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-ews-gray-50",
402
+
403
+ // Size
404
+ sizeClasses[size],
405
+
406
+ // Border and focus states
407
+ hasError
408
+ ? "border-ews-error focus:border-ews-error focus:ring-ews-error/20"
409
+ : "border-ews-gray-300 focus:border-ews-primary focus:ring-ews-primary/20",
410
+
411
+ // Hover state
412
+ !disabled && !hasError && "hover:border-ews-gray-400",
413
+
414
+ // Text color
415
+ "text-ews-gray-900",
416
+
417
+ // Padding for icons
418
+ "pr-10",
419
+
420
+ selectClassName
421
+ )}
422
+ {...props}
423
+ >
424
+ <div className="flex justify-between items-center">
425
+ <div className="flex-1 min-w-0">
426
+ {selectedOption ? (
427
+ renderValue ? (
428
+ renderValue(selectedOption)
429
+ ) : (
430
+ <span className="truncate">{selectedOption.label}</span>
431
+ )
432
+ ) : (
433
+ <span className="text-ews-gray-500">{placeholder}</span>
434
+ )}
435
+ </div>
436
+
437
+ <div className="flex items-center ml-2 space-x-1">
438
+ {clearable && selectedOption && !disabled && (
439
+ <button
440
+ type="button"
441
+ onClick={handleClear}
442
+ className="p-1 rounded hover:bg-ews-gray-100"
443
+ >
444
+ <X
445
+ className={cn(iconSizeClasses[size], "text-ews-gray-400")}
446
+ />
447
+ </button>
448
+ )}
449
+ <ChevronDown
450
+ className={cn(
451
+ iconSizeClasses[size],
452
+ hasError ? "text-ews-error" : "text-ews-gray-400",
453
+ disabled && "text-ews-gray-300",
454
+ isOpen && "rotate-180 transition-transform"
455
+ )}
456
+ />
457
+ </div>
458
+ </div>
459
+ </div>
460
+
461
+ {/* Custom dropdown */}
462
+ {isOpen && (
463
+ <div
464
+ ref={dropdownRef}
465
+ role="listbox"
466
+ className={cn(
467
+ "absolute z-50 w-full bg-white rounded-md border shadow-lg border-ews-gray-300",
468
+ "focus:outline-none",
469
+ dropdownPosition === "top"
470
+ ? "bottom-full mb-1"
471
+ : "top-full mt-1",
472
+ dropdownClassName
473
+ )}
474
+ style={{ maxHeight: `${maxHeight}px` }}
475
+ >
476
+ {/* Search input */}
477
+ {searchable && (
478
+ <div className="p-2 border-b border-ews-gray-200">
479
+ <div className="relative">
480
+ <Search className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-ews-gray-400" />
481
+ <input
482
+ ref={inputRef}
483
+ type="text"
484
+ value={searchTerm}
485
+ onChange={(e) => setSearchTerm(e.target.value)}
486
+ placeholder="Search options..."
487
+ className="py-2 pr-3 pl-9 w-full text-sm rounded-md border border-ews-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-ews-primary/20 focus:border-ews-primary"
488
+ />
489
+ </div>
490
+ </div>
491
+ )}
492
+
493
+ {/* Options list */}
494
+ <div
495
+ className="overflow-auto py-1"
496
+ style={{ maxHeight: `${maxHeight - (searchable ? 60 : 0)}px` }}
497
+ >
498
+ {filteredOptions.length === 0 ? (
499
+ <div className="px-3 py-2 text-sm text-ews-gray-500">
500
+ {searchTerm ? "No options found" : "No options available"}
501
+ </div>
502
+ ) : (
503
+ filteredOptions.map((option, index) => {
504
+ const isSelected = option.value === value;
505
+ const isFocused = index === focusedIndex;
506
+
507
+ return (
508
+ <div
509
+ key={`${String(option.value)}-${index}`}
510
+ ref={(el) => (optionRefs.current[index] = el)}
511
+ role="option"
512
+ aria-selected={isSelected}
513
+ onClick={() => handleSelect(option)}
514
+ className={cn(
515
+ "px-3 py-2 text-sm cursor-pointer transition-colors",
516
+ isSelected && "bg-ews-primary text-white",
517
+ !isSelected && isFocused && "bg-ews-gray-100",
518
+ !isSelected && !isFocused && "hover:bg-ews-gray-50",
519
+ option.disabled && "opacity-50 cursor-not-allowed"
520
+ )}
521
+ >
522
+ {renderOption ? (
523
+ renderOption(option, isSelected)
524
+ ) : (
525
+ <span className="truncate">{option.label}</span>
526
+ )}
527
+ </div>
528
+ );
529
+ })
530
+ )}
531
+ </div>
532
+ </div>
533
+ )}
534
+ </div>
535
+
536
+ {/* Helper text or error message */}
537
+ {(helperText || error) && (
538
+ <div className="mt-1">
539
+ {error ? (
540
+ <p className="text-sm text-ews-error">{error}</p>
541
+ ) : (
542
+ <p className="text-sm text-ews-gray-500">{helperText}</p>
543
+ )}
544
+ </div>
545
+ )}
546
+ </div>
547
+ );
548
+ }
549
+ );
550
+
551
+ Select.displayName = "Select";
552
+
553
+ export { Select };
@@ -0,0 +1,2 @@
1
+ export { Select } from "./Select";
2
+ export type { SelectOption, SelectProps } from "./Select";