| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- import * as React from 'react'
- import { cn, fuzzyMatch } from '@/lib/utils'
- import { Button } from '@/components/ui/button'
- import { Input } from '@/components/ui/input'
- import {
- Popover,
- PopoverContent,
- PopoverTrigger,
- } from '@/components/ui/popover'
- interface SearchableSelectOption {
- value: string
- label: string
- }
- interface SearchableSelectProps {
- value?: string
- onValueChange: (value: string) => void
- options: SearchableSelectOption[]
- placeholder?: string
- searchPlaceholder?: string
- emptyMessage?: string
- className?: string
- disabled?: boolean
- }
- export function SearchableSelect({
- value,
- onValueChange,
- options,
- placeholder = 'Select...',
- searchPlaceholder = 'Search...',
- emptyMessage = 'No results found',
- className,
- disabled,
- }: SearchableSelectProps) {
- const [open, setOpen] = React.useState(false)
- const [search, setSearch] = React.useState('')
- // Find the selected option's label
- const selectedOption = options.find((opt) => opt.value === value)
- // Filter options based on search (fuzzy matching: spaces, underscores, hyphens are equivalent)
- const filteredOptions = React.useMemo(() => {
- if (!search) return options
- return options.filter(
- (opt) => fuzzyMatch(opt.label, search) || fuzzyMatch(opt.value, search)
- )
- }, [options, search])
- const handleSelect = (selectedValue: string) => {
- onValueChange(selectedValue)
- setOpen(false)
- setSearch('')
- }
- return (
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="secondary"
- role="combobox"
- aria-expanded={open}
- disabled={disabled}
- className={cn(
- 'w-full justify-between font-normal',
- !value && 'text-muted-foreground',
- className
- )}
- >
- <span className="truncate">
- {selectedOption?.label || placeholder}
- </span>
- <span className="material-icons text-base ml-2 shrink-0 opacity-50">
- unfold_more
- </span>
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
- <div className="flex flex-col">
- {/* Search input */}
- <div className="p-2 border-b">
- <Input
- placeholder={searchPlaceholder}
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- className="h-8"
- autoFocus
- />
- </div>
- {/* Options list */}
- <div className="max-h-[200px] overflow-y-auto">
- {filteredOptions.length === 0 ? (
- <div className="py-6 text-center text-sm text-muted-foreground">
- {emptyMessage}
- </div>
- ) : (
- filteredOptions.map((option) => (
- <button
- key={option.value}
- type="button"
- className={cn(
- 'w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground flex items-center gap-2',
- value === option.value && 'bg-accent'
- )}
- onClick={() => handleSelect(option.value)}
- >
- <span
- className={cn(
- 'material-icons text-base',
- value === option.value ? 'opacity-100' : 'opacity-0'
- )}
- >
- check
- </span>
- <span className="truncate">{option.label}</span>
- </button>
- ))
- )}
- </div>
- </div>
- </PopoverContent>
- </Popover>
- )
- }
|