@idealyst/components 1.0.82 → 1.0.83

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.
@@ -0,0 +1,436 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ // @ts-ignore - web-specific import
4
+ import { getWebProps } from 'react-native-unistyles/web';
5
+ import { SelectProps, SelectOption } from './types';
6
+ import { selectStyles } from './Select.styles';
7
+
8
+ const Select: React.FC<SelectProps> = ({
9
+ options,
10
+ value,
11
+ onValueChange,
12
+ placeholder = 'Select an option',
13
+ disabled = false,
14
+ error = false,
15
+ helperText,
16
+ label,
17
+ variant = 'outlined',
18
+ intent = 'neutral',
19
+ size = 'medium',
20
+ searchable = false,
21
+ filterOption,
22
+ maxHeight = 240,
23
+ style,
24
+ testID,
25
+ accessibilityLabel,
26
+ }) => {
27
+ const [isOpen, setIsOpen] = useState(false);
28
+ const [searchTerm, setSearchTerm] = useState('');
29
+ const [focusedIndex, setFocusedIndex] = useState(-1);
30
+ const triggerRef = useRef<HTMLButtonElement>(null);
31
+ const dropdownRef = useRef<HTMLDivElement>(null);
32
+ const searchInputRef = useRef<HTMLInputElement>(null);
33
+
34
+ // Debug: Log when trigger ref is set
35
+ const setTriggerRef = (el: HTMLButtonElement | null) => {
36
+ console.log('Setting trigger ref to:', el);
37
+ triggerRef.current = el;
38
+ };
39
+
40
+ const selectedOption = options.find(option => option.value === value);
41
+
42
+ // Filter options based on search term
43
+ const filteredOptions = searchable && searchTerm
44
+ ? options.filter(option => {
45
+ if (filterOption) {
46
+ return filterOption(option, searchTerm);
47
+ }
48
+ return option.label.toLowerCase().includes(searchTerm.toLowerCase());
49
+ })
50
+ : options;
51
+
52
+ // Apply styles with variants
53
+ selectStyles.useVariants({
54
+ variant: variant as any,
55
+ size,
56
+ intent,
57
+ disabled,
58
+ error,
59
+ focused: isOpen,
60
+ });
61
+
62
+ // Position dropdown when it opens
63
+ useEffect(() => {
64
+ if (!isOpen) return;
65
+
66
+ let retryCount = 0;
67
+ const maxRetries = 10;
68
+
69
+ const positionDropdown = () => {
70
+ if (!triggerRef.current || !dropdownRef.current) {
71
+ console.log(`[Attempt ${retryCount + 1}/${maxRetries}] Refs not ready:`, {
72
+ trigger: !!triggerRef.current,
73
+ dropdown: !!dropdownRef.current
74
+ });
75
+
76
+ if (retryCount < maxRetries) {
77
+ retryCount++;
78
+ setTimeout(positionDropdown, 10);
79
+ }
80
+ return;
81
+ }
82
+
83
+ const trigger = triggerRef.current;
84
+ const dropdown = dropdownRef.current;
85
+ const triggerRect = trigger.getBoundingClientRect();
86
+
87
+ console.log('Trigger button found at:', {
88
+ top: triggerRect.top,
89
+ left: triggerRect.left,
90
+ bottom: triggerRect.bottom,
91
+ right: triggerRect.right,
92
+ width: triggerRect.width,
93
+ height: triggerRect.height
94
+ });
95
+
96
+ console.log('Dropdown initial position:', {
97
+ currentTop: dropdown.style.top,
98
+ currentLeft: dropdown.style.left,
99
+ offsetHeight: dropdown.offsetHeight,
100
+ offsetWidth: dropdown.offsetWidth
101
+ });
102
+
103
+ // Calculate and set position
104
+ const top = triggerRect.bottom + 4;
105
+ const left = triggerRect.left;
106
+ const width = triggerRect.width;
107
+
108
+ dropdown.style.position = 'fixed';
109
+ dropdown.style.top = `${top}px`;
110
+ dropdown.style.left = `${left}px`;
111
+ dropdown.style.width = `${width}px`;
112
+ dropdown.style.maxHeight = `${maxHeight}px`;
113
+
114
+ console.log('Dropdown NEW position set to:', {
115
+ top: `${top}px`,
116
+ left: `${left}px`,
117
+ width: `${width}px`,
118
+ actualTop: dropdown.style.top,
119
+ actualLeft: dropdown.style.left,
120
+ actualWidth: dropdown.style.width
121
+ });
122
+
123
+ // Verify position was applied
124
+ const dropdownRect = dropdown.getBoundingClientRect();
125
+ console.log('Dropdown actual position after setting:', {
126
+ top: dropdownRect.top,
127
+ left: dropdownRect.left,
128
+ width: dropdownRect.width,
129
+ height: dropdownRect.height
130
+ });
131
+ };
132
+
133
+ // Start positioning attempts
134
+ positionDropdown();
135
+
136
+ // Reposition on scroll/resize
137
+ const handleReposition = () => {
138
+ retryCount = 0;
139
+ positionDropdown();
140
+ };
141
+
142
+ window.addEventListener('scroll', handleReposition, true);
143
+ window.addEventListener('resize', handleReposition);
144
+
145
+ return () => {
146
+ window.removeEventListener('scroll', handleReposition, true);
147
+ window.removeEventListener('resize', handleReposition);
148
+ };
149
+ }, [isOpen, maxHeight]);
150
+
151
+ // Close dropdown when clicking outside
152
+ useEffect(() => {
153
+ if (!isOpen) return;
154
+
155
+ const handleClickOutside = (event: MouseEvent) => {
156
+ const target = event.target as Node;
157
+
158
+ // Check if click is outside both trigger and dropdown
159
+ if (
160
+ triggerRef.current && !triggerRef.current.contains(target) &&
161
+ dropdownRef.current && !dropdownRef.current.contains(target)
162
+ ) {
163
+ setIsOpen(false);
164
+ }
165
+ };
166
+
167
+ // Use capture phase for better event handling
168
+ document.addEventListener('mousedown', handleClickOutside, true);
169
+
170
+ return () => {
171
+ document.removeEventListener('mousedown', handleClickOutside, true);
172
+ };
173
+ }, [isOpen]);
174
+
175
+ // Handle keyboard navigation
176
+ useEffect(() => {
177
+ if (!isOpen) return;
178
+
179
+ const handleKeyDown = (event: KeyboardEvent) => {
180
+ switch (event.key) {
181
+ case 'Escape':
182
+ setIsOpen(false);
183
+ triggerRef.current?.focus();
184
+ break;
185
+ case 'ArrowDown':
186
+ event.preventDefault();
187
+ setFocusedIndex(prev =>
188
+ prev < filteredOptions.length - 1 ? prev + 1 : 0
189
+ );
190
+ break;
191
+ case 'ArrowUp':
192
+ event.preventDefault();
193
+ setFocusedIndex(prev =>
194
+ prev > 0 ? prev - 1 : filteredOptions.length - 1
195
+ );
196
+ break;
197
+ case 'Enter':
198
+ case ' ':
199
+ event.preventDefault();
200
+ if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) {
201
+ const option = filteredOptions[focusedIndex];
202
+ if (!option.disabled) {
203
+ handleOptionSelect(option);
204
+ }
205
+ }
206
+ break;
207
+ }
208
+ };
209
+
210
+ document.addEventListener('keydown', handleKeyDown);
211
+ return () => {
212
+ document.removeEventListener('keydown', handleKeyDown);
213
+ };
214
+ }, [isOpen, focusedIndex, filteredOptions]);
215
+
216
+ // Focus search input when dropdown opens
217
+ useEffect(() => {
218
+ if (isOpen && searchable && searchInputRef.current) {
219
+ // Delay to ensure dropdown is positioned
220
+ setTimeout(() => {
221
+ searchInputRef.current?.focus();
222
+ }, 50);
223
+ }
224
+ }, [isOpen, searchable]);
225
+
226
+ const handleTriggerClick = () => {
227
+ if (!disabled) {
228
+ setIsOpen(!isOpen);
229
+ setSearchTerm('');
230
+ setFocusedIndex(-1);
231
+ }
232
+ };
233
+
234
+ const handleOptionSelect = (option: SelectOption) => {
235
+ if (!option.disabled) {
236
+ onValueChange(option.value);
237
+ setIsOpen(false);
238
+ setSearchTerm('');
239
+ triggerRef.current?.focus();
240
+ }
241
+ };
242
+
243
+ const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
244
+ setSearchTerm(e.target.value);
245
+ setFocusedIndex(0);
246
+ };
247
+
248
+ const containerWebProps = getWebProps([
249
+ selectStyles.container,
250
+ style
251
+ ]);
252
+
253
+ const triggerWebProps = getWebProps([
254
+ selectStyles.trigger,
255
+ isOpen && selectStyles.triggerOpen
256
+ ]);
257
+
258
+ // MUI-style dropdown portal
259
+ const renderDropdown = () => {
260
+ if (!isOpen) return null;
261
+
262
+ return createPortal(
263
+ <div
264
+ ref={dropdownRef}
265
+ style={{
266
+ position: 'fixed',
267
+ top: '0px', // Explicit initial position
268
+ left: '0px', // Explicit initial position
269
+ opacity: 1,
270
+ zIndex: 1300, // MUI's z-index for select
271
+ backgroundColor: 'white',
272
+ borderRadius: '4px',
273
+ boxShadow: '0px 5px 5px -3px rgba(0,0,0,0.2), 0px 8px 10px 1px rgba(0,0,0,0.14), 0px 3px 14px 2px rgba(0,0,0,0.12)',
274
+ overflow: 'auto',
275
+ minWidth: '200px', // Ensure minimum width
276
+ visibility: 'visible', // Ensure it's visible
277
+ }}
278
+ role="listbox"
279
+ >
280
+ {searchable && (
281
+ <div
282
+ style={{
283
+ padding: '8px 16px',
284
+ borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
285
+ position: 'sticky',
286
+ top: 0,
287
+ backgroundColor: 'white',
288
+ zIndex: 1,
289
+ }}
290
+ >
291
+ <input
292
+ ref={searchInputRef}
293
+ type="text"
294
+ placeholder="Search options..."
295
+ value={searchTerm}
296
+ onChange={handleSearchChange}
297
+ style={{
298
+ width: '100%',
299
+ padding: '8px 12px',
300
+ border: '1px solid rgba(0, 0, 0, 0.23)',
301
+ borderRadius: '4px',
302
+ fontSize: '14px',
303
+ outline: 'none',
304
+ }}
305
+ onFocus={(e) => {
306
+ e.target.style.borderColor = '#1976d2';
307
+ }}
308
+ onBlur={(e) => {
309
+ e.target.style.borderColor = 'rgba(0, 0, 0, 0.23)';
310
+ }}
311
+ />
312
+ </div>
313
+ )}
314
+
315
+ <div style={{ padding: '8px 0' }}>
316
+ {filteredOptions.map((option, index) => {
317
+ const isSelected = option.value === value;
318
+ const isFocused = index === focusedIndex;
319
+
320
+ return (
321
+ <div
322
+ key={option.value}
323
+ onClick={() => handleOptionSelect(option)}
324
+ role="option"
325
+ aria-selected={isSelected}
326
+ onMouseEnter={() => setFocusedIndex(index)}
327
+ style={{
328
+ padding: '6px 16px',
329
+ cursor: option.disabled ? 'default' : 'pointer',
330
+ backgroundColor: isFocused
331
+ ? 'rgba(0, 0, 0, 0.04)'
332
+ : isSelected
333
+ ? 'rgba(25, 118, 210, 0.08)'
334
+ : 'transparent',
335
+ color: option.disabled
336
+ ? 'rgba(0, 0, 0, 0.38)'
337
+ : 'rgba(0, 0, 0, 0.87)',
338
+ fontSize: '14px',
339
+ lineHeight: '1.5',
340
+ transition: 'background-color 150ms cubic-bezier(0.4, 0, 0.2, 1)',
341
+ display: 'flex',
342
+ alignItems: 'center',
343
+ gap: '12px',
344
+ }}
345
+ >
346
+ {option.icon && (
347
+ <span style={{ display: 'flex', alignItems: 'center' }}>
348
+ {option.icon}
349
+ </span>
350
+ )}
351
+ <span>{option.label}</span>
352
+ </div>
353
+ );
354
+ })}
355
+
356
+ {filteredOptions.length === 0 && (
357
+ <div
358
+ style={{
359
+ padding: '6px 16px',
360
+ color: 'rgba(0, 0, 0, 0.54)',
361
+ fontSize: '14px',
362
+ }}
363
+ >
364
+ No options found
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>,
369
+ document.body
370
+ );
371
+ };
372
+
373
+ return (
374
+ <div {...containerWebProps} data-testid={testID}>
375
+ {label && (
376
+ <label {...getWebProps([selectStyles.label])}>
377
+ {label}
378
+ </label>
379
+ )}
380
+
381
+ <button
382
+ ref={setTriggerRef}
383
+ {...triggerWebProps}
384
+ onClick={handleTriggerClick}
385
+ disabled={disabled}
386
+ aria-label={accessibilityLabel || label}
387
+ aria-expanded={isOpen}
388
+ aria-haspopup="listbox"
389
+ type="button"
390
+ >
391
+ <div {...getWebProps([selectStyles.triggerContent])}>
392
+ {selectedOption?.icon && (
393
+ <span {...getWebProps([selectStyles.icon])}>
394
+ {selectedOption.icon}
395
+ </span>
396
+ )}
397
+ <span
398
+ {...getWebProps([
399
+ selectedOption ? selectStyles.triggerText : selectStyles.placeholder
400
+ ])}
401
+ >
402
+ {selectedOption ? selectedOption.label : placeholder}
403
+ </span>
404
+ </div>
405
+
406
+ <svg
407
+ {...getWebProps([
408
+ selectStyles.chevron,
409
+ isOpen && selectStyles.chevronOpen
410
+ ])}
411
+ width="16"
412
+ height="16"
413
+ viewBox="0 0 16 16"
414
+ fill="currentColor"
415
+ >
416
+ <path d="M4.427 9.573l3.396-3.396a.25.25 0 01.354 0l3.396 3.396a.25.25 0 01-.177.427H4.604a.25.25 0 01-.177-.427z" />
417
+ </svg>
418
+ </button>
419
+
420
+ {renderDropdown()}
421
+
422
+ {helperText && (
423
+ <div
424
+ {...getWebProps([
425
+ selectStyles.helperText,
426
+ { error }
427
+ ])}
428
+ >
429
+ {helperText}
430
+ </div>
431
+ )}
432
+ </div>
433
+ );
434
+ };
435
+
436
+ export default Select;
@@ -0,0 +1,2 @@
1
+ export { default } from './Select.native';
2
+ export * from './types';
@@ -0,0 +1,2 @@
1
+ export { default } from './Select.web';
2
+ export * from './types';
@@ -0,0 +1,2 @@
1
+ export { default } from './Select.web';
2
+ export * from './types';
@@ -0,0 +1,118 @@
1
+ import { ReactNode } from 'react';
2
+ import type { IntentVariant } from '../theme/variants';
3
+
4
+ export interface SelectOption {
5
+ /**
6
+ * The unique value for this option
7
+ */
8
+ value: string;
9
+
10
+ /**
11
+ * The display label for this option
12
+ */
13
+ label: string;
14
+
15
+ /**
16
+ * Whether this option is disabled
17
+ */
18
+ disabled?: boolean;
19
+
20
+ /**
21
+ * Optional icon or custom content to display before the label
22
+ */
23
+ icon?: ReactNode;
24
+ }
25
+
26
+ export interface SelectProps {
27
+ /**
28
+ * Array of options to display in the select
29
+ */
30
+ options: SelectOption[];
31
+
32
+ /**
33
+ * The currently selected value
34
+ */
35
+ value?: string;
36
+
37
+ /**
38
+ * Called when the selected value changes
39
+ */
40
+ onValueChange?: (value: string) => void;
41
+
42
+ /**
43
+ * Placeholder text when no value is selected
44
+ */
45
+ placeholder?: string;
46
+
47
+ /**
48
+ * Whether the select is disabled
49
+ */
50
+ disabled?: boolean;
51
+
52
+ /**
53
+ * Whether the select shows an error state
54
+ */
55
+ error?: boolean;
56
+
57
+ /**
58
+ * Helper text to display below the select
59
+ */
60
+ helperText?: string;
61
+
62
+ /**
63
+ * Label text to display above the select
64
+ */
65
+ label?: string;
66
+
67
+ /**
68
+ * The visual variant of the select
69
+ */
70
+ variant?: 'outlined' | 'filled';
71
+
72
+ /**
73
+ * The intent/color scheme of the select
74
+ */
75
+ intent?: IntentVariant;
76
+
77
+ /**
78
+ * The size of the select
79
+ */
80
+ size?: 'small' | 'medium' | 'large';
81
+
82
+ /**
83
+ * Whether to show a search/filter input (web only)
84
+ */
85
+ searchable?: boolean;
86
+
87
+ /**
88
+ * Custom search filter function (used with searchable)
89
+ */
90
+ filterOption?: (option: SelectOption, searchTerm: string) => boolean;
91
+
92
+ /**
93
+ * Native iOS presentation mode (native only)
94
+ * 'dropdown' uses a standard dropdown overlay
95
+ * 'actionSheet' uses iOS ActionSheet for selection
96
+ */
97
+ presentationMode?: 'dropdown' | 'actionSheet';
98
+
99
+ /**
100
+ * Maximum height for the dropdown content
101
+ */
102
+ maxHeight?: number;
103
+
104
+ /**
105
+ * Additional styles (platform-specific)
106
+ */
107
+ style?: any;
108
+
109
+ /**
110
+ * Test ID for testing
111
+ */
112
+ testID?: string;
113
+
114
+ /**
115
+ * Accessibility label
116
+ */
117
+ accessibilityLabel?: string;
118
+ }
@@ -14,6 +14,7 @@ import { ScreenExamples } from './ScreenExamples';
14
14
  import { SVGImageExamples } from './SVGImageExamples';
15
15
  import { DialogExamples } from './DialogExamples';
16
16
  import { PopoverExamples } from './PopoverExamples';
17
+ import { SelectExamples } from './SelectExamples';
17
18
  import { ThemeExtensionExamples } from './ThemeExtensionExamples';
18
19
 
19
20
  export const AllExamples = () => {
@@ -73,6 +74,9 @@ export const AllExamples = () => {
73
74
  <PopoverExamples />
74
75
  <Divider spacing="medium" />
75
76
 
77
+ <SelectExamples />
78
+ <Divider spacing="medium" />
79
+
76
80
  <Divider spacing="large" intent="success">
77
81
  <Text size="small" weight="semibold" color="green">THEME SYSTEM</Text>
78
82
  </Divider>