@papernote/ui 1.3.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ActionBar.d.ts +112 -0
- package/dist/components/ActionBar.d.ts.map +1 -0
- package/dist/components/BottomNavigation.d.ts +98 -0
- package/dist/components/BottomNavigation.d.ts.map +1 -0
- package/dist/components/Checkbox.d.ts +2 -0
- package/dist/components/Checkbox.d.ts.map +1 -1
- package/dist/components/CheckboxList.d.ts +81 -0
- package/dist/components/CheckboxList.d.ts.map +1 -0
- package/dist/components/Chip.d.ts +92 -1
- package/dist/components/Chip.d.ts.map +1 -1
- package/dist/components/ConfirmDialog.d.ts +43 -1
- package/dist/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/components/DataTable.d.ts +10 -1
- package/dist/components/DataTable.d.ts.map +1 -1
- package/dist/components/DataTableCardView.d.ts +99 -0
- package/dist/components/DataTableCardView.d.ts.map +1 -0
- package/dist/components/ExpandablePanel.d.ts +142 -0
- package/dist/components/ExpandablePanel.d.ts.map +1 -0
- package/dist/components/FloatingActionButton.d.ts +98 -0
- package/dist/components/FloatingActionButton.d.ts.map +1 -0
- package/dist/components/Input.d.ts +45 -1
- package/dist/components/Input.d.ts.map +1 -1
- package/dist/components/MobileHeader.d.ts +98 -0
- package/dist/components/MobileHeader.d.ts.map +1 -0
- package/dist/components/MobileLayout.d.ts +121 -0
- package/dist/components/MobileLayout.d.ts.map +1 -0
- package/dist/components/Modal.d.ts +78 -1
- package/dist/components/Modal.d.ts.map +1 -1
- package/dist/components/PageHeader.d.ts +86 -0
- package/dist/components/PageHeader.d.ts.map +1 -0
- package/dist/components/PullToRefresh.d.ts +87 -0
- package/dist/components/PullToRefresh.d.ts.map +1 -0
- package/dist/components/QueryTransparency.d.ts +1 -1
- package/dist/components/QueryTransparency.d.ts.map +1 -1
- package/dist/components/SearchableList.d.ts +83 -0
- package/dist/components/SearchableList.d.ts.map +1 -0
- package/dist/components/Select.d.ts +16 -2
- package/dist/components/Select.d.ts.map +1 -1
- package/dist/components/Sidebar.d.ts +40 -1
- package/dist/components/Sidebar.d.ts.map +1 -1
- package/dist/components/SwipeActions.d.ts +93 -0
- package/dist/components/SwipeActions.d.ts.map +1 -0
- package/dist/components/Switch.d.ts +1 -0
- package/dist/components/Switch.d.ts.map +1 -1
- package/dist/components/Textarea.d.ts +13 -0
- package/dist/components/Textarea.d.ts.map +1 -1
- package/dist/components/index.d.ts +31 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/context/MobileContext.d.ts +168 -0
- package/dist/context/MobileContext.d.ts.map +1 -0
- package/dist/hooks/useResponsive.d.ts +158 -0
- package/dist/hooks/useResponsive.d.ts.map +1 -0
- package/dist/index.d.ts +1871 -51
- package/dist/index.esm.js +3025 -196
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +3063 -194
- package/dist/index.js.map +1 -1
- package/dist/styles.css +434 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/ActionBar.stories.tsx +246 -0
- package/src/components/ActionBar.tsx +242 -0
- package/src/components/BottomNavigation.stories.tsx +142 -0
- package/src/components/BottomNavigation.tsx +225 -0
- package/src/components/Checkbox.stories.tsx +162 -0
- package/src/components/Checkbox.tsx +22 -6
- package/src/components/CheckboxList.stories.tsx +311 -0
- package/src/components/CheckboxList.tsx +433 -0
- package/src/components/Chip.stories.tsx +389 -0
- package/src/components/Chip.tsx +182 -3
- package/src/components/ConfirmDialog.tsx +56 -4
- package/src/components/DataTable.tsx +60 -1
- package/src/components/DataTableCardView.stories.tsx +307 -0
- package/src/components/DataTableCardView.tsx +419 -0
- package/src/components/ExpandablePanel.stories.tsx +620 -0
- package/src/components/ExpandablePanel.tsx +383 -0
- package/src/components/FloatingActionButton.stories.tsx +197 -0
- package/src/components/FloatingActionButton.tsx +301 -0
- package/src/components/Grid.stories.tsx +16 -16
- package/src/components/Input.stories.tsx +214 -0
- package/src/components/Input.tsx +81 -4
- package/src/components/MobileHeader.stories.tsx +205 -0
- package/src/components/MobileHeader.tsx +233 -0
- package/src/components/MobileLayout.stories.tsx +338 -0
- package/src/components/MobileLayout.tsx +313 -0
- package/src/components/Modal.stories.tsx +388 -0
- package/src/components/Modal.tsx +122 -4
- package/src/components/PageHeader.stories.tsx +198 -0
- package/src/components/PageHeader.tsx +217 -0
- package/src/components/PullToRefresh.stories.tsx +321 -0
- package/src/components/PullToRefresh.tsx +294 -0
- package/src/components/QueryTransparency.tsx +1 -1
- package/src/components/SearchableList.stories.tsx +437 -0
- package/src/components/SearchableList.tsx +326 -0
- package/src/components/Select.stories.tsx +190 -0
- package/src/components/Select.tsx +353 -137
- package/src/components/Sidebar.tsx +193 -10
- package/src/components/SwipeActions.stories.tsx +327 -0
- package/src/components/SwipeActions.tsx +387 -0
- package/src/components/Switch.stories.tsx +158 -0
- package/src/components/Switch.tsx +12 -3
- package/src/components/Textarea.tsx +31 -1
- package/src/components/index.ts +69 -3
- package/src/context/MobileContext.tsx +296 -0
- package/src/hooks/useResponsive.ts +360 -0
- package/src/types/index.ts +4 -0
- package/tailwind.config.js +56 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle, useId } from 'react';
|
|
2
2
|
import { Check, ChevronDown, Search, Loader2, X } from 'lucide-react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { useIsMobile } from '../hooks/useResponsive';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Single option in a select dropdown
|
|
@@ -77,13 +79,31 @@ export interface SelectProps {
|
|
|
77
79
|
virtualHeight?: string;
|
|
78
80
|
/** Height of each option row in pixels (default: 42) */
|
|
79
81
|
virtualItemHeight?: number;
|
|
82
|
+
/** Size of the select trigger - 'lg' provides 44px touch-friendly target */
|
|
83
|
+
size?: 'sm' | 'md' | 'lg';
|
|
84
|
+
/** Mobile display mode - 'auto' uses BottomSheet on mobile, 'dropdown' always uses dropdown, 'native' uses native select on mobile */
|
|
85
|
+
mobileMode?: 'auto' | 'dropdown' | 'native';
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
// Size classes for trigger button
|
|
89
|
+
const sizeClasses = {
|
|
90
|
+
sm: 'h-8 text-sm py-1',
|
|
91
|
+
md: 'h-10 text-base py-2',
|
|
92
|
+
lg: 'h-12 text-base py-3 min-h-touch', // 44px touch target
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Size classes for options
|
|
96
|
+
const optionSizeClasses = {
|
|
97
|
+
sm: 'py-2 text-sm',
|
|
98
|
+
md: 'py-2.5 text-sm',
|
|
99
|
+
lg: 'py-3.5 text-base min-h-touch', // 44px touch target for mobile
|
|
100
|
+
};
|
|
101
|
+
|
|
82
102
|
/**
|
|
83
|
-
* Select - Dropdown select component with search, groups, and
|
|
103
|
+
* Select - Dropdown select component with search, groups, virtual scrolling, and mobile support
|
|
84
104
|
*
|
|
85
105
|
* A feature-rich select component supporting flat or grouped options, search/filter,
|
|
86
|
-
* option creation, virtual scrolling for large lists, and
|
|
106
|
+
* option creation, virtual scrolling for large lists, and mobile-optimized BottomSheet display.
|
|
87
107
|
*
|
|
88
108
|
* @example Basic select
|
|
89
109
|
* ```tsx
|
|
@@ -101,6 +121,16 @@ export interface SelectProps {
|
|
|
101
121
|
* />
|
|
102
122
|
* ```
|
|
103
123
|
*
|
|
124
|
+
* @example Mobile-optimized with large touch targets
|
|
125
|
+
* ```tsx
|
|
126
|
+
* <Select
|
|
127
|
+
* options={options}
|
|
128
|
+
* size="lg"
|
|
129
|
+
* mobileMode="auto"
|
|
130
|
+
* placeholder="Select..."
|
|
131
|
+
* />
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
104
134
|
* @example Searchable with groups
|
|
105
135
|
* ```tsx
|
|
106
136
|
* const groups = [
|
|
@@ -159,6 +189,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
159
189
|
virtualized = false,
|
|
160
190
|
virtualHeight = '300px',
|
|
161
191
|
virtualItemHeight = 42,
|
|
192
|
+
size = 'md',
|
|
193
|
+
mobileMode = 'auto',
|
|
162
194
|
} = props;
|
|
163
195
|
const [isOpen, setIsOpen] = useState(false);
|
|
164
196
|
const [searchQuery, setSearchQuery] = useState('');
|
|
@@ -167,7 +199,17 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
167
199
|
const selectRef = useRef<HTMLDivElement>(null);
|
|
168
200
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
169
201
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
202
|
+
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
|
170
203
|
const listRef = useRef<HTMLDivElement>(null);
|
|
204
|
+
const nativeSelectRef = useRef<HTMLSelectElement>(null);
|
|
205
|
+
|
|
206
|
+
// Detect mobile viewport
|
|
207
|
+
const isMobile = useIsMobile();
|
|
208
|
+
const useMobileSheet = mobileMode === 'auto' && isMobile;
|
|
209
|
+
const useNativeSelect = mobileMode === 'native' && isMobile;
|
|
210
|
+
|
|
211
|
+
// Auto-size for mobile
|
|
212
|
+
const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
|
|
171
213
|
|
|
172
214
|
// Generate unique IDs for ARIA
|
|
173
215
|
const labelId = useId();
|
|
@@ -268,8 +310,10 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
268
310
|
setIsOpen(false);
|
|
269
311
|
};
|
|
270
312
|
|
|
271
|
-
// Handle click outside
|
|
313
|
+
// Handle click outside (desktop dropdown only)
|
|
272
314
|
useEffect(() => {
|
|
315
|
+
if (useMobileSheet) return; // Mobile sheet handles its own closing
|
|
316
|
+
|
|
273
317
|
const handleClickOutside = (event: MouseEvent) => {
|
|
274
318
|
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
|
275
319
|
setIsOpen(false);
|
|
@@ -284,14 +328,46 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
284
328
|
return () => {
|
|
285
329
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
286
330
|
};
|
|
287
|
-
}, [isOpen]);
|
|
331
|
+
}, [isOpen, useMobileSheet]);
|
|
288
332
|
|
|
289
333
|
// Focus search input when opened
|
|
290
334
|
useEffect(() => {
|
|
291
|
-
if (isOpen && searchable
|
|
292
|
-
|
|
335
|
+
if (isOpen && searchable) {
|
|
336
|
+
if (useMobileSheet && mobileSearchInputRef.current) {
|
|
337
|
+
// Slight delay for mobile sheet animation
|
|
338
|
+
setTimeout(() => mobileSearchInputRef.current?.focus(), 100);
|
|
339
|
+
} else if (searchInputRef.current) {
|
|
340
|
+
searchInputRef.current.focus();
|
|
341
|
+
}
|
|
293
342
|
}
|
|
294
|
-
}, [isOpen, searchable]);
|
|
343
|
+
}, [isOpen, searchable, useMobileSheet]);
|
|
344
|
+
|
|
345
|
+
// Lock body scroll when mobile sheet is open
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
if (useMobileSheet && isOpen) {
|
|
348
|
+
document.body.style.overflow = 'hidden';
|
|
349
|
+
} else {
|
|
350
|
+
document.body.style.overflow = '';
|
|
351
|
+
}
|
|
352
|
+
return () => {
|
|
353
|
+
document.body.style.overflow = '';
|
|
354
|
+
};
|
|
355
|
+
}, [isOpen, useMobileSheet]);
|
|
356
|
+
|
|
357
|
+
// Handle escape key for mobile sheet
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!useMobileSheet || !isOpen) return;
|
|
360
|
+
|
|
361
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
362
|
+
if (e.key === 'Escape') {
|
|
363
|
+
setIsOpen(false);
|
|
364
|
+
setSearchQuery('');
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
document.addEventListener('keydown', handleEscape);
|
|
369
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
370
|
+
}, [isOpen, useMobileSheet]);
|
|
295
371
|
|
|
296
372
|
const handleSelect = (optionValue: string) => {
|
|
297
373
|
onChange?.(optionValue);
|
|
@@ -299,6 +375,190 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
299
375
|
setSearchQuery('');
|
|
300
376
|
};
|
|
301
377
|
|
|
378
|
+
const handleClose = () => {
|
|
379
|
+
setIsOpen(false);
|
|
380
|
+
setSearchQuery('');
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Render option button (shared between desktop and mobile)
|
|
384
|
+
const renderOption = (option: SelectOption, isSelected: boolean, mobile = false) => (
|
|
385
|
+
<button
|
|
386
|
+
key={option.value}
|
|
387
|
+
type="button"
|
|
388
|
+
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
389
|
+
disabled={option.disabled}
|
|
390
|
+
className={`
|
|
391
|
+
w-full flex items-center justify-between px-4 transition-colors
|
|
392
|
+
${mobile ? optionSizeClasses.lg : optionSizeClasses[effectiveSize]}
|
|
393
|
+
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
394
|
+
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 active:bg-paper-100 cursor-pointer'}
|
|
395
|
+
`}
|
|
396
|
+
role="option"
|
|
397
|
+
aria-selected={isSelected}
|
|
398
|
+
>
|
|
399
|
+
<span className="flex items-center gap-2">
|
|
400
|
+
{option.icon && <span>{option.icon}</span>}
|
|
401
|
+
{option.label}
|
|
402
|
+
</span>
|
|
403
|
+
{isSelected && <Check className={`${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600`} />}
|
|
404
|
+
</button>
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Render options list content (shared between desktop and mobile)
|
|
408
|
+
const renderOptionsContent = (mobile = false) => {
|
|
409
|
+
if (loading) {
|
|
410
|
+
return (
|
|
411
|
+
<div className="px-4 py-8 flex items-center justify-center" role="status" aria-live="polite">
|
|
412
|
+
<Loader2 className="h-5 w-5 animate-spin text-ink-500" />
|
|
413
|
+
<span className="ml-2 text-sm text-ink-500">Loading...</span>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (filteredOptions.length === 0 && filteredGroups.length === 0 && !showCreateOption) {
|
|
419
|
+
return (
|
|
420
|
+
<div className="px-4 py-3 text-sm text-ink-500 text-center" role="status" aria-live="polite">
|
|
421
|
+
No options found
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<>
|
|
428
|
+
{/* Create new option */}
|
|
429
|
+
{showCreateOption && (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onClick={handleCreateOption}
|
|
433
|
+
className={`
|
|
434
|
+
w-full flex items-center px-4 text-accent-700 hover:bg-accent-50 transition-colors border-b border-paper-200
|
|
435
|
+
${mobile ? 'py-3.5 text-base' : 'py-2.5 text-sm'}
|
|
436
|
+
`}
|
|
437
|
+
>
|
|
438
|
+
<span className="font-medium">Create "{searchQuery}"</span>
|
|
439
|
+
</button>
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{/* Virtual scrolling container */}
|
|
443
|
+
{useVirtualScrolling ? (
|
|
444
|
+
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
445
|
+
<div style={{ transform: `translateY(${offsetY}px)` }}>
|
|
446
|
+
{visibleItems.map((item) => {
|
|
447
|
+
const option = item.option;
|
|
448
|
+
const isSelected = option.value === value;
|
|
449
|
+
const key = `${item.type}-${item.groupIndex}-${item.optionIndex}-${option.value}`;
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<button
|
|
453
|
+
key={key}
|
|
454
|
+
type="button"
|
|
455
|
+
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
456
|
+
disabled={option.disabled}
|
|
457
|
+
style={{ height: mobile ? '56px' : `${virtualItemHeight}px` }}
|
|
458
|
+
className={`
|
|
459
|
+
w-full flex items-center justify-between px-4 transition-colors
|
|
460
|
+
${mobile ? 'text-base' : 'text-sm'}
|
|
461
|
+
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
462
|
+
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
463
|
+
`}
|
|
464
|
+
role="option"
|
|
465
|
+
aria-selected={isSelected}
|
|
466
|
+
>
|
|
467
|
+
<span className="flex items-center gap-2">
|
|
468
|
+
{option.icon && <span>{option.icon}</span>}
|
|
469
|
+
{option.label}
|
|
470
|
+
</span>
|
|
471
|
+
{isSelected && <Check className={`${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600`} />}
|
|
472
|
+
</button>
|
|
473
|
+
);
|
|
474
|
+
})}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
) : (
|
|
478
|
+
<>
|
|
479
|
+
{/* Render flat options */}
|
|
480
|
+
{filteredOptions.map((option) => renderOption(option, option.value === value, mobile))}
|
|
481
|
+
|
|
482
|
+
{/* Render grouped options */}
|
|
483
|
+
{filteredGroups.map((group) => (
|
|
484
|
+
<div key={group.label}>
|
|
485
|
+
{/* Group Header */}
|
|
486
|
+
<div className={`
|
|
487
|
+
px-4 font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200
|
|
488
|
+
${mobile ? 'py-2.5 text-xs' : 'py-2 text-xs'}
|
|
489
|
+
`}>
|
|
490
|
+
{group.label}
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
{/* Group Options */}
|
|
494
|
+
{group.options.map((option) => renderOption(option, option.value === value, mobile))}
|
|
495
|
+
</div>
|
|
496
|
+
))}
|
|
497
|
+
</>
|
|
498
|
+
)}
|
|
499
|
+
</>
|
|
500
|
+
);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// Native select for mobile (optional)
|
|
504
|
+
if (useNativeSelect) {
|
|
505
|
+
return (
|
|
506
|
+
<div className="w-full">
|
|
507
|
+
{label && (
|
|
508
|
+
<label id={labelId} className="label">
|
|
509
|
+
{label}
|
|
510
|
+
</label>
|
|
511
|
+
)}
|
|
512
|
+
|
|
513
|
+
<div className="relative">
|
|
514
|
+
<select
|
|
515
|
+
ref={nativeSelectRef}
|
|
516
|
+
value={value || ''}
|
|
517
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
518
|
+
disabled={disabled}
|
|
519
|
+
className={`
|
|
520
|
+
input w-full appearance-none pr-10
|
|
521
|
+
${sizeClasses[effectiveSize]}
|
|
522
|
+
${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
|
|
523
|
+
${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
|
524
|
+
`}
|
|
525
|
+
aria-labelledby={label ? labelId : undefined}
|
|
526
|
+
aria-invalid={error ? 'true' : undefined}
|
|
527
|
+
aria-describedby={error ? errorId : (helperText ? helperTextId : undefined)}
|
|
528
|
+
>
|
|
529
|
+
<option value="" disabled>{placeholder}</option>
|
|
530
|
+
{options.map((opt) => (
|
|
531
|
+
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
532
|
+
{opt.label}
|
|
533
|
+
</option>
|
|
534
|
+
))}
|
|
535
|
+
{groups.map((group) => (
|
|
536
|
+
<optgroup key={group.label} label={group.label}>
|
|
537
|
+
{group.options.map((opt) => (
|
|
538
|
+
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
539
|
+
{opt.label}
|
|
540
|
+
</option>
|
|
541
|
+
))}
|
|
542
|
+
</optgroup>
|
|
543
|
+
))}
|
|
544
|
+
</select>
|
|
545
|
+
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-500 pointer-events-none" />
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
{error && (
|
|
549
|
+
<p id={errorId} className="mt-2 text-xs text-error-600" role="alert" aria-live="assertive">
|
|
550
|
+
{error}
|
|
551
|
+
</p>
|
|
552
|
+
)}
|
|
553
|
+
{helperText && !error && (
|
|
554
|
+
<p id={helperTextId} className="mt-2 text-xs text-ink-600">
|
|
555
|
+
{helperText}
|
|
556
|
+
</p>
|
|
557
|
+
)}
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
302
562
|
return (
|
|
303
563
|
<div className="w-full">
|
|
304
564
|
{/* Label */}
|
|
@@ -317,7 +577,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
317
577
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
|
318
578
|
disabled={disabled}
|
|
319
579
|
className={`
|
|
320
|
-
input w-full flex items-center justify-between
|
|
580
|
+
input w-full flex items-center justify-between px-3
|
|
581
|
+
${sizeClasses[effectiveSize]}
|
|
321
582
|
${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
|
|
322
583
|
${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
|
|
323
584
|
`}
|
|
@@ -349,15 +610,15 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
349
610
|
className="text-ink-400 hover:text-ink-600 transition-colors p-0.5"
|
|
350
611
|
aria-label="Clear selection"
|
|
351
612
|
>
|
|
352
|
-
<X className=
|
|
613
|
+
<X className={`${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'}`} />
|
|
353
614
|
</button>
|
|
354
615
|
)}
|
|
355
|
-
<ChevronDown className={
|
|
616
|
+
<ChevronDown className={`${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'} text-ink-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
356
617
|
</div>
|
|
357
618
|
</button>
|
|
358
619
|
|
|
359
|
-
{/* Dropdown */}
|
|
360
|
-
{isOpen && (
|
|
620
|
+
{/* Desktop Dropdown */}
|
|
621
|
+
{isOpen && !useMobileSheet && (
|
|
361
622
|
<div className="absolute z-50 w-full mt-2 bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in">
|
|
362
623
|
{/* Search Input */}
|
|
363
624
|
{searchable && (
|
|
@@ -391,136 +652,91 @@ const Select = forwardRef<SelectHandle, SelectProps>(
|
|
|
391
652
|
aria-label="Available options"
|
|
392
653
|
aria-multiselectable="false"
|
|
393
654
|
>
|
|
394
|
-
{
|
|
395
|
-
<div className="px-4 py-8 flex items-center justify-center" role="status" aria-live="polite">
|
|
396
|
-
<Loader2 className="h-5 w-5 animate-spin text-ink-500" />
|
|
397
|
-
<span className="ml-2 text-sm text-ink-500">Loading...</span>
|
|
398
|
-
</div>
|
|
399
|
-
) : filteredOptions.length === 0 && filteredGroups.length === 0 && !showCreateOption ? (
|
|
400
|
-
<div className="px-4 py-3 text-sm text-ink-500 text-center" role="status" aria-live="polite">
|
|
401
|
-
No options found
|
|
402
|
-
</div>
|
|
403
|
-
) : (
|
|
404
|
-
<>
|
|
405
|
-
{/* Create new option */}
|
|
406
|
-
{showCreateOption && (
|
|
407
|
-
<button
|
|
408
|
-
type="button"
|
|
409
|
-
onClick={handleCreateOption}
|
|
410
|
-
className="w-full flex items-center px-4 py-2.5 text-sm text-accent-700 hover:bg-accent-50 transition-colors border-b border-paper-200"
|
|
411
|
-
>
|
|
412
|
-
<span className="font-medium">Create "{searchQuery}"</span>
|
|
413
|
-
</button>
|
|
414
|
-
)}
|
|
415
|
-
|
|
416
|
-
{/* Virtual scrolling container */}
|
|
417
|
-
{useVirtualScrolling ? (
|
|
418
|
-
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
419
|
-
<div style={{ transform: `translateY(${offsetY}px)` }}>
|
|
420
|
-
{visibleItems.map((item) => {
|
|
421
|
-
const option = item.option;
|
|
422
|
-
const isSelected = option.value === value;
|
|
423
|
-
const key = `${item.type}-${item.groupIndex}-${item.optionIndex}-${option.value}`;
|
|
424
|
-
|
|
425
|
-
return (
|
|
426
|
-
<button
|
|
427
|
-
key={key}
|
|
428
|
-
type="button"
|
|
429
|
-
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
430
|
-
disabled={option.disabled}
|
|
431
|
-
style={{ height: `${virtualItemHeight}px` }}
|
|
432
|
-
className={`
|
|
433
|
-
w-full flex items-center justify-between px-4 text-sm transition-colors
|
|
434
|
-
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
435
|
-
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
436
|
-
`}
|
|
437
|
-
role="option"
|
|
438
|
-
aria-selected={isSelected}
|
|
439
|
-
>
|
|
440
|
-
<span className="flex items-center gap-2">
|
|
441
|
-
{option.icon && <span>{option.icon}</span>}
|
|
442
|
-
{option.label}
|
|
443
|
-
</span>
|
|
444
|
-
{isSelected && <Check className="h-4 w-4 text-accent-600" />}
|
|
445
|
-
</button>
|
|
446
|
-
);
|
|
447
|
-
})}
|
|
448
|
-
</div>
|
|
449
|
-
</div>
|
|
450
|
-
) : (
|
|
451
|
-
<>
|
|
452
|
-
{/* Render flat options */}
|
|
453
|
-
{filteredOptions.map((option) => {
|
|
454
|
-
const isSelected = option.value === value;
|
|
455
|
-
|
|
456
|
-
return (
|
|
457
|
-
<button
|
|
458
|
-
key={option.value}
|
|
459
|
-
type="button"
|
|
460
|
-
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
461
|
-
disabled={option.disabled}
|
|
462
|
-
className={`
|
|
463
|
-
w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors
|
|
464
|
-
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
465
|
-
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
466
|
-
`}
|
|
467
|
-
role="option"
|
|
468
|
-
aria-selected={isSelected}
|
|
469
|
-
>
|
|
470
|
-
<span className="flex items-center gap-2">
|
|
471
|
-
{option.icon && <span>{option.icon}</span>}
|
|
472
|
-
{option.label}
|
|
473
|
-
</span>
|
|
474
|
-
{isSelected && <Check className="h-4 w-4 text-accent-600" />}
|
|
475
|
-
</button>
|
|
476
|
-
);
|
|
477
|
-
})}
|
|
478
|
-
|
|
479
|
-
{/* Render grouped options */}
|
|
480
|
-
{filteredGroups.map((group) => (
|
|
481
|
-
<div key={group.label}>
|
|
482
|
-
{/* Group Header */}
|
|
483
|
-
<div className="px-4 py-2 text-xs font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200">
|
|
484
|
-
{group.label}
|
|
485
|
-
</div>
|
|
486
|
-
|
|
487
|
-
{/* Group Options */}
|
|
488
|
-
{group.options.map((option) => {
|
|
489
|
-
const isSelected = option.value === value;
|
|
490
|
-
|
|
491
|
-
return (
|
|
492
|
-
<button
|
|
493
|
-
key={option.value}
|
|
494
|
-
type="button"
|
|
495
|
-
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
496
|
-
disabled={option.disabled}
|
|
497
|
-
className={`
|
|
498
|
-
w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors
|
|
499
|
-
${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
|
|
500
|
-
${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
|
|
501
|
-
`}
|
|
502
|
-
role="option"
|
|
503
|
-
aria-selected={isSelected}
|
|
504
|
-
>
|
|
505
|
-
<span className="flex items-center gap-2">
|
|
506
|
-
{option.icon && <span>{option.icon}</span>}
|
|
507
|
-
{option.label}
|
|
508
|
-
</span>
|
|
509
|
-
{isSelected && <Check className="h-4 w-4 text-accent-600" />}
|
|
510
|
-
</button>
|
|
511
|
-
);
|
|
512
|
-
})}
|
|
513
|
-
</div>
|
|
514
|
-
))}
|
|
515
|
-
</>
|
|
516
|
-
)}
|
|
517
|
-
</>
|
|
518
|
-
)}
|
|
655
|
+
{renderOptionsContent(false)}
|
|
519
656
|
</div>
|
|
520
657
|
</div>
|
|
521
658
|
)}
|
|
522
659
|
</div>
|
|
523
660
|
|
|
661
|
+
{/* Mobile Bottom Sheet */}
|
|
662
|
+
{isOpen && useMobileSheet && createPortal(
|
|
663
|
+
<div
|
|
664
|
+
className="fixed inset-0 z-50 flex items-end"
|
|
665
|
+
onClick={(e) => e.target === e.currentTarget && handleClose()}
|
|
666
|
+
role="dialog"
|
|
667
|
+
aria-modal="true"
|
|
668
|
+
aria-labelledby={label ? `mobile-${labelId}` : undefined}
|
|
669
|
+
>
|
|
670
|
+
{/* Backdrop */}
|
|
671
|
+
<div className="absolute inset-0 bg-black/50 animate-fade-in" />
|
|
672
|
+
|
|
673
|
+
{/* Sheet */}
|
|
674
|
+
<div
|
|
675
|
+
className="relative w-full bg-white rounded-t-2xl shadow-2xl animate-slide-up max-h-[85vh] flex flex-col"
|
|
676
|
+
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
|
677
|
+
>
|
|
678
|
+
{/* Handle */}
|
|
679
|
+
<div className="py-3 cursor-grab">
|
|
680
|
+
<div className="w-12 h-1.5 bg-ink-300 rounded-full mx-auto" />
|
|
681
|
+
</div>
|
|
682
|
+
|
|
683
|
+
{/* Header */}
|
|
684
|
+
<div className="px-4 pb-3 border-b border-paper-200 flex items-center justify-between">
|
|
685
|
+
{label && (
|
|
686
|
+
<h2 id={`mobile-${labelId}`} className="text-lg font-semibold text-ink-900">
|
|
687
|
+
{label}
|
|
688
|
+
</h2>
|
|
689
|
+
)}
|
|
690
|
+
{!label && (
|
|
691
|
+
<h2 className="text-lg font-semibold text-ink-900">
|
|
692
|
+
{placeholder}
|
|
693
|
+
</h2>
|
|
694
|
+
)}
|
|
695
|
+
<button
|
|
696
|
+
onClick={handleClose}
|
|
697
|
+
className="text-ink-400 hover:text-ink-600 transition-colors p-2 -mr-2"
|
|
698
|
+
aria-label="Close"
|
|
699
|
+
>
|
|
700
|
+
<X className="h-5 w-5" />
|
|
701
|
+
</button>
|
|
702
|
+
</div>
|
|
703
|
+
|
|
704
|
+
{/* Search Input (Mobile) */}
|
|
705
|
+
{searchable && (
|
|
706
|
+
<div className="p-3 border-b border-paper-200">
|
|
707
|
+
<div className="relative">
|
|
708
|
+
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-400" />
|
|
709
|
+
<input
|
|
710
|
+
ref={mobileSearchInputRef}
|
|
711
|
+
type="text"
|
|
712
|
+
value={searchQuery}
|
|
713
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
714
|
+
placeholder="Search..."
|
|
715
|
+
inputMode="search"
|
|
716
|
+
enterKeyHint="search"
|
|
717
|
+
className="w-full pl-12 pr-4 py-3 text-base border border-paper-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400"
|
|
718
|
+
role="searchbox"
|
|
719
|
+
aria-label="Search options"
|
|
720
|
+
/>
|
|
721
|
+
</div>
|
|
722
|
+
</div>
|
|
723
|
+
)}
|
|
724
|
+
|
|
725
|
+
{/* Options List (Mobile) */}
|
|
726
|
+
<div
|
|
727
|
+
id={listboxId}
|
|
728
|
+
className="overflow-y-auto flex-1"
|
|
729
|
+
role="listbox"
|
|
730
|
+
aria-label="Available options"
|
|
731
|
+
aria-multiselectable="false"
|
|
732
|
+
>
|
|
733
|
+
{renderOptionsContent(true)}
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
</div>,
|
|
737
|
+
document.body
|
|
738
|
+
)}
|
|
739
|
+
|
|
524
740
|
{/* Helper Text or Error */}
|
|
525
741
|
{error && (
|
|
526
742
|
<p id={errorId} className="mt-2 text-xs text-error-600" role="alert" aria-live="assertive">
|