|
import React, { useState, useEffect, useCallback, useRef } from 'react' |
|
import { Loader2 } from 'lucide-react' |
|
import { useDebounce } from '@/hooks/useDebounce' |
|
|
|
import { cn } from '@/lib/utils' |
|
import { |
|
Command, |
|
CommandEmpty, |
|
CommandGroup, |
|
CommandInput, |
|
CommandItem, |
|
CommandList |
|
} from '@/components/ui/Command' |
|
|
|
export interface Option { |
|
value: string |
|
label: string |
|
disabled?: boolean |
|
description?: string |
|
icon?: React.ReactNode |
|
} |
|
|
|
export interface AsyncSearchProps<T> { |
|
|
|
fetcher: (query?: string) => Promise<T[]> |
|
|
|
preload?: boolean |
|
|
|
filterFn?: (option: T, query: string) => boolean |
|
|
|
renderOption: (option: T) => React.ReactNode |
|
|
|
getOptionValue: (option: T) => string |
|
|
|
notFound?: React.ReactNode |
|
|
|
loadingSkeleton?: React.ReactNode |
|
|
|
value: string | null |
|
|
|
onChange: (value: string) => void |
|
|
|
onFocus: (value: string) => void |
|
|
|
label: string |
|
|
|
placeholder?: string |
|
|
|
disabled?: boolean |
|
|
|
width?: string | number |
|
|
|
className?: string |
|
|
|
triggerClassName?: string |
|
|
|
noResultsMessage?: string |
|
|
|
clearable?: boolean |
|
} |
|
|
|
export function AsyncSearch<T>({ |
|
fetcher, |
|
preload, |
|
filterFn, |
|
renderOption, |
|
getOptionValue, |
|
notFound, |
|
loadingSkeleton, |
|
label, |
|
placeholder = 'Select...', |
|
value, |
|
onChange, |
|
onFocus, |
|
disabled = false, |
|
className, |
|
noResultsMessage |
|
}: AsyncSearchProps<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 [searchTerm, setSearchTerm] = useState('') |
|
const debouncedSearchTerm = useDebounce(searchTerm, preload ? 0 : 150) |
|
const containerRef = useRef<HTMLDivElement>(null) |
|
|
|
useEffect(() => { |
|
setMounted(true) |
|
}, []) |
|
|
|
|
|
useEffect(() => { |
|
const handleClickOutside = (event: MouseEvent) => { |
|
if ( |
|
containerRef.current && |
|
!containerRef.current.contains(event.target as Node) && |
|
open |
|
) { |
|
setOpen(false) |
|
} |
|
} |
|
|
|
document.addEventListener('mousedown', handleClickOutside) |
|
return () => { |
|
document.removeEventListener('mousedown', handleClickOutside) |
|
} |
|
}, [open]) |
|
|
|
const fetchOptions = useCallback(async (query: string) => { |
|
try { |
|
setLoading(true) |
|
setError(null) |
|
const data = await fetcher(query) |
|
setOptions(data) |
|
} catch (err) { |
|
setError(err instanceof Error ? err.message : 'Failed to fetch options') |
|
} finally { |
|
setLoading(false) |
|
} |
|
}, [fetcher]) |
|
|
|
|
|
useEffect(() => { |
|
if (!mounted) return |
|
|
|
if (preload) { |
|
if (debouncedSearchTerm) { |
|
setOptions((prev) => |
|
prev.filter((option) => |
|
filterFn ? filterFn(option, debouncedSearchTerm) : true |
|
) |
|
) |
|
} |
|
} else { |
|
fetchOptions(debouncedSearchTerm) |
|
} |
|
}, [mounted, debouncedSearchTerm, preload, filterFn, fetchOptions]) |
|
|
|
|
|
useEffect(() => { |
|
if (!mounted || !value) return |
|
fetchOptions(value) |
|
}, [mounted, value, fetchOptions]) |
|
|
|
const handleSelect = useCallback((currentValue: string) => { |
|
onChange(currentValue) |
|
requestAnimationFrame(() => { |
|
|
|
const input = document.activeElement as HTMLElement |
|
input?.blur() |
|
|
|
setOpen(false) |
|
}) |
|
}, [onChange]) |
|
|
|
const handleFocus = useCallback(() => { |
|
setOpen(true) |
|
|
|
fetchOptions(searchTerm) |
|
}, [searchTerm, fetchOptions]) |
|
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => { |
|
const target = e.target as HTMLElement |
|
if (target.closest('.cmd-item')) { |
|
e.preventDefault() |
|
} |
|
}, []) |
|
|
|
return ( |
|
<div |
|
ref={containerRef} |
|
className={cn(disabled && 'cursor-not-allowed opacity-50', className)} |
|
onMouseDown={handleMouseDown} |
|
> |
|
<Command shouldFilter={false} className="bg-transparent"> |
|
<div> |
|
<CommandInput |
|
placeholder={placeholder} |
|
value={searchTerm} |
|
className="max-h-8" |
|
onFocus={handleFocus} |
|
onValueChange={(value) => { |
|
setSearchTerm(value) |
|
if (!open) setOpen(true) |
|
}} |
|
/> |
|
{loading && ( |
|
<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 hidden={!open}> |
|
{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, idx) => ( |
|
<React.Fragment key={getOptionValue(option) + `-fragment-${idx}`}> |
|
<CommandItem |
|
key={getOptionValue(option) + `${idx}`} |
|
value={getOptionValue(option)} |
|
onSelect={handleSelect} |
|
onMouseMove={() => onFocus(getOptionValue(option))} |
|
className="truncate cmd-item" |
|
> |
|
{renderOption(option)} |
|
</CommandItem> |
|
{idx !== options.length - 1 && ( |
|
<div key={`divider-${idx}`} className="bg-foreground/10 h-[1px]" /> |
|
)} |
|
</React.Fragment> |
|
))} |
|
</CommandGroup> |
|
</CommandList> |
|
</Command> |
|
</div> |
|
) |
|
} |
|
|
|
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> |
|
) |
|
} |
|
|