searchable-select.tsx 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. import * as React from 'react'
  2. import { cn, fuzzyMatch } from '@/lib/utils'
  3. import { Button } from '@/components/ui/button'
  4. import { Input } from '@/components/ui/input'
  5. import {
  6. Popover,
  7. PopoverContent,
  8. PopoverTrigger,
  9. } from '@/components/ui/popover'
  10. interface SearchableSelectOption {
  11. value: string
  12. label: string
  13. }
  14. interface SearchableSelectProps {
  15. value?: string
  16. onValueChange: (value: string) => void
  17. options: SearchableSelectOption[]
  18. placeholder?: string
  19. searchPlaceholder?: string
  20. emptyMessage?: string
  21. className?: string
  22. disabled?: boolean
  23. }
  24. export function SearchableSelect({
  25. value,
  26. onValueChange,
  27. options,
  28. placeholder = 'Select...',
  29. searchPlaceholder = 'Search...',
  30. emptyMessage = 'No results found',
  31. className,
  32. disabled,
  33. }: SearchableSelectProps) {
  34. const [open, setOpen] = React.useState(false)
  35. const [search, setSearch] = React.useState('')
  36. // Find the selected option's label
  37. const selectedOption = options.find((opt) => opt.value === value)
  38. // Filter options based on search (fuzzy matching: spaces, underscores, hyphens are equivalent)
  39. const filteredOptions = React.useMemo(() => {
  40. if (!search) return options
  41. return options.filter(
  42. (opt) => fuzzyMatch(opt.label, search) || fuzzyMatch(opt.value, search)
  43. )
  44. }, [options, search])
  45. const handleSelect = (selectedValue: string) => {
  46. onValueChange(selectedValue)
  47. setOpen(false)
  48. setSearch('')
  49. }
  50. return (
  51. <Popover open={open} onOpenChange={setOpen}>
  52. <PopoverTrigger asChild>
  53. <Button
  54. variant="secondary"
  55. role="combobox"
  56. aria-expanded={open}
  57. disabled={disabled}
  58. className={cn(
  59. 'w-full justify-between font-normal',
  60. !value && 'text-muted-foreground',
  61. className
  62. )}
  63. >
  64. <span className="truncate">
  65. {selectedOption?.label || placeholder}
  66. </span>
  67. <span className="material-icons text-base ml-2 shrink-0 opacity-50">
  68. unfold_more
  69. </span>
  70. </Button>
  71. </PopoverTrigger>
  72. <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
  73. <div className="flex flex-col">
  74. {/* Search input */}
  75. <div className="p-2 border-b">
  76. <Input
  77. placeholder={searchPlaceholder}
  78. value={search}
  79. onChange={(e) => setSearch(e.target.value)}
  80. className="h-8"
  81. autoFocus
  82. />
  83. </div>
  84. {/* Options list */}
  85. <div className="max-h-[200px] overflow-y-auto">
  86. {filteredOptions.length === 0 ? (
  87. <div className="py-6 text-center text-sm text-muted-foreground">
  88. {emptyMessage}
  89. </div>
  90. ) : (
  91. filteredOptions.map((option) => (
  92. <button
  93. key={option.value}
  94. type="button"
  95. className={cn(
  96. 'w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground flex items-center gap-2',
  97. value === option.value && 'bg-accent'
  98. )}
  99. onClick={() => handleSelect(option.value)}
  100. >
  101. <span
  102. className={cn(
  103. 'material-icons text-base',
  104. value === option.value ? 'opacity-100' : 'opacity-0'
  105. )}
  106. >
  107. check
  108. </span>
  109. <span className="truncate">{option.label}</span>
  110. </button>
  111. ))
  112. )}
  113. </div>
  114. </div>
  115. </PopoverContent>
  116. </Popover>
  117. )
  118. }