@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.
Files changed (108) hide show
  1. package/dist/components/ActionBar.d.ts +112 -0
  2. package/dist/components/ActionBar.d.ts.map +1 -0
  3. package/dist/components/BottomNavigation.d.ts +98 -0
  4. package/dist/components/BottomNavigation.d.ts.map +1 -0
  5. package/dist/components/Checkbox.d.ts +2 -0
  6. package/dist/components/Checkbox.d.ts.map +1 -1
  7. package/dist/components/CheckboxList.d.ts +81 -0
  8. package/dist/components/CheckboxList.d.ts.map +1 -0
  9. package/dist/components/Chip.d.ts +92 -1
  10. package/dist/components/Chip.d.ts.map +1 -1
  11. package/dist/components/ConfirmDialog.d.ts +43 -1
  12. package/dist/components/ConfirmDialog.d.ts.map +1 -1
  13. package/dist/components/DataTable.d.ts +10 -1
  14. package/dist/components/DataTable.d.ts.map +1 -1
  15. package/dist/components/DataTableCardView.d.ts +99 -0
  16. package/dist/components/DataTableCardView.d.ts.map +1 -0
  17. package/dist/components/ExpandablePanel.d.ts +142 -0
  18. package/dist/components/ExpandablePanel.d.ts.map +1 -0
  19. package/dist/components/FloatingActionButton.d.ts +98 -0
  20. package/dist/components/FloatingActionButton.d.ts.map +1 -0
  21. package/dist/components/Input.d.ts +45 -1
  22. package/dist/components/Input.d.ts.map +1 -1
  23. package/dist/components/MobileHeader.d.ts +98 -0
  24. package/dist/components/MobileHeader.d.ts.map +1 -0
  25. package/dist/components/MobileLayout.d.ts +121 -0
  26. package/dist/components/MobileLayout.d.ts.map +1 -0
  27. package/dist/components/Modal.d.ts +78 -1
  28. package/dist/components/Modal.d.ts.map +1 -1
  29. package/dist/components/PageHeader.d.ts +86 -0
  30. package/dist/components/PageHeader.d.ts.map +1 -0
  31. package/dist/components/PullToRefresh.d.ts +87 -0
  32. package/dist/components/PullToRefresh.d.ts.map +1 -0
  33. package/dist/components/QueryTransparency.d.ts +1 -1
  34. package/dist/components/QueryTransparency.d.ts.map +1 -1
  35. package/dist/components/SearchableList.d.ts +83 -0
  36. package/dist/components/SearchableList.d.ts.map +1 -0
  37. package/dist/components/Select.d.ts +16 -2
  38. package/dist/components/Select.d.ts.map +1 -1
  39. package/dist/components/Sidebar.d.ts +40 -1
  40. package/dist/components/Sidebar.d.ts.map +1 -1
  41. package/dist/components/SwipeActions.d.ts +93 -0
  42. package/dist/components/SwipeActions.d.ts.map +1 -0
  43. package/dist/components/Switch.d.ts +1 -0
  44. package/dist/components/Switch.d.ts.map +1 -1
  45. package/dist/components/Textarea.d.ts +13 -0
  46. package/dist/components/Textarea.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +31 -3
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/context/MobileContext.d.ts +168 -0
  50. package/dist/context/MobileContext.d.ts.map +1 -0
  51. package/dist/hooks/useResponsive.d.ts +158 -0
  52. package/dist/hooks/useResponsive.d.ts.map +1 -0
  53. package/dist/index.d.ts +1871 -51
  54. package/dist/index.esm.js +3025 -196
  55. package/dist/index.esm.js.map +1 -1
  56. package/dist/index.js +3063 -194
  57. package/dist/index.js.map +1 -1
  58. package/dist/styles.css +434 -1
  59. package/dist/types/index.d.ts +2 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/package.json +1 -1
  62. package/src/components/ActionBar.stories.tsx +246 -0
  63. package/src/components/ActionBar.tsx +242 -0
  64. package/src/components/BottomNavigation.stories.tsx +142 -0
  65. package/src/components/BottomNavigation.tsx +225 -0
  66. package/src/components/Checkbox.stories.tsx +162 -0
  67. package/src/components/Checkbox.tsx +22 -6
  68. package/src/components/CheckboxList.stories.tsx +311 -0
  69. package/src/components/CheckboxList.tsx +433 -0
  70. package/src/components/Chip.stories.tsx +389 -0
  71. package/src/components/Chip.tsx +182 -3
  72. package/src/components/ConfirmDialog.tsx +56 -4
  73. package/src/components/DataTable.tsx +60 -1
  74. package/src/components/DataTableCardView.stories.tsx +307 -0
  75. package/src/components/DataTableCardView.tsx +419 -0
  76. package/src/components/ExpandablePanel.stories.tsx +620 -0
  77. package/src/components/ExpandablePanel.tsx +383 -0
  78. package/src/components/FloatingActionButton.stories.tsx +197 -0
  79. package/src/components/FloatingActionButton.tsx +301 -0
  80. package/src/components/Grid.stories.tsx +16 -16
  81. package/src/components/Input.stories.tsx +214 -0
  82. package/src/components/Input.tsx +81 -4
  83. package/src/components/MobileHeader.stories.tsx +205 -0
  84. package/src/components/MobileHeader.tsx +233 -0
  85. package/src/components/MobileLayout.stories.tsx +338 -0
  86. package/src/components/MobileLayout.tsx +313 -0
  87. package/src/components/Modal.stories.tsx +388 -0
  88. package/src/components/Modal.tsx +122 -4
  89. package/src/components/PageHeader.stories.tsx +198 -0
  90. package/src/components/PageHeader.tsx +217 -0
  91. package/src/components/PullToRefresh.stories.tsx +321 -0
  92. package/src/components/PullToRefresh.tsx +294 -0
  93. package/src/components/QueryTransparency.tsx +1 -1
  94. package/src/components/SearchableList.stories.tsx +437 -0
  95. package/src/components/SearchableList.tsx +326 -0
  96. package/src/components/Select.stories.tsx +190 -0
  97. package/src/components/Select.tsx +353 -137
  98. package/src/components/Sidebar.tsx +193 -10
  99. package/src/components/SwipeActions.stories.tsx +327 -0
  100. package/src/components/SwipeActions.tsx +387 -0
  101. package/src/components/Switch.stories.tsx +158 -0
  102. package/src/components/Switch.tsx +12 -3
  103. package/src/components/Textarea.tsx +31 -1
  104. package/src/components/index.ts +69 -3
  105. package/src/context/MobileContext.tsx +296 -0
  106. package/src/hooks/useResponsive.ts +360 -0
  107. package/src/types/index.ts +4 -0
  108. 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 virtual scrolling
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 clear functionality.
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 && searchInputRef.current) {
292
- searchInputRef.current.focus();
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="h-4 w-4" />
613
+ <X className={`${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'}`} />
353
614
  </button>
354
615
  )}
355
- <ChevronDown className={`h-4 w-4 text-ink-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
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
- {loading ? (
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">