@syuttechnologies/layout 1.0.2 → 1.0.21

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 (53) hide show
  1. package/README.md +536 -0
  2. package/dist/components/ChangePasswordModal.d.ts.map +1 -1
  3. package/dist/components/ChangePasswordModal.js +22 -11
  4. package/dist/components/EnterpriseLayout.d.ts.map +1 -1
  5. package/dist/components/EnterpriseLayout.js +21 -6
  6. package/dist/components/ui/ActionMenu/ActionMenu.d.ts +52 -0
  7. package/dist/components/ui/ActionMenu/ActionMenu.d.ts.map +1 -0
  8. package/dist/components/ui/ActionMenu/ActionMenu.js +116 -0
  9. package/dist/components/ui/ActionMenu/index.d.ts +3 -0
  10. package/dist/components/ui/ActionMenu/index.d.ts.map +1 -0
  11. package/dist/components/ui/ActionMenu/index.js +2 -0
  12. package/dist/components/ui/ModuleHeader/ModuleHeader.d.ts +90 -0
  13. package/dist/components/ui/ModuleHeader/ModuleHeader.d.ts.map +1 -0
  14. package/dist/components/ui/ModuleHeader/ModuleHeader.js +433 -0
  15. package/dist/components/ui/ModuleHeader/index.d.ts +3 -0
  16. package/dist/components/ui/ModuleHeader/index.d.ts.map +1 -0
  17. package/dist/components/ui/ModuleHeader/index.js +1 -0
  18. package/dist/components/ui/SyutGrid/SyutGrid.d.ts +74 -0
  19. package/dist/components/ui/SyutGrid/SyutGrid.d.ts.map +1 -0
  20. package/dist/components/ui/SyutGrid/SyutGrid.js +306 -0
  21. package/dist/components/ui/SyutGrid/index.d.ts +3 -0
  22. package/dist/components/ui/SyutGrid/index.d.ts.map +1 -0
  23. package/dist/components/ui/SyutGrid/index.js +2 -0
  24. package/dist/components/ui/SyutSelect/SyutSelectUnified.d.ts +128 -0
  25. package/dist/components/ui/SyutSelect/SyutSelectUnified.d.ts.map +1 -0
  26. package/dist/components/ui/SyutSelect/SyutSelectUnified.js +679 -0
  27. package/dist/components/ui/SyutSelect/index.d.ts +3 -0
  28. package/dist/components/ui/SyutSelect/index.d.ts.map +1 -0
  29. package/dist/components/ui/SyutSelect/index.js +2 -0
  30. package/dist/icon-collection/icon-systems.d.ts +89 -0
  31. package/dist/icon-collection/icon-systems.d.ts.map +1 -0
  32. package/dist/icon-collection/icon-systems.js +70 -0
  33. package/dist/icon-collection/index.d.ts +4 -0
  34. package/dist/icon-collection/index.d.ts.map +1 -0
  35. package/dist/icon-collection/index.js +8 -0
  36. package/dist/index.d.ts +12 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +19 -1
  39. package/package.json +9 -4
  40. package/src/components/ChangePasswordModal.tsx +26 -14
  41. package/src/components/EnterpriseLayout.tsx +23 -8
  42. package/src/components/ui/ActionMenu/ActionMenu.tsx +222 -0
  43. package/src/components/ui/ActionMenu/index.ts +2 -0
  44. package/src/components/ui/ModuleHeader/ModuleHeader.tsx +722 -0
  45. package/src/components/ui/ModuleHeader/index.ts +9 -0
  46. package/src/components/ui/SyutGrid/SyutGrid.tsx +483 -0
  47. package/src/components/ui/SyutGrid/index.ts +2 -0
  48. package/src/components/ui/SyutSelect/SyutSelectUnified.tsx +1115 -0
  49. package/src/components/ui/SyutSelect/index.ts +3 -0
  50. package/src/icon-collection/icon-systems.tsx +464 -0
  51. package/src/icon-collection/index.ts +13 -0
  52. package/src/index.ts +47 -1
  53. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,1115 @@
1
+ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
2
+ import Select, {
3
+ components,
4
+ GroupBase,
5
+ StylesConfig,
6
+ ActionMeta,
7
+ MultiValue,
8
+ SingleValue,
9
+ } from 'react-select';
10
+ import CreatableSelect from 'react-select/creatable';
11
+ import AsyncSelect from 'react-select/async';
12
+ import AsyncCreatableSelect from 'react-select/async-creatable';
13
+
14
+ // ============================================================================
15
+ // INJECT SELF-CONTAINED STYLES
16
+ // ============================================================================
17
+
18
+ if (typeof document !== 'undefined') {
19
+ const styleId = 'syut-select-styles';
20
+ if (!document.getElementById(styleId)) {
21
+ const style = document.createElement('style');
22
+ style.id = styleId;
23
+ style.textContent = `
24
+ /* SyutSelectUnified Component Styles */
25
+ @keyframes spin {
26
+ from { transform: rotate(0deg); }
27
+ to { transform: rotate(360deg); }
28
+ }
29
+
30
+ .syut-select-container {
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: 0.25rem;
34
+ width: 100%;
35
+ }
36
+
37
+ .syut-select-label {
38
+ font-size: 0.875rem;
39
+ font-weight: 500;
40
+ color: var(--text-primary, #1e293b);
41
+ margin-bottom: 0.25rem;
42
+ }
43
+
44
+ .syut-select-required {
45
+ color: var(--danger, #ef4444);
46
+ margin-left: 0.25rem;
47
+ }
48
+
49
+ .syut-select-error {
50
+ font-size: 0.75rem;
51
+ color: var(--danger, #ef4444);
52
+ margin-top: 0.25rem;
53
+ }
54
+
55
+ .syut-select-helper {
56
+ font-size: 0.75rem;
57
+ color: var(--text-muted, #64748b);
58
+ margin-top: 0.25rem;
59
+ }
60
+
61
+ /* React-select overrides for consistent theming */
62
+ .syut-select__control {
63
+ border-color: var(--border-color, #e2e8f0) !important;
64
+ background: var(--bg-primary, #ffffff) !important;
65
+ }
66
+
67
+ .syut-select__control--is-focused {
68
+ border-color: var(--primary, #3b82f6) !important;
69
+ box-shadow: 0 0 0 1px var(--primary, #3b82f6) !important;
70
+ }
71
+
72
+ .syut-select__menu {
73
+ background: var(--bg-primary, #ffffff) !important;
74
+ border: 1px solid var(--border-color, #e2e8f0) !important;
75
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
76
+ z-index: 9999 !important;
77
+ }
78
+
79
+ .syut-select__option {
80
+ background: transparent !important;
81
+ color: var(--text-primary, #1e293b) !important;
82
+ }
83
+
84
+ .syut-select__option--is-focused {
85
+ background: var(--bg-secondary, #f8fafc) !important;
86
+ }
87
+
88
+ .syut-select__option--is-selected {
89
+ background: var(--primary, #3b82f6) !important;
90
+ color: white !important;
91
+ }
92
+
93
+ .syut-select__single-value {
94
+ color: var(--text-primary, #1e293b) !important;
95
+ }
96
+
97
+ .syut-select__multi-value {
98
+ background: var(--bg-tertiary, #e0ebf7) !important;
99
+ border-radius: 4px !important;
100
+ }
101
+
102
+ .syut-select__multi-value__label {
103
+ color: var(--text-primary, #1e293b) !important;
104
+ }
105
+
106
+ .syut-select__multi-value__remove:hover {
107
+ background: var(--danger, #ef4444) !important;
108
+ color: white !important;
109
+ }
110
+
111
+ .syut-select__placeholder {
112
+ color: var(--text-muted, #64748b) !important;
113
+ }
114
+
115
+ .syut-select__input-container {
116
+ color: var(--text-primary, #1e293b) !important;
117
+ }
118
+ `;
119
+ document.head.appendChild(style);
120
+ }
121
+ }
122
+
123
+ // ============================================================================
124
+ // TYPES
125
+ // ============================================================================
126
+
127
+ export interface SelectOption {
128
+ value: string;
129
+ label: string;
130
+ isDisabled?: boolean;
131
+ [key: string]: any;
132
+ }
133
+
134
+ export interface SyutSelectUnifiedProps {
135
+ /** Unique identifier for the select */
136
+ id?: string;
137
+ /** Name attribute for form handling */
138
+ name?: string;
139
+ /** Options to display in the dropdown */
140
+ options?: SelectOption[];
141
+ /** Currently selected value(s) - string[] for multi, string for single */
142
+ value?: string | string[];
143
+ /** Callback when selection changes */
144
+ onChange?: (value: any) => void;
145
+ /** Placeholder text */
146
+ placeholder?: string;
147
+ /** Whether the select is disabled */
148
+ isDisabled?: boolean;
149
+ /** Whether the select is in loading state */
150
+ isLoading?: boolean;
151
+ /** Whether to allow multiple selections */
152
+ isMulti?: boolean;
153
+ /** Whether the select is searchable */
154
+ isSearchable?: boolean;
155
+ /** Legacy alias for isSearchable */
156
+ searchable?: boolean;
157
+ /** Whether the value can be cleared */
158
+ isClearable?: boolean;
159
+ /** Whether to allow creating new options */
160
+ isCreatable?: boolean;
161
+ /** Callback for creating new options */
162
+ onCreateOption?: (inputValue: string) => void;
163
+ /** Format for the create option label */
164
+ formatCreateLabel?: (inputValue: string) => React.ReactNode;
165
+ /** Whether to use async loading */
166
+ isAsync?: boolean;
167
+ /** Async function to load options */
168
+ loadOptions?: (
169
+ inputValue: string,
170
+ callback: (options: SelectOption[]) => void
171
+ ) => Promise<SelectOption[]> | void;
172
+ /** Default options to show before search (for async) */
173
+ defaultOptions?: boolean | SelectOption[];
174
+ /** Cache loaded options */
175
+ cacheOptions?: boolean;
176
+ /** Label for the select (optional) */
177
+ label?: string;
178
+ /** Error message to display */
179
+ error?: string;
180
+ /** Helper text below the select */
181
+ helperText?: string;
182
+ /** Whether the field is required */
183
+ required?: boolean;
184
+ /** Additional class name for the container */
185
+ className?: string;
186
+ /** Size variant */
187
+ size?: 'sm' | 'md' | 'lg';
188
+ /** Auto focus the select */
189
+ autoFocus?: boolean;
190
+ /** Menu placement */
191
+ menuPlacement?: 'auto' | 'top' | 'bottom';
192
+ /** Maximum height of dropdown menu */
193
+ maxMenuHeight?: number;
194
+ /**
195
+ * Variant style:
196
+ * - 'default': Uses react-select (tags for multi, dropdown for single)
197
+ * - 'checkbox': Custom checkbox-style multi-select with search and select all
198
+ */
199
+ variant?: 'default' | 'checkbox';
200
+ /** Show select all option (only for checkbox variant) */
201
+ showSelectAll?: boolean;
202
+ /** Label for select all option */
203
+ selectAllLabel?: string;
204
+ /** Search placeholder (for checkbox variant) */
205
+ searchPlaceholder?: string;
206
+ /** No options message */
207
+ noOptionsMessage?: string;
208
+ /** Debounce delay in ms for async search (default: 300) */
209
+ debounceDelay?: number;
210
+ /** Minimum characters before triggering async search (default: 1) */
211
+ minSearchLength?: number;
212
+ /** Message to show while loading async results */
213
+ loadingMessage?: string;
214
+ /** Message to show when search query is too short */
215
+ minSearchMessage?: string;
216
+ /** Inline styles for the container */
217
+ style?: React.CSSProperties;
218
+ }
219
+
220
+ // ============================================================================
221
+ // SIZE CONFIGURATIONS
222
+ // ============================================================================
223
+
224
+ const sizeConfig = {
225
+ sm: { height: '32px', fontSize: '0.8rem', padding: '0.375rem 0.5rem', checkboxSize: '14px' },
226
+ md: { height: '38px', fontSize: '0.875rem', padding: '0.5rem 0.75rem', checkboxSize: '16px' },
227
+ lg: { height: '44px', fontSize: '1rem', padding: '0.625rem 1rem', checkboxSize: '18px' },
228
+ };
229
+
230
+ // ============================================================================
231
+ // REACT-SELECT CUSTOM STYLES
232
+ // ============================================================================
233
+
234
+ const getReactSelectStyles = (
235
+ size: 'sm' | 'md' | 'lg',
236
+ hasError: boolean
237
+ ): StylesConfig<SelectOption, boolean, GroupBase<SelectOption>> => {
238
+ const config = sizeConfig[size];
239
+
240
+ return {
241
+ control: (base, state) => ({
242
+ ...base,
243
+ minHeight: config.height,
244
+ fontSize: config.fontSize,
245
+ borderColor: hasError
246
+ ? 'var(--danger, #ef4444)'
247
+ : state.isFocused
248
+ ? 'var(--primary)'
249
+ : 'var(--border-color)',
250
+ boxShadow: state.isFocused
251
+ ? `0 0 0 1px ${hasError ? 'var(--danger, #ef4444)' : 'var(--primary)'}`
252
+ : 'none',
253
+ backgroundColor: state.isDisabled ? 'var(--bg-secondary)' : 'var(--bg-primary)',
254
+ '&:hover': {
255
+ borderColor: hasError ? 'var(--danger, #ef4444)' : 'var(--primary)',
256
+ },
257
+ cursor: state.isDisabled ? 'not-allowed' : 'pointer',
258
+ }),
259
+ menu: (base) => ({
260
+ ...base,
261
+ backgroundColor: 'var(--bg-primary)',
262
+ border: '1px solid var(--border-color)',
263
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
264
+ zIndex: 9999,
265
+ }),
266
+ menuList: (base) => ({
267
+ ...base,
268
+ padding: '4px',
269
+ }),
270
+ option: (base, state) => ({
271
+ ...base,
272
+ fontSize: config.fontSize,
273
+ padding: config.padding,
274
+ backgroundColor: state.isSelected
275
+ ? 'var(--primary)'
276
+ : state.isFocused
277
+ ? 'var(--bg-secondary)'
278
+ : 'transparent',
279
+ color: state.isSelected ? 'white' : 'var(--text-primary)',
280
+ cursor: state.isDisabled ? 'not-allowed' : 'pointer',
281
+ borderRadius: '4px',
282
+ }),
283
+ singleValue: (base) => ({
284
+ ...base,
285
+ color: 'var(--text-primary)',
286
+ }),
287
+ multiValue: (base) => ({
288
+ ...base,
289
+ backgroundColor: 'var(--bg-tertiary, #e0ebf7)',
290
+ borderRadius: '4px',
291
+ }),
292
+ multiValueLabel: (base) => ({
293
+ ...base,
294
+ color: 'var(--text-primary)',
295
+ fontSize: config.fontSize,
296
+ padding: '2px 6px',
297
+ }),
298
+ multiValueRemove: (base) => ({
299
+ ...base,
300
+ color: 'var(--text-muted)',
301
+ '&:hover': {
302
+ backgroundColor: 'var(--danger, #ef4444)',
303
+ color: 'white',
304
+ },
305
+ }),
306
+ placeholder: (base) => ({
307
+ ...base,
308
+ color: 'var(--text-muted)',
309
+ }),
310
+ input: (base) => ({
311
+ ...base,
312
+ color: 'var(--text-primary)',
313
+ }),
314
+ dropdownIndicator: (base, state) => ({
315
+ ...base,
316
+ color: 'var(--text-muted)',
317
+ transform: state.selectProps.menuIsOpen ? 'rotate(180deg)' : undefined,
318
+ transition: 'transform 0.2s ease',
319
+ }),
320
+ clearIndicator: (base) => ({
321
+ ...base,
322
+ color: 'var(--text-muted)',
323
+ '&:hover': {
324
+ color: 'var(--danger, #ef4444)',
325
+ },
326
+ }),
327
+ };
328
+ };
329
+
330
+ // ============================================================================
331
+ // CHECKBOX VARIANT COMPONENT
332
+ // ============================================================================
333
+
334
+ const CheckboxVariant: React.FC<SyutSelectUnifiedProps & { config: typeof sizeConfig['md'] }> = ({
335
+ id,
336
+ options: initialOptions = [],
337
+ value = [],
338
+ onChange,
339
+ placeholder = 'Select...',
340
+ isDisabled = false,
341
+ isLoading: externalLoading = false,
342
+ isMulti = true,
343
+ isAsync = false,
344
+ loadOptions,
345
+ defaultOptions,
346
+ cacheOptions = true,
347
+ error,
348
+ size = 'md',
349
+ showSelectAll = true,
350
+ selectAllLabel = 'Select All',
351
+ maxMenuHeight = 250,
352
+ searchPlaceholder = 'Search...',
353
+ noOptionsMessage = 'No options found',
354
+ debounceDelay = 300,
355
+ minSearchLength = 1,
356
+ loadingMessage = 'Loading...',
357
+ minSearchMessage = 'Type to search...',
358
+ config,
359
+ }) => {
360
+ const [isOpen, setIsOpen] = useState(false);
361
+ const [searchQuery, setSearchQuery] = useState('');
362
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
363
+ const [asyncOptions, setAsyncOptions] = useState<SelectOption[]>([]);
364
+ const [isSearching, setIsSearching] = useState(false);
365
+ const [hasSearched, setHasSearched] = useState(false);
366
+ const containerRef = useRef<HTMLDivElement>(null);
367
+ const searchInputRef = useRef<HTMLInputElement>(null);
368
+ const optionsListRef = useRef<HTMLDivElement>(null);
369
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
370
+ const optionsCacheRef = useRef<Map<string, SelectOption[]>>(new Map());
371
+
372
+ // Determine which options to use (async loaded or initial)
373
+ const options = isAsync ? asyncOptions : initialOptions;
374
+ const isLoading = externalLoading || isSearching;
375
+
376
+ // Load default options when dropdown opens (for async mode)
377
+ useEffect(() => {
378
+ if (isAsync && isOpen && !hasSearched) {
379
+ if (defaultOptions === true && loadOptions) {
380
+ // Load initial options with empty string
381
+ setIsSearching(true);
382
+ const result = loadOptions('', (loadedOptions) => {
383
+ setAsyncOptions(loadedOptions);
384
+ setIsSearching(false);
385
+ });
386
+ if (result instanceof Promise) {
387
+ result.then((loadedOptions) => {
388
+ setAsyncOptions(loadedOptions);
389
+ setIsSearching(false);
390
+ }).catch(() => {
391
+ setIsSearching(false);
392
+ });
393
+ }
394
+ } else if (Array.isArray(defaultOptions)) {
395
+ setAsyncOptions(defaultOptions);
396
+ }
397
+ }
398
+ }, [isAsync, isOpen, hasSearched, defaultOptions, loadOptions]);
399
+
400
+ // Handle async search with debounce
401
+ useEffect(() => {
402
+ if (!isAsync || !loadOptions) return;
403
+
404
+ // Clear previous timer
405
+ if (debounceTimerRef.current) {
406
+ clearTimeout(debounceTimerRef.current);
407
+ }
408
+
409
+ // Check minimum search length
410
+ if (searchQuery.length < minSearchLength) {
411
+ if (searchQuery.length === 0 && Array.isArray(defaultOptions)) {
412
+ setAsyncOptions(defaultOptions);
413
+ }
414
+ return;
415
+ }
416
+
417
+ // Check cache first
418
+ if (cacheOptions && optionsCacheRef.current.has(searchQuery)) {
419
+ setAsyncOptions(optionsCacheRef.current.get(searchQuery) || []);
420
+ return;
421
+ }
422
+
423
+ // Debounce the API call
424
+ debounceTimerRef.current = setTimeout(() => {
425
+ setIsSearching(true);
426
+ setHasSearched(true);
427
+
428
+ const result = loadOptions(searchQuery, (loadedOptions) => {
429
+ setAsyncOptions(loadedOptions);
430
+ if (cacheOptions) {
431
+ optionsCacheRef.current.set(searchQuery, loadedOptions);
432
+ }
433
+ setIsSearching(false);
434
+ });
435
+
436
+ if (result instanceof Promise) {
437
+ result.then((loadedOptions) => {
438
+ setAsyncOptions(loadedOptions);
439
+ if (cacheOptions) {
440
+ optionsCacheRef.current.set(searchQuery, loadedOptions);
441
+ }
442
+ setIsSearching(false);
443
+ }).catch(() => {
444
+ setIsSearching(false);
445
+ });
446
+ }
447
+ }, debounceDelay);
448
+
449
+ return () => {
450
+ if (debounceTimerRef.current) {
451
+ clearTimeout(debounceTimerRef.current);
452
+ }
453
+ };
454
+ }, [searchQuery, isAsync, loadOptions, debounceDelay, minSearchLength, cacheOptions, defaultOptions]);
455
+
456
+ // Normalize value to array
457
+ const selectedValues = useMemo(() => {
458
+ if (!value) return [];
459
+ return Array.isArray(value) ? value : [value];
460
+ }, [value]);
461
+
462
+ // Filter options based on search query (only for non-async mode)
463
+ const filteredOptions = useMemo(() => {
464
+ if (isAsync) return options; // Async filtering is done by the API
465
+ if (!searchQuery.trim()) return options;
466
+ const query = searchQuery.toLowerCase();
467
+ return options.filter(option =>
468
+ option.label.toLowerCase().includes(query) ||
469
+ option.value.toLowerCase().includes(query)
470
+ );
471
+ }, [options, searchQuery, isAsync]);
472
+
473
+ // Check if all filtered options are selected
474
+ const allFilteredSelected = useMemo(() => {
475
+ const selectableOptions = filteredOptions.filter(opt => !opt.isDisabled);
476
+ if (selectableOptions.length === 0) return false;
477
+ return selectableOptions.every(opt => selectedValues.includes(opt.value));
478
+ }, [filteredOptions, selectedValues]);
479
+
480
+ // Get selected options with their details
481
+ const selectedOptions = useMemo(() => {
482
+ return options.filter(opt => selectedValues.includes(opt.value));
483
+ }, [options, selectedValues]);
484
+
485
+ // Handle click outside to close dropdown
486
+ useEffect(() => {
487
+ const handleClickOutside = (event: MouseEvent) => {
488
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
489
+ setIsOpen(false);
490
+ setSearchQuery('');
491
+ }
492
+ };
493
+
494
+ if (isOpen) {
495
+ document.addEventListener('mousedown', handleClickOutside);
496
+ }
497
+
498
+ return () => {
499
+ document.removeEventListener('mousedown', handleClickOutside);
500
+ };
501
+ }, [isOpen]);
502
+
503
+ // Focus search input when dropdown opens
504
+ useEffect(() => {
505
+ if (isOpen && searchInputRef.current) {
506
+ searchInputRef.current.focus();
507
+ }
508
+ if (!isOpen) {
509
+ setHighlightedIndex(-1);
510
+ }
511
+ }, [isOpen]);
512
+
513
+ // Reset highlighted index when filtered options change
514
+ useEffect(() => {
515
+ setHighlightedIndex(-1);
516
+ }, [filteredOptions.length]);
517
+
518
+ // Scroll highlighted option into view
519
+ useEffect(() => {
520
+ if (highlightedIndex >= 0 && optionsListRef.current) {
521
+ const highlightedElement = optionsListRef.current.children[highlightedIndex] as HTMLElement;
522
+ if (highlightedElement) {
523
+ highlightedElement.scrollIntoView({ block: 'nearest' });
524
+ }
525
+ }
526
+ }, [highlightedIndex]);
527
+
528
+ // Handle option toggle
529
+ const handleOptionToggle = useCallback((optionValue: string) => {
530
+ if (isMulti) {
531
+ const newValue = selectedValues.includes(optionValue)
532
+ ? selectedValues.filter(v => v !== optionValue)
533
+ : [...selectedValues, optionValue];
534
+ onChange?.(newValue);
535
+ } else {
536
+ onChange?.(optionValue);
537
+ setIsOpen(false);
538
+ setSearchQuery('');
539
+ }
540
+ }, [selectedValues, onChange, isMulti]);
541
+
542
+ // Handle select all toggle
543
+ const handleSelectAllToggle = useCallback(() => {
544
+ const selectableOptions = filteredOptions.filter(opt => !opt.isDisabled);
545
+ const selectableValues = selectableOptions.map(opt => opt.value);
546
+
547
+ if (allFilteredSelected) {
548
+ const newValue = selectedValues.filter(v => !selectableValues.includes(v));
549
+ onChange?.(newValue);
550
+ } else {
551
+ const newValue = [...new Set([...selectedValues, ...selectableValues])];
552
+ onChange?.(newValue);
553
+ }
554
+ }, [filteredOptions, allFilteredSelected, selectedValues, onChange]);
555
+
556
+ // Remove a selected item
557
+ const removeSelection = useCallback((optionValue: string, e: React.MouseEvent) => {
558
+ e.stopPropagation();
559
+ if (isMulti) {
560
+ onChange?.(selectedValues.filter(v => v !== optionValue));
561
+ } else {
562
+ onChange?.(null);
563
+ }
564
+ }, [selectedValues, onChange, isMulti]);
565
+
566
+ // Handle keyboard navigation
567
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
568
+ const selectableOptions = filteredOptions.filter(opt => !opt.isDisabled);
569
+
570
+ switch (e.key) {
571
+ case 'ArrowDown':
572
+ e.preventDefault();
573
+ e.stopPropagation();
574
+ setHighlightedIndex(prev => {
575
+ const nextIndex = prev + 1;
576
+ return nextIndex >= selectableOptions.length ? 0 : nextIndex;
577
+ });
578
+ break;
579
+ case 'ArrowUp':
580
+ e.preventDefault();
581
+ e.stopPropagation();
582
+ setHighlightedIndex(prev => {
583
+ const nextIndex = prev - 1;
584
+ return nextIndex < 0 ? selectableOptions.length - 1 : nextIndex;
585
+ });
586
+ break;
587
+ case 'Enter':
588
+ case ' ':
589
+ e.preventDefault();
590
+ e.stopPropagation();
591
+ if (highlightedIndex >= 0 && highlightedIndex < selectableOptions.length) {
592
+ handleOptionToggle(selectableOptions[highlightedIndex].value);
593
+ }
594
+ break;
595
+ case 'Escape':
596
+ e.preventDefault();
597
+ e.stopPropagation();
598
+ setIsOpen(false);
599
+ setSearchQuery('');
600
+ break;
601
+ default:
602
+ e.stopPropagation();
603
+ break;
604
+ }
605
+ }, [filteredOptions, highlightedIndex, handleOptionToggle]);
606
+
607
+ return (
608
+ <div ref={containerRef} style={{ position: 'relative' }}>
609
+ {/* Trigger */}
610
+ <div
611
+ id={id}
612
+ onClick={() => !isDisabled && setIsOpen(!isOpen)}
613
+ style={{
614
+ width: '100%',
615
+ minHeight: config.height,
616
+ maxHeight: '120px',
617
+ overflowY: 'auto',
618
+ padding: '0.25rem 0.5rem',
619
+ paddingRight: '2rem',
620
+ fontSize: config.fontSize,
621
+ display: 'flex',
622
+ flexWrap: 'wrap',
623
+ alignItems: 'flex-start',
624
+ alignContent: 'flex-start',
625
+ gap: '0.25rem',
626
+ background: isDisabled ? 'var(--bg-secondary)' : 'var(--bg-primary)',
627
+ border: `1px solid ${error ? 'var(--danger, #ef4444)' : isOpen ? 'var(--primary)' : 'var(--border-color)'}`,
628
+ borderRadius: 'var(--radius-sm, 4px)',
629
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
630
+ position: 'relative',
631
+ transition: 'all 0.2s ease',
632
+ boxShadow: isOpen ? '0 0 0 1px var(--primary)' : 'none',
633
+ }}
634
+ >
635
+ {isLoading ? (
636
+ <span style={{ color: 'var(--text-muted)', padding: '0.125rem 0.25rem' }}>Loading...</span>
637
+ ) : selectedOptions.length === 0 ? (
638
+ <span style={{ color: 'var(--text-muted)', padding: '0.125rem 0.25rem' }}>{placeholder}</span>
639
+ ) : (
640
+ selectedOptions.map(option => (
641
+ <span
642
+ key={option.value}
643
+ style={{
644
+ display: 'inline-flex',
645
+ alignItems: 'center',
646
+ gap: '0.25rem',
647
+ padding: '0.125rem 0.375rem',
648
+ background: 'var(--bg-tertiary, #e0ebf7)',
649
+ border: '1px solid var(--border-color)',
650
+ borderRadius: '0.25rem',
651
+ fontSize: '0.75rem',
652
+ color: 'var(--text-primary)',
653
+ fontWeight: 500,
654
+ maxWidth: '150px'
655
+ }}
656
+ >
657
+ <span style={{
658
+ overflow: 'hidden',
659
+ textOverflow: 'ellipsis',
660
+ whiteSpace: 'nowrap'
661
+ }}>
662
+ {option.label}
663
+ </span>
664
+ {!isDisabled && (
665
+ <button
666
+ type="button"
667
+ onClick={(e) => removeSelection(option.value, e)}
668
+ style={{
669
+ display: 'inline-flex',
670
+ alignItems: 'center',
671
+ justifyContent: 'center',
672
+ width: '14px',
673
+ height: '14px',
674
+ padding: 0,
675
+ border: 'none',
676
+ background: 'transparent',
677
+ cursor: 'pointer',
678
+ borderRadius: '50%',
679
+ color: 'var(--text-muted)',
680
+ flexShrink: 0
681
+ }}
682
+ onMouseEnter={(e) => {
683
+ e.currentTarget.style.background = 'var(--border-color)';
684
+ e.currentTarget.style.color = 'var(--text-primary)';
685
+ }}
686
+ onMouseLeave={(e) => {
687
+ e.currentTarget.style.background = 'transparent';
688
+ e.currentTarget.style.color = 'var(--text-muted)';
689
+ }}
690
+ >
691
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
692
+ <line x1="18" y1="6" x2="6" y2="18" />
693
+ <line x1="6" y1="6" x2="18" y2="18" />
694
+ </svg>
695
+ </button>
696
+ )}
697
+ </span>
698
+ ))
699
+ )}
700
+ <svg
701
+ width="16"
702
+ height="16"
703
+ viewBox="0 0 24 24"
704
+ fill="none"
705
+ stroke="currentColor"
706
+ strokeWidth="2"
707
+ style={{
708
+ position: 'absolute',
709
+ right: '0.5rem',
710
+ top: '50%',
711
+ transform: isOpen ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%) rotate(0deg)',
712
+ transition: 'transform 0.2s ease',
713
+ color: 'var(--text-muted)',
714
+ flexShrink: 0,
715
+ }}
716
+ >
717
+ <polyline points="6 9 12 15 18 9" />
718
+ </svg>
719
+ </div>
720
+
721
+ {/* Dropdown Menu */}
722
+ {isOpen && (
723
+ <div
724
+ style={{
725
+ position: 'absolute',
726
+ top: '100%',
727
+ left: 0,
728
+ right: 0,
729
+ marginTop: '4px',
730
+ background: 'var(--bg-primary)',
731
+ border: '1px solid var(--border-color)',
732
+ borderRadius: 'var(--radius-sm, 4px)',
733
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
734
+ zIndex: 9999,
735
+ overflow: 'hidden',
736
+ }}
737
+ >
738
+ {/* Search Input */}
739
+ <div style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>
740
+ <input
741
+ ref={searchInputRef}
742
+ type="text"
743
+ value={searchQuery}
744
+ onChange={(e) => setSearchQuery(e.target.value)}
745
+ onClick={(e) => e.stopPropagation()}
746
+ onKeyDown={handleKeyDown}
747
+ onMouseDown={(e) => e.stopPropagation()}
748
+ placeholder={searchPlaceholder}
749
+ style={{
750
+ width: '100%',
751
+ padding: '0.375rem 0.5rem',
752
+ fontSize: config.fontSize,
753
+ border: '1px solid var(--border-color)',
754
+ borderRadius: 'var(--radius-sm, 4px)',
755
+ background: 'var(--bg-primary)',
756
+ color: 'var(--text-primary)',
757
+ outline: 'none',
758
+ }}
759
+ onFocus={(e) => {
760
+ e.target.style.borderColor = 'var(--primary)';
761
+ }}
762
+ onBlur={(e) => {
763
+ e.target.style.borderColor = 'var(--border-color)';
764
+ }}
765
+ />
766
+ </div>
767
+
768
+ {/* Select All Option */}
769
+ {isMulti && showSelectAll && filteredOptions.length > 0 && (
770
+ <div
771
+ onClick={handleSelectAllToggle}
772
+ style={{
773
+ display: 'flex',
774
+ alignItems: 'center',
775
+ gap: '0.5rem',
776
+ padding: config.padding,
777
+ cursor: 'pointer',
778
+ borderBottom: '1px solid var(--border-color)',
779
+ background: 'var(--bg-secondary)',
780
+ fontWeight: 600,
781
+ fontSize: config.fontSize,
782
+ }}
783
+ onMouseEnter={(e) => {
784
+ e.currentTarget.style.background = 'var(--bg-tertiary, #e2e8f0)';
785
+ }}
786
+ onMouseLeave={(e) => {
787
+ e.currentTarget.style.background = 'var(--bg-secondary)';
788
+ }}
789
+ >
790
+ <input
791
+ type="checkbox"
792
+ checked={allFilteredSelected}
793
+ onChange={() => {}}
794
+ style={{
795
+ width: config.checkboxSize,
796
+ height: config.checkboxSize,
797
+ accentColor: 'var(--primary)',
798
+ cursor: 'pointer',
799
+ }}
800
+ />
801
+ <span style={{ color: 'var(--text-primary)' }}>{selectAllLabel}</span>
802
+ </div>
803
+ )}
804
+
805
+ {/* Options List */}
806
+ <div
807
+ ref={optionsListRef}
808
+ style={{
809
+ maxHeight: `${maxMenuHeight}px`,
810
+ overflowY: 'auto',
811
+ }}
812
+ >
813
+ {isLoading ? (
814
+ <div
815
+ style={{
816
+ padding: '1rem',
817
+ textAlign: 'center',
818
+ color: 'var(--text-muted)',
819
+ fontSize: config.fontSize,
820
+ display: 'flex',
821
+ alignItems: 'center',
822
+ justifyContent: 'center',
823
+ gap: '0.5rem',
824
+ }}
825
+ >
826
+ <svg
827
+ width="16"
828
+ height="16"
829
+ viewBox="0 0 24 24"
830
+ fill="none"
831
+ stroke="currentColor"
832
+ strokeWidth="2"
833
+ style={{ animation: 'spin 1s linear infinite' }}
834
+ >
835
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
836
+ <path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
837
+ </svg>
838
+ {loadingMessage}
839
+ </div>
840
+ ) : isAsync && searchQuery.length < minSearchLength && filteredOptions.length === 0 ? (
841
+ <div
842
+ style={{
843
+ padding: '1rem',
844
+ textAlign: 'center',
845
+ color: 'var(--text-muted)',
846
+ fontSize: config.fontSize,
847
+ }}
848
+ >
849
+ {minSearchMessage}
850
+ </div>
851
+ ) : filteredOptions.length === 0 ? (
852
+ <div
853
+ style={{
854
+ padding: '1rem',
855
+ textAlign: 'center',
856
+ color: 'var(--text-muted)',
857
+ fontSize: config.fontSize,
858
+ }}
859
+ >
860
+ {noOptionsMessage}
861
+ </div>
862
+ ) : (
863
+ filteredOptions.filter(opt => !opt.isDisabled).map((option, index) => {
864
+ const isSelected = selectedValues.includes(option.value);
865
+ const isHighlighted = index === highlightedIndex;
866
+ return (
867
+ <div
868
+ key={option.value}
869
+ onClick={() => handleOptionToggle(option.value)}
870
+ onMouseEnter={() => setHighlightedIndex(index)}
871
+ style={{
872
+ display: 'flex',
873
+ alignItems: 'center',
874
+ gap: '0.5rem',
875
+ padding: config.padding,
876
+ cursor: 'pointer',
877
+ fontSize: config.fontSize,
878
+ background: isHighlighted ? 'var(--bg-secondary)' : 'transparent',
879
+ transition: 'background 0.15s ease',
880
+ }}
881
+ >
882
+ {isMulti && (
883
+ <input
884
+ type="checkbox"
885
+ checked={isSelected}
886
+ onChange={() => {}}
887
+ style={{
888
+ width: config.checkboxSize,
889
+ height: config.checkboxSize,
890
+ accentColor: 'var(--primary)',
891
+ cursor: 'pointer',
892
+ }}
893
+ />
894
+ )}
895
+ <span style={{
896
+ color: !isMulti && isSelected ? 'var(--primary)' : 'var(--text-primary)',
897
+ flex: 1,
898
+ fontWeight: !isMulti && isSelected ? 600 : 400,
899
+ }}>
900
+ {option.label}
901
+ </span>
902
+ {!isMulti && isSelected && (
903
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2">
904
+ <polyline points="20 6 9 17 4 12" />
905
+ </svg>
906
+ )}
907
+ </div>
908
+ );
909
+ })
910
+ )}
911
+ </div>
912
+ </div>
913
+ )}
914
+ </div>
915
+ );
916
+ };
917
+
918
+ // ============================================================================
919
+ // MAIN UNIFIED COMPONENT
920
+ // ============================================================================
921
+
922
+ /**
923
+ * SyutSelectUnified - A unified select component with multiple variants
924
+ *
925
+ * @example
926
+ * Single select (default variant):
927
+ * ```tsx
928
+ * <SyutSelectUnified
929
+ * options={options}
930
+ * value={selectedValue}
931
+ * onChange={setSelectedValue}
932
+ * />
933
+ * ```
934
+ *
935
+ * @example
936
+ * Multi-select with checkbox variant:
937
+ * ```tsx
938
+ * <SyutSelectUnified
939
+ * isMulti
940
+ * variant="checkbox"
941
+ * options={options}
942
+ * value={selectedValues}
943
+ * onChange={setSelectedValues}
944
+ * showSelectAll
945
+ * />
946
+ * ```
947
+ *
948
+ * @example
949
+ * Creatable select:
950
+ * ```tsx
951
+ * <SyutSelectUnified
952
+ * isCreatable
953
+ * options={options}
954
+ * onCreateOption={(value) => addOption(value)}
955
+ * />
956
+ * ```
957
+ */
958
+ export function SyutSelectUnified({
959
+ id,
960
+ name,
961
+ options = [],
962
+ value,
963
+ onChange,
964
+ placeholder = 'Select...',
965
+ isDisabled = false,
966
+ isLoading = false,
967
+ isMulti = false,
968
+ isSearchable: isSearchableProp = false,
969
+ searchable,
970
+ isClearable = true,
971
+ isCreatable = false,
972
+ onCreateOption,
973
+ formatCreateLabel = (inputValue) => `Create "${inputValue}"`,
974
+ isAsync = false,
975
+ loadOptions,
976
+ defaultOptions = true,
977
+ cacheOptions = true,
978
+ label,
979
+ error,
980
+ helperText,
981
+ required = false,
982
+ className = '',
983
+ size = 'md',
984
+ autoFocus = false,
985
+ menuPlacement = 'auto',
986
+ maxMenuHeight = 300,
987
+ variant = 'default',
988
+ showSelectAll = true,
989
+ selectAllLabel = 'Select All',
990
+ searchPlaceholder = 'Search...',
991
+ noOptionsMessage = 'No options found',
992
+ debounceDelay = 300,
993
+ minSearchLength = 1,
994
+ loadingMessage = 'Loading...',
995
+ minSearchMessage = 'Type to search...',
996
+ style,
997
+ }: SyutSelectUnifiedProps) {
998
+ const config = sizeConfig[size];
999
+
1000
+ // Handle legacy searchable prop
1001
+ const isSearchable = searchable ?? isSearchableProp;
1002
+
1003
+ // Convert value to SelectOption format for react-select
1004
+ const reactSelectValue = useMemo(() => {
1005
+ if (!value) return isMulti ? [] : null;
1006
+ if (isMulti) {
1007
+ const values = Array.isArray(value) ? value : [value];
1008
+ return options.filter(opt => values.includes(opt.value));
1009
+ }
1010
+ return options.find(opt => opt.value === value) || null;
1011
+ }, [value, options, isMulti]);
1012
+
1013
+ // Handle react-select change
1014
+ const handleReactSelectChange = useCallback(
1015
+ (
1016
+ newValue: MultiValue<SelectOption> | SingleValue<SelectOption>,
1017
+ actionMeta: ActionMeta<SelectOption>
1018
+ ) => {
1019
+ if (isMulti) {
1020
+ const values = (newValue as MultiValue<SelectOption>).map(opt => opt.value);
1021
+ onChange?.(values);
1022
+ } else {
1023
+ onChange?.((newValue as SingleValue<SelectOption>)?.value || null);
1024
+ }
1025
+ },
1026
+ [onChange, isMulti]
1027
+ );
1028
+
1029
+ // Get react-select styles
1030
+ const customStyles = useMemo(
1031
+ () => getReactSelectStyles(size, !!error),
1032
+ [size, error]
1033
+ );
1034
+
1035
+ // Determine which Select component to use
1036
+ const SelectComponent = useMemo(() => {
1037
+ if (isAsync && isCreatable) return AsyncCreatableSelect;
1038
+ if (isAsync) return AsyncSelect;
1039
+ if (isCreatable) return CreatableSelect;
1040
+ return Select;
1041
+ }, [isAsync, isCreatable]);
1042
+
1043
+ // Use checkbox variant for multi-select checkbox style
1044
+ const useCheckboxVariant = variant === 'checkbox';
1045
+
1046
+ return (
1047
+ <div className={`syut-select-container ${className}`} style={style}>
1048
+ {label && (
1049
+ <label className="syut-select-label" htmlFor={id}>
1050
+ {label}
1051
+ {required && <span className="syut-select-required">*</span>}
1052
+ </label>
1053
+ )}
1054
+
1055
+ {useCheckboxVariant ? (
1056
+ <CheckboxVariant
1057
+ id={id}
1058
+ options={options}
1059
+ value={value}
1060
+ onChange={onChange}
1061
+ placeholder={placeholder}
1062
+ isDisabled={isDisabled}
1063
+ isLoading={isLoading}
1064
+ isMulti={isMulti}
1065
+ isAsync={isAsync}
1066
+ loadOptions={loadOptions}
1067
+ defaultOptions={defaultOptions}
1068
+ cacheOptions={cacheOptions}
1069
+ error={error}
1070
+ size={size}
1071
+ showSelectAll={showSelectAll}
1072
+ selectAllLabel={selectAllLabel}
1073
+ maxMenuHeight={maxMenuHeight}
1074
+ searchPlaceholder={searchPlaceholder}
1075
+ noOptionsMessage={noOptionsMessage}
1076
+ debounceDelay={debounceDelay}
1077
+ minSearchLength={minSearchLength}
1078
+ loadingMessage={loadingMessage}
1079
+ minSearchMessage={minSearchMessage}
1080
+ config={config}
1081
+ />
1082
+ ) : (
1083
+ <SelectComponent
1084
+ inputId={id}
1085
+ name={name}
1086
+ options={options}
1087
+ value={reactSelectValue}
1088
+ onChange={handleReactSelectChange}
1089
+ placeholder={placeholder}
1090
+ isDisabled={isDisabled}
1091
+ isLoading={isLoading}
1092
+ isMulti={isMulti}
1093
+ isSearchable={isSearchable}
1094
+ isClearable={isClearable}
1095
+ styles={customStyles}
1096
+ noOptionsMessage={() => noOptionsMessage}
1097
+ autoFocus={autoFocus}
1098
+ menuPlacement={menuPlacement}
1099
+ closeMenuOnSelect={!isMulti}
1100
+ maxMenuHeight={maxMenuHeight}
1101
+ classNamePrefix="syut-select"
1102
+ {...(isCreatable ? { onCreateOption, formatCreateLabel } : {})}
1103
+ {...(isAsync ? { loadOptions, defaultOptions, cacheOptions } : {})}
1104
+ />
1105
+ )}
1106
+
1107
+ {error && <span className="syut-select-error">{error}</span>}
1108
+ {helperText && !error && (
1109
+ <span className="syut-select-helper">{helperText}</span>
1110
+ )}
1111
+ </div>
1112
+ );
1113
+ }
1114
+
1115
+ export default SyutSelectUnified;