|
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<T> { |
|
|
|
fetcher: (query?: string) => Promise<T[]> |
|
|
|
preload?: boolean |
|
|
|
filterFn?: (option: T, query: string) => boolean |
|
|
|
renderOption: (option: T) => React.ReactNode |
|
|
|
getOptionValue: (option: T) => string |
|
|
|
getDisplayValue: (option: T) => React.ReactNode |
|
|
|
notFound?: React.ReactNode |
|
|
|
loadingSkeleton?: React.ReactNode |
|
|
|
value: string |
|
|
|
onChange: (value: string) => void |
|
|
|
label: string |
|
|
|
placeholder?: string |
|
|
|
disabled?: boolean |
|
|
|
|
|
|
|
className?: string |
|
|
|
triggerClassName?: string |
|
|
|
searchInputClassName?: string |
|
|
|
noResultsMessage?: string |
|
|
|
triggerTooltip?: string |
|
|
|
clearable?: boolean |
|
} |
|
|
|
export function AsyncSelect<T>({ |
|
fetcher, |
|
preload, |
|
filterFn, |
|
renderOption, |
|
getOptionValue, |
|
getDisplayValue, |
|
notFound, |
|
loadingSkeleton, |
|
label, |
|
placeholder = 'Select...', |
|
value, |
|
onChange, |
|
disabled = false, |
|
className, |
|
triggerClassName, |
|
searchInputClassName, |
|
noResultsMessage, |
|
triggerTooltip, |
|
clearable = true |
|
}: AsyncSelectProps<T>) { |
|
const [mounted, setMounted] = useState(false) |
|
const [open, setOpen] = useState(false) |
|
const [options, setOptions] = useState<T[]>([]) |
|
const [loading, setLoading] = useState(false) |
|
const [error, setError] = useState<string | null>(null) |
|
const [selectedValue, setSelectedValue] = useState(value) |
|
const [selectedOption, setSelectedOption] = useState<T | null>(null) |
|
const [searchTerm, setSearchTerm] = useState('') |
|
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150) |
|
const [originalOptions, setOriginalOptions] = useState<T[]>([]) |
|
const [initialValueDisplay, setInitialValueDisplay] = useState<React.ReactNode | null>(null) |
|
|
|
useEffect(() => { |
|
setMounted(true) |
|
setSelectedValue(value) |
|
}, [value]) |
|
|
|
|
|
useEffect(() => { |
|
if (value && (!options.length || !selectedOption)) { |
|
|
|
setInitialValueDisplay(<div>{value}</div>) |
|
} else if (selectedOption) { |
|
|
|
setInitialValueDisplay(null) |
|
} |
|
}, [value, options.length, selectedOption]) |
|
|
|
|
|
useEffect(() => { |
|
if (value && options.length > 0) { |
|
const option = options.find((opt) => getOptionValue(opt) === value) |
|
if (option) { |
|
setSelectedOption(option) |
|
} |
|
} |
|
}, [value, options, getOptionValue]) |
|
|
|
|
|
useEffect(() => { |
|
const initializeOptions = async () => { |
|
try { |
|
setLoading(true) |
|
setError(null) |
|
|
|
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) |
|
} |
|
} |
|
|
|
}, [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 ( |
|
<Popover open={open} onOpenChange={setOpen}> |
|
<PopoverTrigger asChild> |
|
<Button |
|
variant="outline" |
|
role="combobox" |
|
aria-expanded={open} |
|
className={cn( |
|
'justify-between', |
|
disabled && 'cursor-not-allowed opacity-50', |
|
triggerClassName |
|
)} |
|
disabled={disabled} |
|
tooltip={triggerTooltip} |
|
side="bottom" |
|
> |
|
{value === '*' ? <div>*</div> : (selectedOption ? getDisplayValue(selectedOption) : (initialValueDisplay || placeholder))} |
|
<ChevronsUpDown className="opacity-50" size={10} /> |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent |
|
className={cn('p-0', className)} |
|
onCloseAutoFocus={(e) => e.preventDefault()} |
|
align="start" |
|
sideOffset={8} |
|
collisionPadding={5} |
|
> |
|
<Command shouldFilter={false}> |
|
<div className="relative w-full border-b"> |
|
<CommandInput |
|
placeholder={`Search ${label.toLowerCase()}...`} |
|
value={searchTerm} |
|
onValueChange={(value) => { |
|
setSearchTerm(value) |
|
}} |
|
className={searchInputClassName} |
|
/> |
|
{loading && options.length > 0 && ( |
|
<div className="absolute top-1/2 right-2 flex -translate-y-1/2 transform items-center"> |
|
<Loader2 className="h-4 w-4 animate-spin" /> |
|
</div> |
|
)} |
|
</div> |
|
<CommandList> |
|
{error && <div className="text-destructive p-4 text-center">{error}</div>} |
|
{loading && options.length === 0 && (loadingSkeleton || <DefaultLoadingSkeleton />)} |
|
{!loading && |
|
!error && |
|
options.length === 0 && |
|
(notFound || ( |
|
<CommandEmpty> |
|
{noResultsMessage ?? `No ${label.toLowerCase()} found.`} |
|
</CommandEmpty> |
|
))} |
|
<CommandGroup> |
|
{options.map((option) => ( |
|
<CommandItem |
|
key={getOptionValue(option)} |
|
value={getOptionValue(option)} |
|
onSelect={handleSelect} |
|
className="truncate" |
|
> |
|
{renderOption(option)} |
|
<Check |
|
className={cn( |
|
'ml-auto h-3 w-3', |
|
selectedValue === getOptionValue(option) ? 'opacity-100' : 'opacity-0' |
|
)} |
|
/> |
|
</CommandItem> |
|
))} |
|
</CommandGroup> |
|
</CommandList> |
|
</Command> |
|
</PopoverContent> |
|
</Popover> |
|
) |
|
} |
|
|
|
function DefaultLoadingSkeleton() { |
|
return ( |
|
<CommandGroup> |
|
<CommandItem disabled> |
|
<div className="flex w-full items-center gap-2"> |
|
<div className="bg-muted h-6 w-6 animate-pulse rounded-full" /> |
|
<div className="flex flex-1 flex-col gap-1"> |
|
<div className="bg-muted h-4 w-24 animate-pulse rounded" /> |
|
<div className="bg-muted h-3 w-16 animate-pulse rounded" /> |
|
</div> |
|
</div> |
|
</CommandItem> |
|
</CommandGroup> |
|
) |
|
} |
|
|