import { useState, useEffect, useCallback } from 'react' import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' import { useDebounce } from '@/hooks/useDebounce' import { cn } from '@/lib/utils' import Button from '@/components/ui/Button' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/Command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' export interface Option { value: string label: string disabled?: boolean description?: string icon?: React.ReactNode } export interface AsyncSelectProps { /** Async function to fetch options */ fetcher: (query?: string) => Promise /** Preload all data ahead of time */ preload?: boolean /** Function to filter options */ filterFn?: (option: T, query: string) => boolean /** Function to render each option */ renderOption: (option: T) => React.ReactNode /** Function to get the value from an option */ getOptionValue: (option: T) => string /** Function to get the display value for the selected option */ getDisplayValue: (option: T) => React.ReactNode /** Custom not found message */ notFound?: React.ReactNode /** Custom loading skeleton */ loadingSkeleton?: React.ReactNode /** Currently selected value */ value: string /** Callback when selection changes */ onChange: (value: string) => void /** Label for the select field */ label: string /** Placeholder text when no selection */ placeholder?: string /** Disable the entire select */ disabled?: boolean /** Custom width for the popover * width?: string | number /** Custom class names */ className?: string /** Custom trigger button class names */ triggerClassName?: string /** Custom search input class names */ searchInputClassName?: string /** Custom no results message */ noResultsMessage?: string /** Custom trigger tooltip */ triggerTooltip?: string /** Allow clearing the selection */ clearable?: boolean } export function AsyncSelect({ fetcher, preload, filterFn, renderOption, getOptionValue, getDisplayValue, notFound, loadingSkeleton, label, placeholder = 'Select...', value, onChange, disabled = false, className, triggerClassName, searchInputClassName, noResultsMessage, triggerTooltip, clearable = true }: AsyncSelectProps) { const [mounted, setMounted] = useState(false) const [open, setOpen] = useState(false) const [options, setOptions] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [selectedValue, setSelectedValue] = useState(value) const [selectedOption, setSelectedOption] = useState(null) const [searchTerm, setSearchTerm] = useState('') const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150) const [originalOptions, setOriginalOptions] = useState([]) useEffect(() => { setMounted(true) setSelectedValue(value) }, [value]) // Initialize selectedOption when options are loaded and value exists useEffect(() => { if (value && options.length > 0) { const option = options.find((opt) => getOptionValue(opt) === value) if (option) { setSelectedOption(option) } } }, [value, options, getOptionValue]) // Effect for initial fetch useEffect(() => { const initializeOptions = async () => { try { setLoading(true) setError(null) // If we have a value, use it for the initial search const data = await fetcher(value) setOriginalOptions(data) setOptions(data) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch options') } finally { setLoading(false) } } if (!mounted) { initializeOptions() } }, [mounted, fetcher, value]) useEffect(() => { const fetchOptions = async () => { try { setLoading(true) setError(null) const data = await fetcher(debouncedSearchTerm) setOriginalOptions(data) setOptions(data) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch options') } finally { setLoading(false) } } if (!mounted) { fetchOptions() } else if (!preload) { fetchOptions() } else if (preload) { if (debouncedSearchTerm) { setOptions( originalOptions.filter((option) => filterFn ? filterFn(option, debouncedSearchTerm) : true ) ) } else { setOptions(originalOptions) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [fetcher, debouncedSearchTerm, mounted, preload, filterFn]) const handleSelect = useCallback( (currentValue: string) => { const newValue = clearable && currentValue === selectedValue ? '' : currentValue setSelectedValue(newValue) setSelectedOption(options.find((option) => getOptionValue(option) === newValue) || null) onChange(newValue) setOpen(false) }, [selectedValue, onChange, clearable, options, getOptionValue] ) return ( e.preventDefault()}>
{ setSearchTerm(value) }} className={searchInputClassName} /> {loading && options.length > 0 && (
)}
{error &&
{error}
} {loading && options.length === 0 && (loadingSkeleton || )} {!loading && !error && options.length === 0 && (notFound || ( {noResultsMessage ?? `No ${label.toLowerCase()} found.`} ))} {options.map((option) => ( {renderOption(option)} ))}
) } function DefaultLoadingSkeleton() { return (
) }