File size: 7,097 Bytes
467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 467ab64 3578090 422ad46 3578090 920a7ad 3578090 467ab64 3578090 920a7ad 3578090 920a7ad 3578090 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 |
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> {
/** Async function to fetch options */
fetcher: (query?: string) => Promise<T[]>
/** 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
/** Custom not found message */
notFound?: React.ReactNode
/** Custom loading skeleton */
loadingSkeleton?: React.ReactNode
/** Currently selected value */
value: string | null
/** Callback when selection changes */
onChange: (value: string) => void
/** Callback when focus changes */
onFocus: (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 no results message */
noResultsMessage?: string
/** Allow clearing the selection */
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)
}, [])
// Handle clicks outside of the component
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])
// Load options when search term changes
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])
// Load initial value
useEffect(() => {
if (!mounted || !value) return
fetchOptions(value)
}, [mounted, value, fetchOptions])
const handleSelect = useCallback((currentValue: string) => {
onChange(currentValue)
requestAnimationFrame(() => {
// Blur the input to ensure focus event triggers on next click
const input = document.activeElement as HTMLElement
input?.blur()
// Close the dropdown
setOpen(false)
})
}, [onChange])
const handleFocus = useCallback(() => {
setOpen(true)
// Use current search term to fetch options
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>
)
}
|