@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.
- package/README.md +536 -0
- package/dist/components/ChangePasswordModal.d.ts.map +1 -1
- package/dist/components/ChangePasswordModal.js +22 -11
- package/dist/components/EnterpriseLayout.d.ts.map +1 -1
- package/dist/components/EnterpriseLayout.js +21 -6
- package/dist/components/ui/ActionMenu/ActionMenu.d.ts +52 -0
- package/dist/components/ui/ActionMenu/ActionMenu.d.ts.map +1 -0
- package/dist/components/ui/ActionMenu/ActionMenu.js +116 -0
- package/dist/components/ui/ActionMenu/index.d.ts +3 -0
- package/dist/components/ui/ActionMenu/index.d.ts.map +1 -0
- package/dist/components/ui/ActionMenu/index.js +2 -0
- package/dist/components/ui/ModuleHeader/ModuleHeader.d.ts +90 -0
- package/dist/components/ui/ModuleHeader/ModuleHeader.d.ts.map +1 -0
- package/dist/components/ui/ModuleHeader/ModuleHeader.js +433 -0
- package/dist/components/ui/ModuleHeader/index.d.ts +3 -0
- package/dist/components/ui/ModuleHeader/index.d.ts.map +1 -0
- package/dist/components/ui/ModuleHeader/index.js +1 -0
- package/dist/components/ui/SyutGrid/SyutGrid.d.ts +74 -0
- package/dist/components/ui/SyutGrid/SyutGrid.d.ts.map +1 -0
- package/dist/components/ui/SyutGrid/SyutGrid.js +306 -0
- package/dist/components/ui/SyutGrid/index.d.ts +3 -0
- package/dist/components/ui/SyutGrid/index.d.ts.map +1 -0
- package/dist/components/ui/SyutGrid/index.js +2 -0
- package/dist/components/ui/SyutSelect/SyutSelectUnified.d.ts +128 -0
- package/dist/components/ui/SyutSelect/SyutSelectUnified.d.ts.map +1 -0
- package/dist/components/ui/SyutSelect/SyutSelectUnified.js +679 -0
- package/dist/components/ui/SyutSelect/index.d.ts +3 -0
- package/dist/components/ui/SyutSelect/index.d.ts.map +1 -0
- package/dist/components/ui/SyutSelect/index.js +2 -0
- package/dist/icon-collection/icon-systems.d.ts +89 -0
- package/dist/icon-collection/icon-systems.d.ts.map +1 -0
- package/dist/icon-collection/icon-systems.js +70 -0
- package/dist/icon-collection/index.d.ts +4 -0
- package/dist/icon-collection/index.d.ts.map +1 -0
- package/dist/icon-collection/index.js +8 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -1
- package/package.json +9 -4
- package/src/components/ChangePasswordModal.tsx +26 -14
- package/src/components/EnterpriseLayout.tsx +23 -8
- package/src/components/ui/ActionMenu/ActionMenu.tsx +222 -0
- package/src/components/ui/ActionMenu/index.ts +2 -0
- package/src/components/ui/ModuleHeader/ModuleHeader.tsx +722 -0
- package/src/components/ui/ModuleHeader/index.ts +9 -0
- package/src/components/ui/SyutGrid/SyutGrid.tsx +483 -0
- package/src/components/ui/SyutGrid/index.ts +2 -0
- package/src/components/ui/SyutSelect/SyutSelectUnified.tsx +1115 -0
- package/src/components/ui/SyutSelect/index.ts +3 -0
- package/src/icon-collection/icon-systems.tsx +464 -0
- package/src/icon-collection/index.ts +13 -0
- package/src/index.ts +47 -1
- 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;
|