@papernote/ui 1.13.0 → 1.14.1

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.
@@ -1,7 +1,14 @@
1
- import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle, useId } from 'react';
2
- import { Check, ChevronDown, Search, Loader2, X } from 'lucide-react';
3
- import { createPortal } from 'react-dom';
4
- import { useIsMobile } from '../hooks/useResponsive';
1
+ import React, {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ forwardRef,
6
+ useImperativeHandle,
7
+ useId,
8
+ } from "react";
9
+ import { Check, ChevronDown, Search, Loader2, X } from "lucide-react";
10
+ import { createPortal } from "react-dom";
11
+ import { useIsMobile } from "../hooks/useResponsive";
5
12
 
6
13
  /**
7
14
  * Single option in a select dropdown
@@ -80,9 +87,9 @@ export interface SelectProps {
80
87
  /** Height of each option row in pixels (default: 42) */
81
88
  virtualItemHeight?: number;
82
89
  /** Size of the select trigger - 'lg' provides 44px touch-friendly target */
83
- size?: 'sm' | 'md' | 'lg';
90
+ size?: "sm" | "md" | "lg";
84
91
  /** Mobile display mode - 'auto' uses BottomSheet on mobile, 'dropdown' always uses dropdown, 'native' uses native select on mobile */
85
- mobileMode?: 'auto' | 'dropdown' | 'native';
92
+ mobileMode?: "auto" | "dropdown" | "native";
86
93
  /** Render dropdown via portal (default: true). Set to false when overflow clipping is not an issue */
87
94
  usePortal?: boolean;
88
95
  /** Whether this field is required */
@@ -91,16 +98,16 @@ export interface SelectProps {
91
98
 
92
99
  // Size classes for trigger button
93
100
  const sizeClasses = {
94
- sm: 'h-8 text-sm py-1',
95
- md: 'h-10 text-base py-2',
96
- lg: 'h-12 text-base py-3 min-h-touch', // 44px touch target
101
+ sm: "h-8 text-sm py-1",
102
+ md: "h-10 text-base py-2",
103
+ lg: "h-12 text-base py-3 min-h-touch", // 44px touch target
97
104
  };
98
105
 
99
106
  // Size classes for options
100
107
  const optionSizeClasses = {
101
- sm: 'py-2 text-sm',
102
- md: 'py-2.5 text-sm',
103
- lg: 'py-3.5 text-base min-h-touch', // 44px touch target for mobile
108
+ sm: "py-2 text-sm",
109
+ md: "py-2.5 text-sm",
110
+ lg: "py-3.5 text-base min-h-touch", // 44px touch target for mobile
104
111
  };
105
112
 
106
113
  /**
@@ -173,14 +180,13 @@ const optionSizeClasses = {
173
180
  * />
174
181
  * ```
175
182
  */
176
- const Select = forwardRef<SelectHandle, SelectProps>(
177
- (props, ref) => {
183
+ const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => {
178
184
  const {
179
185
  options = [],
180
186
  groups = [],
181
187
  value,
182
188
  onChange,
183
- placeholder = 'Select an option',
189
+ placeholder = "Select an option",
184
190
  searchable = false,
185
191
  disabled = false,
186
192
  label,
@@ -191,18 +197,23 @@ const Select = forwardRef<SelectHandle, SelectProps>(
191
197
  creatable = false,
192
198
  onCreateOption,
193
199
  virtualized = false,
194
- virtualHeight = '300px',
200
+ virtualHeight = "300px",
195
201
  virtualItemHeight = 42,
196
- size = 'md',
197
- mobileMode = 'auto',
202
+ size = "md",
203
+ mobileMode = "auto",
198
204
  usePortal = true,
199
205
  required = false,
200
206
  } = props;
201
207
  const [isOpen, setIsOpen] = useState(false);
202
- const [searchQuery, setSearchQuery] = useState('');
208
+ const [searchQuery, setSearchQuery] = useState("");
203
209
  const [scrollTop, setScrollTop] = useState(0);
204
210
  const [activeDescendant] = useState<string | undefined>(undefined);
205
- const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number; width: number; placement: 'bottom' | 'top' } | null>(null);
211
+ const [dropdownPosition, setDropdownPosition] = useState<{
212
+ top: number;
213
+ left: number;
214
+ width: number;
215
+ placement: "bottom" | "top";
216
+ } | null>(null);
206
217
  const selectRef = useRef<HTMLDivElement>(null);
207
218
  const buttonRef = useRef<HTMLButtonElement>(null);
208
219
  const dropdownRef = useRef<HTMLDivElement>(null);
@@ -213,12 +224,12 @@ const Select = forwardRef<SelectHandle, SelectProps>(
213
224
 
214
225
  // Detect mobile viewport
215
226
  const isMobile = useIsMobile();
216
- const useMobileSheet = mobileMode === 'auto' && isMobile;
217
- const useNativeSelect = mobileMode === 'native' && isMobile;
218
-
227
+ const useMobileSheet = mobileMode === "auto" && isMobile;
228
+ const useNativeSelect = mobileMode === "native" && isMobile;
229
+
219
230
  // Auto-size for mobile
220
- const effectiveSize = isMobile && size === 'md' ? 'lg' : size;
221
-
231
+ const effectiveSize = isMobile && size === "md" ? "lg" : size;
232
+
222
233
  // Generate unique IDs for ARIA
223
234
  const labelId = useId();
224
235
  const listboxId = useId();
@@ -234,12 +245,9 @@ const Select = forwardRef<SelectHandle, SelectProps>(
234
245
  }));
235
246
 
236
247
  // Flatten all options (from both options and groups)
237
- const allOptions = [
238
- ...options,
239
- ...groups.flatMap(group => group.options)
240
- ];
248
+ const allOptions = [...options, ...groups.flatMap((group) => group.options)];
241
249
 
242
- const selectedOption = allOptions.find(opt => opt.value === value);
250
+ const selectedOption = allOptions.find((opt) => opt.value === value);
243
251
 
244
252
  // Filter options/groups based on search
245
253
  const getFilteredData = () => {
@@ -250,27 +258,29 @@ const Select = forwardRef<SelectHandle, SelectProps>(
250
258
  const query = searchQuery.toLowerCase();
251
259
 
252
260
  // Filter flat options
253
- const filteredOptions = options.filter(opt =>
254
- opt.label.toLowerCase().includes(query)
261
+ const filteredOptions = options.filter((opt) =>
262
+ opt.label.toLowerCase().includes(query),
255
263
  );
256
264
 
257
265
  // Filter grouped options
258
266
  const filteredGroups = groups
259
- .map(group => ({
267
+ .map((group) => ({
260
268
  ...group,
261
- options: group.options.filter(opt =>
262
- opt.label.toLowerCase().includes(query)
263
- )
269
+ options: group.options.filter((opt) =>
270
+ opt.label.toLowerCase().includes(query),
271
+ ),
264
272
  }))
265
- .filter(group => group.options.length > 0);
273
+ .filter((group) => group.options.length > 0);
266
274
 
267
275
  return { options: filteredOptions, groups: filteredGroups };
268
276
  };
269
277
 
270
- const { options: filteredOptions, groups: filteredGroups } = getFilteredData();
278
+ const { options: filteredOptions, groups: filteredGroups } =
279
+ getFilteredData();
271
280
 
272
281
  // Virtual scrolling calculations
273
- const totalItems = filteredOptions.length + filteredGroups.flatMap(g => g.options).length;
282
+ const totalItems =
283
+ filteredOptions.length + filteredGroups.flatMap((g) => g.options).length;
274
284
  const useVirtualScrolling = virtualized && totalItems > 50;
275
285
 
276
286
  const visibleRangeStart = useVirtualScrolling
@@ -278,32 +288,54 @@ const Select = forwardRef<SelectHandle, SelectProps>(
278
288
  : 0;
279
289
  const visibleRangeEnd = useVirtualScrolling
280
290
  ? Math.min(
281
- visibleRangeStart + Math.ceil(parseInt(virtualHeight) / virtualItemHeight) + 5,
282
- totalItems
291
+ visibleRangeStart +
292
+ Math.ceil(parseInt(virtualHeight) / virtualItemHeight) +
293
+ 5,
294
+ totalItems,
283
295
  )
284
296
  : totalItems;
285
297
 
286
298
  // Flatten all filtered items for virtualization
287
299
  const allFilteredItems = [
288
- ...filteredOptions.map((opt, idx) => ({ type: 'option' as const, option: opt, groupIndex: -1, optionIndex: idx })),
300
+ ...filteredOptions.map((opt, idx) => ({
301
+ type: "option" as const,
302
+ option: opt,
303
+ groupIndex: -1,
304
+ optionIndex: idx,
305
+ })),
289
306
  ...filteredGroups.flatMap((group, groupIdx) =>
290
- group.options.map((opt, optIdx) => ({ type: 'grouped' as const, option: opt, groupIndex: groupIdx, optionIndex: optIdx, groupLabel: group.label }))
291
- )
307
+ group.options.map((opt, optIdx) => ({
308
+ type: "grouped" as const,
309
+ option: opt,
310
+ groupIndex: groupIdx,
311
+ optionIndex: optIdx,
312
+ groupLabel: group.label,
313
+ })),
314
+ ),
292
315
  ];
293
316
 
294
317
  const visibleItems = useVirtualScrolling
295
318
  ? allFilteredItems.slice(visibleRangeStart, visibleRangeEnd)
296
319
  : allFilteredItems;
297
320
 
298
- const offsetY = useVirtualScrolling ? visibleRangeStart * virtualItemHeight : 0;
299
- const totalHeight = useVirtualScrolling ? totalItems * virtualItemHeight : 'auto';
321
+ const offsetY = useVirtualScrolling
322
+ ? visibleRangeStart * virtualItemHeight
323
+ : 0;
324
+ const totalHeight = useVirtualScrolling
325
+ ? totalItems * virtualItemHeight
326
+ : "auto";
300
327
 
301
328
  // Check if we should show "Create" option
302
- const showCreateOption = creatable &&
303
- searchQuery.trim() !== '' &&
304
- !filteredOptions.some(opt => opt.label.toLowerCase() === searchQuery.toLowerCase()) &&
305
- !filteredGroups.some(group =>
306
- group.options.some(opt => opt.label.toLowerCase() === searchQuery.toLowerCase())
329
+ const showCreateOption =
330
+ creatable &&
331
+ searchQuery.trim() !== "" &&
332
+ !filteredOptions.some(
333
+ (opt) => opt.label.toLowerCase() === searchQuery.toLowerCase(),
334
+ ) &&
335
+ !filteredGroups.some((group) =>
336
+ group.options.some(
337
+ (opt) => opt.label.toLowerCase() === searchQuery.toLowerCase(),
338
+ ),
307
339
  );
308
340
 
309
341
  // Handle creating new option
@@ -314,7 +346,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
314
346
  // If no callback, just select the typed value
315
347
  onChange?.(searchQuery.trim());
316
348
  }
317
- setSearchQuery('');
349
+ setSearchQuery("");
318
350
  setIsOpen(false);
319
351
  };
320
352
 
@@ -325,21 +357,23 @@ const Select = forwardRef<SelectHandle, SelectProps>(
325
357
  const handleClickOutside = (event: MouseEvent) => {
326
358
  const target = event.target as Node;
327
359
  // Check if click is outside both the select trigger and the dropdown portal
328
- const isOutsideSelect = selectRef.current && !selectRef.current.contains(target);
329
- const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target);
360
+ const isOutsideSelect =
361
+ selectRef.current && !selectRef.current.contains(target);
362
+ const isOutsideDropdown =
363
+ dropdownRef.current && !dropdownRef.current.contains(target);
330
364
 
331
365
  if (isOutsideSelect && isOutsideDropdown) {
332
366
  setIsOpen(false);
333
- setSearchQuery('');
367
+ setSearchQuery("");
334
368
  }
335
369
  };
336
370
 
337
371
  if (isOpen) {
338
- document.addEventListener('mousedown', handleClickOutside);
372
+ document.addEventListener("mousedown", handleClickOutside);
339
373
  }
340
374
 
341
375
  return () => {
342
- document.removeEventListener('mousedown', handleClickOutside);
376
+ document.removeEventListener("mousedown", handleClickOutside);
343
377
  };
344
378
  }, [isOpen, useMobileSheet]);
345
379
 
@@ -377,11 +411,13 @@ const Select = forwardRef<SelectHandle, SelectProps>(
377
411
  const hasSpaceAbove = spaceAbove >= dropdownHeight + gap;
378
412
 
379
413
  // Prefer bottom placement, flip to top if not enough space below but enough above
380
- const placement: 'bottom' | 'top' = hasSpaceBelow || !hasSpaceAbove ? 'bottom' : 'top';
414
+ const placement: "bottom" | "top" =
415
+ hasSpaceBelow || !hasSpaceAbove ? "bottom" : "top";
381
416
 
382
- const top = placement === 'bottom'
383
- ? rect.bottom + gap
384
- : rect.top - dropdownHeight - gap;
417
+ const top =
418
+ placement === "bottom"
419
+ ? rect.bottom + gap
420
+ : rect.top - dropdownHeight - gap;
385
421
 
386
422
  setDropdownPosition({
387
423
  top,
@@ -395,24 +431,24 @@ const Select = forwardRef<SelectHandle, SelectProps>(
395
431
  updatePosition();
396
432
 
397
433
  // Listen for scroll events on all scrollable ancestors
398
- window.addEventListener('scroll', updatePosition, true);
399
- window.addEventListener('resize', updatePosition);
434
+ window.addEventListener("scroll", updatePosition, true);
435
+ window.addEventListener("resize", updatePosition);
400
436
 
401
437
  return () => {
402
- window.removeEventListener('scroll', updatePosition, true);
403
- window.removeEventListener('resize', updatePosition);
438
+ window.removeEventListener("scroll", updatePosition, true);
439
+ window.removeEventListener("resize", updatePosition);
404
440
  };
405
441
  }, [isOpen, useMobileSheet, usePortal]);
406
442
 
407
443
  // Lock body scroll when mobile sheet is open
408
444
  useEffect(() => {
409
445
  if (useMobileSheet && isOpen) {
410
- document.body.style.overflow = 'hidden';
446
+ document.body.style.overflow = "hidden";
411
447
  } else {
412
- document.body.style.overflow = '';
448
+ document.body.style.overflow = "";
413
449
  }
414
450
  return () => {
415
- document.body.style.overflow = '';
451
+ document.body.style.overflow = "";
416
452
  };
417
453
  }, [isOpen, useMobileSheet]);
418
454
 
@@ -421,29 +457,33 @@ const Select = forwardRef<SelectHandle, SelectProps>(
421
457
  if (!useMobileSheet || !isOpen) return;
422
458
 
423
459
  const handleEscape = (e: KeyboardEvent) => {
424
- if (e.key === 'Escape') {
460
+ if (e.key === "Escape") {
425
461
  setIsOpen(false);
426
- setSearchQuery('');
462
+ setSearchQuery("");
427
463
  }
428
464
  };
429
465
 
430
- document.addEventListener('keydown', handleEscape);
431
- return () => document.removeEventListener('keydown', handleEscape);
466
+ document.addEventListener("keydown", handleEscape);
467
+ return () => document.removeEventListener("keydown", handleEscape);
432
468
  }, [isOpen, useMobileSheet]);
433
469
 
434
470
  const handleSelect = (optionValue: string) => {
435
471
  onChange?.(optionValue);
436
472
  setIsOpen(false);
437
- setSearchQuery('');
473
+ setSearchQuery("");
438
474
  };
439
475
 
440
476
  const handleClose = () => {
441
477
  setIsOpen(false);
442
- setSearchQuery('');
478
+ setSearchQuery("");
443
479
  };
444
480
 
445
481
  // Render option button (shared between desktop and mobile)
446
- const renderOption = (option: SelectOption, isSelected: boolean, mobile = false) => (
482
+ const renderOption = (
483
+ option: SelectOption,
484
+ isSelected: boolean,
485
+ mobile = false,
486
+ ) => (
447
487
  <button
448
488
  key={option.value}
449
489
  type="button"
@@ -452,8 +492,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
452
492
  className={`
453
493
  w-full flex items-center justify-between px-4 transition-colors
454
494
  ${mobile ? optionSizeClasses.lg : optionSizeClasses[effectiveSize]}
455
- ${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
456
- ${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 active:bg-paper-100 cursor-pointer'}
495
+ ${isSelected ? "bg-accent-50 text-accent-900" : "text-ink-700"}
496
+ ${option.disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-paper-50 active:bg-paper-100 cursor-pointer"}
457
497
  `}
458
498
  role="option"
459
499
  aria-selected={isSelected}
@@ -462,7 +502,11 @@ const Select = forwardRef<SelectHandle, SelectProps>(
462
502
  {option.icon && <span>{option.icon}</span>}
463
503
  {option.label}
464
504
  </span>
465
- {isSelected && <Check className={`${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600`} />}
505
+ {isSelected && (
506
+ <Check
507
+ className={`${mobile ? "h-5 w-5" : "h-4 w-4"} text-accent-600`}
508
+ />
509
+ )}
466
510
  </button>
467
511
  );
468
512
 
@@ -470,16 +514,28 @@ const Select = forwardRef<SelectHandle, SelectProps>(
470
514
  const renderOptionsContent = (mobile = false) => {
471
515
  if (loading) {
472
516
  return (
473
- <div className="px-4 py-8 flex items-center justify-center" role="status" aria-live="polite">
517
+ <div
518
+ className="px-4 py-8 flex items-center justify-center"
519
+ role="status"
520
+ aria-live="polite"
521
+ >
474
522
  <Loader2 className="h-5 w-5 animate-spin text-ink-500" />
475
523
  <span className="ml-2 text-sm text-ink-500">Loading...</span>
476
524
  </div>
477
525
  );
478
526
  }
479
-
480
- if (filteredOptions.length === 0 && filteredGroups.length === 0 && !showCreateOption) {
527
+
528
+ if (
529
+ filteredOptions.length === 0 &&
530
+ filteredGroups.length === 0 &&
531
+ !showCreateOption
532
+ ) {
481
533
  return (
482
- <div className="px-4 py-3 text-sm text-ink-500 text-center" role="status" aria-live="polite">
534
+ <div
535
+ className="px-4 py-3 text-sm text-ink-500 text-center"
536
+ role="status"
537
+ aria-live="polite"
538
+ >
483
539
  No options found
484
540
  </div>
485
541
  );
@@ -494,7 +550,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
494
550
  onClick={handleCreateOption}
495
551
  className={`
496
552
  w-full flex items-center px-4 text-accent-700 hover:bg-accent-50 transition-colors border-b border-paper-200
497
- ${mobile ? 'py-3.5 text-base' : 'py-2.5 text-sm'}
553
+ ${mobile ? "py-3.5 text-base" : "py-2.5 text-sm"}
498
554
  `}
499
555
  >
500
556
  <span className="font-medium">Create "{searchQuery}"</span>
@@ -503,7 +559,7 @@ const Select = forwardRef<SelectHandle, SelectProps>(
503
559
 
504
560
  {/* Virtual scrolling container */}
505
561
  {useVirtualScrolling ? (
506
- <div style={{ height: totalHeight, position: 'relative' }}>
562
+ <div style={{ height: totalHeight, position: "relative" }}>
507
563
  <div style={{ transform: `translateY(${offsetY}px)` }}>
508
564
  {visibleItems.map((item) => {
509
565
  const option = item.option;
@@ -514,14 +570,18 @@ const Select = forwardRef<SelectHandle, SelectProps>(
514
570
  <button
515
571
  key={key}
516
572
  type="button"
517
- onClick={() => !option.disabled && handleSelect(option.value)}
573
+ onClick={() =>
574
+ !option.disabled && handleSelect(option.value)
575
+ }
518
576
  disabled={option.disabled}
519
- style={{ height: mobile ? '56px' : `${virtualItemHeight}px` }}
577
+ style={{
578
+ height: mobile ? "56px" : `${virtualItemHeight}px`,
579
+ }}
520
580
  className={`
521
581
  w-full flex items-center justify-between px-4 transition-colors
522
- ${mobile ? 'text-base' : 'text-sm'}
523
- ${isSelected ? 'bg-accent-50 text-accent-900' : 'text-ink-700'}
524
- ${option.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-paper-50 cursor-pointer'}
582
+ ${mobile ? "text-base" : "text-sm"}
583
+ ${isSelected ? "bg-accent-50 text-accent-900" : "text-ink-700"}
584
+ ${option.disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-paper-50 cursor-pointer"}
525
585
  `}
526
586
  role="option"
527
587
  aria-selected={isSelected}
@@ -530,7 +590,11 @@ const Select = forwardRef<SelectHandle, SelectProps>(
530
590
  {option.icon && <span>{option.icon}</span>}
531
591
  {option.label}
532
592
  </span>
533
- {isSelected && <Check className={`${mobile ? 'h-5 w-5' : 'h-4 w-4'} text-accent-600`} />}
593
+ {isSelected && (
594
+ <Check
595
+ className={`${mobile ? "h-5 w-5" : "h-4 w-4"} text-accent-600`}
596
+ />
597
+ )}
534
598
  </button>
535
599
  );
536
600
  })}
@@ -539,21 +603,27 @@ const Select = forwardRef<SelectHandle, SelectProps>(
539
603
  ) : (
540
604
  <>
541
605
  {/* Render flat options */}
542
- {filteredOptions.map((option) => renderOption(option, option.value === value, mobile))}
606
+ {filteredOptions.map((option) =>
607
+ renderOption(option, option.value === value, mobile),
608
+ )}
543
609
 
544
610
  {/* Render grouped options */}
545
611
  {filteredGroups.map((group) => (
546
612
  <div key={group.label}>
547
613
  {/* Group Header */}
548
- <div className={`
614
+ <div
615
+ className={`
549
616
  px-4 font-semibold text-ink-500 uppercase tracking-wider bg-paper-50 border-t border-b border-paper-200
550
- ${mobile ? 'py-2.5 text-xs' : 'py-2 text-xs'}
551
- `}>
617
+ ${mobile ? "py-2.5 text-xs" : "py-2 text-xs"}
618
+ `}
619
+ >
552
620
  {group.label}
553
621
  </div>
554
622
 
555
623
  {/* Group Options */}
556
- {group.options.map((option) => renderOption(option, option.value === value, mobile))}
624
+ {group.options.map((option) =>
625
+ renderOption(option, option.value === value, mobile),
626
+ )}
557
627
  </div>
558
628
  ))}
559
629
  </>
@@ -576,21 +646,25 @@ const Select = forwardRef<SelectHandle, SelectProps>(
576
646
  <div className="relative">
577
647
  <select
578
648
  ref={nativeSelectRef}
579
- value={value || ''}
649
+ value={value || ""}
580
650
  onChange={(e) => onChange?.(e.target.value)}
581
651
  disabled={disabled}
582
652
  className={`
583
653
  input w-full appearance-none pr-10
584
654
  ${sizeClasses[effectiveSize]}
585
- ${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
586
- ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
655
+ ${error ? "border-error-400 focus:border-error-400 focus:ring-error-400" : ""}
656
+ ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}
587
657
  `}
588
658
  aria-labelledby={label ? labelId : undefined}
589
- aria-invalid={error ? 'true' : undefined}
590
- aria-describedby={error ? errorId : (helperText ? helperTextId : undefined)}
659
+ aria-invalid={error ? "true" : undefined}
660
+ aria-describedby={
661
+ error ? errorId : helperText ? helperTextId : undefined
662
+ }
591
663
  aria-required={required}
592
664
  >
593
- <option value="" disabled>{placeholder}</option>
665
+ <option value="" disabled>
666
+ {placeholder}
667
+ </option>
594
668
  {options.map((opt) => (
595
669
  <option key={opt.value} value={opt.value} disabled={opt.disabled}>
596
670
  {opt.label}
@@ -599,7 +673,11 @@ const Select = forwardRef<SelectHandle, SelectProps>(
599
673
  {groups.map((group) => (
600
674
  <optgroup key={group.label} label={group.label}>
601
675
  {group.options.map((opt) => (
602
- <option key={opt.value} value={opt.value} disabled={opt.disabled}>
676
+ <option
677
+ key={opt.value}
678
+ value={opt.value}
679
+ disabled={opt.disabled}
680
+ >
603
681
  {opt.label}
604
682
  </option>
605
683
  ))}
@@ -610,7 +688,12 @@ const Select = forwardRef<SelectHandle, SelectProps>(
610
688
  </div>
611
689
 
612
690
  {error && (
613
- <p id={errorId} className="mt-2 text-xs text-error-600" role="alert" aria-live="assertive">
691
+ <p
692
+ id={errorId}
693
+ className="mt-2 text-xs text-error-600"
694
+ role="alert"
695
+ aria-live="assertive"
696
+ >
614
697
  {error}
615
698
  </p>
616
699
  )}
@@ -644,8 +727,8 @@ const Select = forwardRef<SelectHandle, SelectProps>(
644
727
  className={`
645
728
  input w-full flex items-center justify-between px-3
646
729
  ${sizeClasses[effectiveSize]}
647
- ${error ? 'border-error-400 focus:border-error-400 focus:ring-error-400' : ''}
648
- ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
730
+ ${error ? "border-error-400 focus:border-error-400 focus:ring-error-400" : ""}
731
+ ${disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}
649
732
  `}
650
733
  role="combobox"
651
734
  aria-haspopup="listbox"
@@ -654,87 +737,117 @@ const Select = forwardRef<SelectHandle, SelectProps>(
654
737
  aria-labelledby={label ? labelId : undefined}
655
738
  aria-label={!label ? placeholder : undefined}
656
739
  aria-activedescendant={activeDescendant}
657
- aria-invalid={error ? 'true' : undefined}
658
- aria-describedby={error ? errorId : (helperText ? helperTextId : undefined)}
740
+ aria-invalid={error ? "true" : undefined}
741
+ aria-describedby={
742
+ error ? errorId : helperText ? helperTextId : undefined
743
+ }
659
744
  aria-disabled={disabled}
660
745
  aria-required={required}
661
746
  >
662
- <span className={`flex items-center gap-2 ${selectedOption ? 'text-ink-800' : 'text-ink-400'}`}>
663
- {loading && <Loader2 className="h-4 w-4 animate-spin text-ink-500" />}
664
- {!loading && selectedOption?.icon && <span>{selectedOption.icon}</span>}
747
+ <span
748
+ className={`flex items-center gap-2 ${selectedOption ? "text-ink-800" : "text-ink-400"}`}
749
+ >
750
+ {loading && (
751
+ <Loader2 className="h-4 w-4 animate-spin text-ink-500" />
752
+ )}
753
+ {!loading && selectedOption?.icon && (
754
+ <span>{selectedOption.icon}</span>
755
+ )}
665
756
  {selectedOption ? selectedOption.label : placeholder}
666
757
  </span>
667
758
  <div className="flex items-center gap-1">
668
759
  {clearable && value && (
669
- <button
670
- type="button"
760
+ <span
761
+ role="button"
762
+ tabIndex={-1}
671
763
  onClick={(e) => {
672
764
  e.stopPropagation();
673
- onChange?.('');
765
+ onChange?.("");
674
766
  setIsOpen(false);
675
767
  }}
676
- className="text-ink-400 hover:text-ink-600 transition-colors p-0.5"
768
+ onKeyDown={(e) => {
769
+ if (e.key === "Enter" || e.key === " ") {
770
+ e.preventDefault();
771
+ e.stopPropagation();
772
+ onChange?.("");
773
+ setIsOpen(false);
774
+ }
775
+ }}
776
+ className="text-ink-400 hover:text-ink-600 transition-colors p-0.5 cursor-pointer inline-flex"
677
777
  aria-label="Clear selection"
678
778
  >
679
- <X className={`${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'}`} />
680
- </button>
779
+ <X
780
+ className={`${effectiveSize === "lg" ? "h-5 w-5" : "h-4 w-4"}`}
781
+ />
782
+ </span>
681
783
  )}
682
- <ChevronDown className={`${effectiveSize === 'lg' ? 'h-5 w-5' : 'h-4 w-4'} text-ink-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
784
+ <ChevronDown
785
+ className={`${effectiveSize === "lg" ? "h-5 w-5" : "h-4 w-4"} text-ink-500 transition-transform ${isOpen ? "rotate-180" : ""}`}
786
+ />
683
787
  </div>
684
788
  </button>
685
-
686
789
  </div>
687
790
 
688
791
  {/* Desktop Dropdown - rendered via portal to avoid overflow clipping */}
689
- {isOpen && !useMobileSheet && (usePortal ? dropdownPosition : true) && (
690
- usePortal ? createPortal(
691
- <div
692
- ref={dropdownRef}
693
- className={`fixed z-[9999] bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in ${
694
- dropdownPosition?.placement === 'top' ? 'origin-bottom' : 'origin-top'
695
- }`}
696
- style={{
697
- top: dropdownPosition!.top,
698
- left: dropdownPosition!.left,
699
- width: dropdownPosition!.width,
700
- }}
701
- >
702
- {/* Search Input */}
703
- {searchable && (
704
- <div className="p-2 border-b border-paper-200">
705
- <div className="relative">
706
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
707
- <input
708
- ref={searchInputRef}
709
- type="text"
710
- value={searchQuery}
711
- onChange={(e) => setSearchQuery(e.target.value)}
712
- placeholder="Search..."
713
- className="w-full pl-9 pr-3 py-2 text-sm border border-paper-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400"
714
- role="searchbox"
715
- aria-label="Search options"
716
- aria-autocomplete="list"
717
- aria-controls={listboxId}
718
- />
719
- </div>
720
- </div>
721
- )}
722
-
723
- {/* Options List */}
792
+ {isOpen &&
793
+ !useMobileSheet &&
794
+ (usePortal ? dropdownPosition : true) &&
795
+ (usePortal ? (
796
+ createPortal(
724
797
  <div
725
- ref={listRef}
726
- id={listboxId}
727
- className="overflow-y-auto"
728
- style={{ maxHeight: useVirtualScrolling ? virtualHeight : '12rem' }}
729
- onScroll={(e) => useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)}
730
- role="listbox"
731
- aria-label="Available options"
732
- aria-multiselectable="false"
798
+ ref={dropdownRef}
799
+ className={`fixed z-[9999] bg-white bg-subtle-grain rounded-lg shadow-lg border border-paper-200 max-h-60 overflow-hidden animate-fade-in ${
800
+ dropdownPosition?.placement === "top"
801
+ ? "origin-bottom"
802
+ : "origin-top"
803
+ }`}
804
+ style={{
805
+ top: dropdownPosition!.top,
806
+ left: dropdownPosition!.left,
807
+ width: dropdownPosition!.width,
808
+ }}
733
809
  >
734
- {renderOptionsContent(false)}
735
- </div>
736
- </div>,
737
- document.body
810
+ {/* Search Input */}
811
+ {searchable && (
812
+ <div className="p-2 border-b border-paper-200">
813
+ <div className="relative">
814
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
815
+ <input
816
+ ref={searchInputRef}
817
+ type="text"
818
+ value={searchQuery}
819
+ onChange={(e) => setSearchQuery(e.target.value)}
820
+ placeholder="Search..."
821
+ className="w-full pl-9 pr-3 py-2 text-sm border border-paper-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-400 focus:border-accent-400"
822
+ role="searchbox"
823
+ aria-label="Search options"
824
+ aria-autocomplete="list"
825
+ aria-controls={listboxId}
826
+ />
827
+ </div>
828
+ </div>
829
+ )}
830
+
831
+ {/* Options List */}
832
+ <div
833
+ ref={listRef}
834
+ id={listboxId}
835
+ className="overflow-y-auto"
836
+ style={{
837
+ maxHeight: useVirtualScrolling ? virtualHeight : "12rem",
838
+ }}
839
+ onScroll={(e) =>
840
+ useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)
841
+ }
842
+ role="listbox"
843
+ aria-label="Available options"
844
+ aria-multiselectable="false"
845
+ >
846
+ {renderOptionsContent(false)}
847
+ </div>
848
+ </div>,
849
+ document.body,
850
+ )
738
851
  ) : (
739
852
  // Non-portal dropdown (inline, relative positioning)
740
853
  <div
@@ -767,8 +880,12 @@ const Select = forwardRef<SelectHandle, SelectProps>(
767
880
  ref={listRef}
768
881
  id={listboxId}
769
882
  className="overflow-y-auto"
770
- style={{ maxHeight: useVirtualScrolling ? virtualHeight : '12rem' }}
771
- onScroll={(e) => useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)}
883
+ style={{
884
+ maxHeight: useVirtualScrolling ? virtualHeight : "12rem",
885
+ }}
886
+ onScroll={(e) =>
887
+ useVirtualScrolling && setScrollTop(e.currentTarget.scrollTop)
888
+ }
772
889
  role="listbox"
773
890
  aria-label="Available options"
774
891
  aria-multiselectable="false"
@@ -776,91 +893,100 @@ const Select = forwardRef<SelectHandle, SelectProps>(
776
893
  {renderOptionsContent(false)}
777
894
  </div>
778
895
  </div>
779
- )
780
- )}
896
+ ))}
781
897
 
782
898
  {/* Mobile Bottom Sheet */}
783
- {isOpen && useMobileSheet && createPortal(
784
- <div
785
- className="fixed inset-0 z-50 flex items-end"
786
- onClick={(e) => e.target === e.currentTarget && handleClose()}
787
- role="dialog"
788
- aria-modal="true"
789
- aria-labelledby={label ? `mobile-${labelId}` : undefined}
790
- >
791
- {/* Backdrop */}
792
- <div className="absolute inset-0 bg-black/50 animate-fade-in" />
793
-
794
- {/* Sheet */}
899
+ {isOpen &&
900
+ useMobileSheet &&
901
+ createPortal(
795
902
  <div
796
- className="relative w-full bg-white rounded-t-2xl shadow-2xl animate-slide-up max-h-[85vh] flex flex-col"
797
- style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
903
+ className="fixed inset-0 z-50 flex items-end"
904
+ onClick={(e) => e.target === e.currentTarget && handleClose()}
905
+ role="dialog"
906
+ aria-modal="true"
907
+ aria-labelledby={label ? `mobile-${labelId}` : undefined}
798
908
  >
799
- {/* Handle */}
800
- <div className="py-3 cursor-grab">
801
- <div className="w-12 h-1.5 bg-ink-300 rounded-full mx-auto" />
802
- </div>
909
+ {/* Backdrop */}
910
+ <div className="absolute inset-0 bg-black/50 animate-fade-in" />
803
911
 
804
- {/* Header */}
805
- <div className="px-4 pb-3 border-b border-paper-200 flex items-center justify-between">
806
- {label && (
807
- <h2 id={`mobile-${labelId}`} className="text-lg font-semibold text-ink-900">
808
- {label}
809
- </h2>
810
- )}
811
- {!label && (
812
- <h2 className="text-lg font-semibold text-ink-900">
813
- {placeholder}
814
- </h2>
815
- )}
816
- <button
817
- onClick={handleClose}
818
- className="text-ink-400 hover:text-ink-600 transition-colors p-2 -mr-2"
819
- aria-label="Close"
820
- >
821
- <X className="h-5 w-5" />
822
- </button>
823
- </div>
912
+ {/* Sheet */}
913
+ <div
914
+ className="relative w-full bg-white rounded-t-2xl shadow-2xl animate-slide-up max-h-[85vh] flex flex-col"
915
+ style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
916
+ >
917
+ {/* Handle */}
918
+ <div className="py-3 cursor-grab">
919
+ <div className="w-12 h-1.5 bg-ink-300 rounded-full mx-auto" />
920
+ </div>
824
921
 
825
- {/* Search Input (Mobile) */}
826
- {searchable && (
827
- <div className="p-3 border-b border-paper-200">
828
- <div className="relative">
829
- <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-400" />
830
- <input
831
- ref={mobileSearchInputRef}
832
- type="text"
833
- value={searchQuery}
834
- onChange={(e) => setSearchQuery(e.target.value)}
835
- placeholder="Search..."
836
- inputMode="search"
837
- enterKeyHint="search"
838
- 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"
839
- role="searchbox"
840
- aria-label="Search options"
841
- />
842
- </div>
922
+ {/* Header */}
923
+ <div className="px-4 pb-3 border-b border-paper-200 flex items-center justify-between">
924
+ {label && (
925
+ <h2
926
+ id={`mobile-${labelId}`}
927
+ className="text-lg font-semibold text-ink-900"
928
+ >
929
+ {label}
930
+ </h2>
931
+ )}
932
+ {!label && (
933
+ <h2 className="text-lg font-semibold text-ink-900">
934
+ {placeholder}
935
+ </h2>
936
+ )}
937
+ <button
938
+ onClick={handleClose}
939
+ className="text-ink-400 hover:text-ink-600 transition-colors p-2 -mr-2"
940
+ aria-label="Close"
941
+ >
942
+ <X className="h-5 w-5" />
943
+ </button>
843
944
  </div>
844
- )}
845
945
 
846
- {/* Options List (Mobile) */}
847
- <div
848
- id={listboxId}
849
- className="overflow-y-auto flex-1"
850
- role="listbox"
851
- aria-label="Available options"
852
- aria-multiselectable="false"
853
- >
854
- {renderOptionsContent(true)}
946
+ {/* Search Input (Mobile) */}
947
+ {searchable && (
948
+ <div className="p-3 border-b border-paper-200">
949
+ <div className="relative">
950
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-ink-400" />
951
+ <input
952
+ ref={mobileSearchInputRef}
953
+ type="text"
954
+ value={searchQuery}
955
+ onChange={(e) => setSearchQuery(e.target.value)}
956
+ placeholder="Search..."
957
+ inputMode="search"
958
+ enterKeyHint="search"
959
+ 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"
960
+ role="searchbox"
961
+ aria-label="Search options"
962
+ />
963
+ </div>
964
+ </div>
965
+ )}
966
+
967
+ {/* Options List (Mobile) */}
968
+ <div
969
+ id={listboxId}
970
+ className="overflow-y-auto flex-1"
971
+ role="listbox"
972
+ aria-label="Available options"
973
+ aria-multiselectable="false"
974
+ >
975
+ {renderOptionsContent(true)}
976
+ </div>
855
977
  </div>
856
- </div>
857
- </div>,
858
- document.body
859
- )}
978
+ </div>,
979
+ document.body,
980
+ )}
860
981
 
861
982
  {/* Helper Text or Error */}
862
983
  {error && (
863
- <p id={errorId} className="mt-2 text-xs text-error-600" role="alert" aria-live="assertive">
984
+ <p
985
+ id={errorId}
986
+ className="mt-2 text-xs text-error-600"
987
+ role="alert"
988
+ aria-live="assertive"
989
+ >
864
990
  {error}
865
991
  </p>
866
992
  )}
@@ -873,5 +999,5 @@ const Select = forwardRef<SelectHandle, SelectProps>(
873
999
  );
874
1000
  });
875
1001
 
876
- Select.displayName = 'Select';
1002
+ Select.displayName = "Select";
877
1003
  export default Select;